2024-08-15 16:43:13 +03:00
// mautrix-discord - A Matrix-Discord puppeting bridge.
2026-01-08 16:55:34 -08:00
// Copyright (C) 2026 Tulir Asokan
2024-08-15 16:43:13 +03:00
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
2026-05-13 01:54:48 -07:00
"errors"
2026-02-09 14:33:56 -08:00
"fmt"
2026-03-30 21:42:52 -07:00
"maps"
2026-03-05 14:05:35 -08:00
"math"
2026-10-29 13:59:58 +00:00
"net/http"
2026-02-16 21:00:15 -08:00
"strings"
2026-01-08 16:11:26 -08:00
"time"
2024-08-15 16:43:13 +03:00
2025-12-16 18:35:29 -08:00
"github.com/bwmarrin/discordgo"
2026-01-08 16:11:26 -08:00
"github.com/rs/zerolog"
2026-10-05 18:26:10 +03:00
"go.mau.fi/util/ptr"
"go.mau.fi/util/variationselector"
2024-08-15 16:43:13 +03:00
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
2026-10-14 10:59:51 +00:00
"maunium.net/go/mautrix/bridgev2/networkid"
2026-02-25 07:53:21 -08:00
"maunium.net/go/mautrix/event"
2026-01-09 17:08:33 -08:00
"go.mau.fi/mautrix-discord/pkg/discordid"
2024-08-15 16:43:13 +03:00
)
var (
2026-10-29 13:59:58 +00:00
_ bridgev2 . ReactionHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . RedactionHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . EditHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . ReadReceiptHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . TypingHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . MuteHandlingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . UserBlockingNetworkAPI = ( * DiscordClient ) ( nil )
_ bridgev2 . MessageRequestAcceptingNetworkAPI = ( * DiscordClient ) ( nil )
2024-08-15 16:43:13 +03:00
)
2026-03-30 21:42:52 -07:00
type contextKey int
const (
contextKeyChannel contextKey = iota
)
type SendAttempt struct {
At time . Time
ChannelType discordgo . ChannelType
RecipientRelationshipType * discordgo . RelationshipType
}
2025-12-16 18:35:29 -08:00
func ( d * DiscordClient ) HandleMatrixMessage ( ctx context . Context , msg * bridgev2 . MatrixMessage ) ( * bridgev2 . MatrixMessageResponse , error ) {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
2025-12-16 18:35:29 -08:00
return nil , bridgev2 . ErrNotLoggedIn
}
2026-03-10 20:21:33 -07:00
log := zerolog . Ctx ( ctx ) . With ( ) . Str ( "action" , "matrix message send" ) . Logger ( )
ctx = log . WithContext ( ctx )
2025-12-16 18:35:29 -08:00
portal := msg . Portal
2026-01-09 17:08:33 -08:00
guildID := portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
2026-02-25 07:53:21 -08:00
parentChannelID := discordid . ParseChannelPortalID ( portal . ID )
channelID := parentChannelID
threadChannelID := ""
2026-02-25 10:10:56 -08:00
threadRootRemoteID := getMatrixThreadRootRemoteMessageID ( msg . ThreadRoot )
2026-02-25 07:53:21 -08:00
if threadRootRemoteID != "" {
thread , err := d . getThreadByRootMessageID ( ctx , threadRootRemoteID )
if err != nil {
return nil , err
}
if thread != nil {
threadChannelID = thread . ThreadChannelID
} else if guildID != "" {
var startErr error
threadChannelID , startErr = d . startThreadFromMatrix ( ctx , guildID , parentChannelID , threadRootRemoteID , getThreadName ( msg . Content ) )
if startErr != nil {
// If creating the thread failed, try resolving it once more in case it already exists.
thread , err = d . getThreadByRootMessageID ( ctx , threadRootRemoteID )
if err != nil {
return nil , err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
} else {
return nil , fmt . Errorf ( "failed to create Discord thread from Matrix message: %w" , startErr )
}
}
}
}
if threadChannelID != "" {
channelID = threadChannelID
}
2026-02-25 10:10:56 -08:00
refererOpt := makeDiscordReferer ( guildID , parentChannelID , threadChannelID )
2025-12-16 18:35:29 -08:00
2026-05-13 01:54:48 -07:00
ch := d . channelWithID ( ctx , channelID )
ctx = context . WithValue ( ctx , contextKeyChannel , ch )
// Perform any required screening before making any requests to Discord at
// all (message conversion does).
if err := d . screenOutgoingMessage ( ctx , ch ) ; err != nil {
return nil , err
}
2026-02-25 07:53:21 -08:00
sendReq , err := d . connector . MsgConv . ToDiscord ( ctx , d . Session , msg , channelID , refererOpt )
2025-12-16 18:35:29 -08:00
if err != nil {
2026-10-29 13:10:50 +00:00
return nil , d . tryWrappingError ( ctx , err )
2025-12-16 18:35:29 -08:00
}
2026-02-25 07:53:21 -08:00
if sendReq . Reference != nil && sendReq . Reference . ChannelID == parentChannelID && threadChannelID != "" {
sendReq . Reference . ChannelID = threadChannelID
}
2025-12-16 18:35:29 -08:00
2026-03-30 21:42:52 -07:00
if ch != nil {
2026-05-13 01:54:48 -07:00
var relType * discordgo . RelationshipType
if rel := d . relationshipWithDMRecipient ( ch ) ; rel != nil {
relType = & rel . Type
}
2026-03-30 21:42:52 -07:00
if channelIsPrivate ( ch ) {
// NOTE: These analytics are so that we can get some data on what's
// causing Discord to disable/restrict/ban accounts. For message
// attempts, we only send these for DMs at the moment.
2026-05-13 01:54:48 -07:00
//
// (This fires a goroutine internally so it won't block.)
2026-03-30 21:42:52 -07:00
d . sendOutgoingMessageAttemptAnalytics ( ctx , map [ string ] any {
"messageFlags" : sendReq . Flags ,
"messageType" : sendReq . Type ,
"hasAttachments" : len ( sendReq . Attachments ) > 0 ,
"hasEmbeds" : len ( sendReq . Embeds ) > 0 ,
"isReplying" : sendReq . Reference != nil && sendReq . Reference . Type == discordgo . MessageReferenceTypeDefault ,
} )
}
2026-03-30 21:42:52 -07:00
d . lastSendAttemptMutex . Lock ( )
d . lastSendAttempt = & SendAttempt {
At : time . Now ( ) ,
ChannelType : ch . Type ,
RecipientRelationshipType : relType ,
}
d . lastSendAttemptMutex . Unlock ( )
}
2026-10-14 10:59:51 +00:00
txnID := networkid . TransactionID ( sendReq . Nonce )
pendingMsg := & database . Message { }
if threadRootRemoteID != "" {
pendingMsg . ThreadRoot = discordid . MakeMessageID ( threadRootRemoteID )
}
msg . AddPendingToSave ( pendingMsg , txnID , nil )
2026-03-30 21:42:52 -07:00
sentMsg , err := d . Session . ChannelMessageSendComplex ( channelID , sendReq , refererOpt , discordgo . WithContext ( ctx ) )
2025-12-16 18:35:29 -08:00
if err != nil {
2026-10-14 10:59:51 +00:00
var restErr * discordgo . RESTError
if errors . As ( err , & restErr ) {
msg . RemovePending ( txnID )
}
2026-03-10 20:21:33 -07:00
return nil , d . tryWrappingError ( ctx , err )
2025-12-16 18:35:29 -08:00
}
sentMsgTimestamp , _ := discordgo . SnowflakeTimestamp ( sentMsg . ID )
2026-02-25 07:53:21 -08:00
dbMessage := & database . Message {
ID : discordid . MakeMessageID ( sentMsg . ID ) ,
SenderID : discordid . MakeUserID ( sentMsg . Author . ID ) ,
Timestamp : sentMsgTimestamp ,
}
if threadRootRemoteID != "" {
dbMessage . ThreadRoot = discordid . MakeMessageID ( threadRootRemoteID )
}
2025-12-16 18:35:29 -08:00
return & bridgev2 . MatrixMessageResponse {
2026-10-14 10:59:51 +00:00
DB : dbMessage ,
RemovePending : txnID ,
2025-12-16 18:35:29 -08:00
} , nil
2024-08-15 16:43:13 +03:00
}
2026-10-29 13:59:58 +00:00
var (
errCannotDMStranger = errors . New ( "can't direct message a stranger" )
errPendingMessageRequest = errors . New ( "message request hasn't been accepted" )
errMessageRequestNotAccepted = errors . New ( "failed to accept message request" )
)
2026-05-13 01:54:48 -07:00
func ( d * DiscordClient ) screenOutgoingMessage ( ctx context . Context , destCh * discordgo . Channel ) error {
log := zerolog . Ctx ( ctx )
2026-10-29 13:59:58 +00:00
if channelIsPendingMessageRequest ( destCh ) {
log . Info ( ) . Msg ( "Preventing send into a message request that hasn't been accepted" )
return bridgev2 . WrapErrorInStatus ( errPendingMessageRequest ) .
WithStatus ( event . MessageStatusFail ) .
WithIsCertain ( true ) .
WithMessage ( "Accept the message request before replying." ) .
WithSendNotice ( true )
}
2026-05-13 01:54:48 -07:00
if d . connector . Config . ForbidDMingStrangersEnabled ( ) {
dmRecipID := dmChannelRecipientID ( destCh )
if dmRecipID != nil {
rel := d . relationshipWithUserID ( * dmRecipID )
friendsWithDMRecip := rel != nil && rel . Type == discordgo . RelationshipFriend
dmRecip := d . userCache . Resolve ( ctx , * dmRecipID )
if dmRecip != nil && ! dmRecip . Bot && ! friendsWithDMRecip {
loggedRelType := "none"
if rel != nil {
loggedRelType = readableRelationshipType ( rel . Type )
}
2026-10-29 13:40:05 +00:00
if d . connector . Config . AllowRepliesToMessageRequestsEnabled ( ) && channelIsOrWasMessageRequest ( destCh ) {
log . Info ( ) .
Str ( "relationship_type" , loggedRelType ) .
Bool ( "is_message_request" , destCh . IsMessageRequest ) .
Msg ( "Allowing direct message send to a non-friend: channel is an incoming message request" )
return nil
}
2026-10-29 14:37:37 +00:00
vitals := d . peekVitals ( )
standingKnown := ! d . connector . Config . ReportScrubbedAccountStanding || ( vitals != nil && vitals . Safety != nil )
if d . connector . Config . AllowDMingStrangersWhenUnimpeded && standingKnown && vitals . Unimpeded ( ) {
2026-10-29 13:40:05 +00:00
log . Info ( ) .
Str ( "relationship_type" , loggedRelType ) .
Msg ( "Allowing direct message send to a stranger: account vitals are unimpeded" )
return nil
}
2026-05-13 01:54:48 -07:00
log . Info ( ) .
Str ( "relationship_type" , loggedRelType ) .
Msg ( "Preventing direct message send to a stranger" )
return bridgev2 . WrapErrorInStatus ( errCannotDMStranger ) .
WithStatus ( event . MessageStatusFail ) .
WithIsCertain ( true ) .
WithMessage ( "You can't message users who aren't on your friends list. To continue, use the Discord app to chat or add them as a friend." ) .
WithSendNotice ( true )
}
}
}
return nil
}
2026-10-29 13:59:58 +00:00
func channelIsPendingMessageRequest ( ch * discordgo . Channel ) bool {
return ch != nil && ch . Type == discordgo . ChannelTypeDM && ch . IsMessageRequest
}
func ( d * DiscordClient ) HandleMatrixAcceptMessageRequest ( ctx context . Context , msg * bridgev2 . MatrixAcceptMessageRequest ) error {
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
channelID := discordid . ParseChannelPortalID ( msg . Portal . ID )
ch := d . channelWithID ( ctx , channelID )
if ch == nil || ch . Type != discordgo . ChannelTypeDM {
return fmt . Errorf ( "%w: not a direct message" , errMessageRequestNotAccepted )
}
if ! ch . IsMessageRequest {
return nil
}
endpoint := discordgo . EndpointChannel ( channelID ) + "/recipients/@me"
_ , err := d . Session . RequestWithBucketID (
http . MethodPut ,
endpoint ,
map [ string ] int { "consent_status" : 2 } ,
endpoint ,
discordgo . WithReferer ( "https://discord.com/message-requests" ) ,
discordgo . WithContext ( ctx ) ,
)
if err != nil {
return bridgev2 . WrapErrorInStatus ( fmt . Errorf ( "%w: %w" , errMessageRequestNotAccepted , err ) ) .
WithStatus ( event . MessageStatusRetriable ) .
WithMessage ( "Couldn't accept the message request on Discord." ) .
WithSendNotice ( true )
}
d . Session . State . Lock ( )
ch . IsMessageRequest = false
d . Session . State . Unlock ( )
zerolog . Ctx ( ctx ) . Info ( ) . Bool ( "implicit" , msg . Content . IsImplicit ) . Msg ( "Accepted Discord message request" )
return nil
}
2026-03-30 21:42:52 -07:00
func ( d * DiscordClient ) sendOutgoingMessageAttemptAnalytics ( ctx context . Context , extra map [ string ] any ) {
props := d . baseAnalyticsProps ( ctx )
maps . Copy ( props , extra )
d . UserLogin . TrackAnalytics ( "Discord outgoing message attempt" , props )
}
2024-08-15 16:43:13 +03:00
func ( d * DiscordClient ) HandleMatrixEdit ( ctx context . Context , msg * bridgev2 . MatrixEdit ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
2026-02-09 14:33:56 -08:00
log := zerolog . Ctx ( ctx ) . With ( ) . Str ( "action" , "matrix message edit" ) . Logger ( )
ctx = log . WithContext ( ctx )
content , _ := d . connector . MsgConv . ConvertMatrixMessageContent (
ctx ,
msg . Portal ,
msg . Content ,
// Disregard link previews for now. Discord generally allows you to
// remove individual link previews from a message though.
[ ] string { } ,
)
2026-02-25 07:53:21 -08:00
guildID := msg . Portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
parentChannelID := discordid . ParseChannelPortalID ( msg . Portal . ID )
channelID := parentChannelID
threadChannelID := ""
if msg . EditTarget != nil && msg . EditTarget . ThreadRoot != "" {
thread , err := d . getThreadByRootMessageID ( ctx , discordid . ParseMessageID ( msg . EditTarget . ThreadRoot ) )
if err != nil {
return fmt . Errorf ( "failed to resolve target thread for message edit: %w" , err )
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
channelID = threadChannelID
}
}
2026-02-09 14:33:56 -08:00
_ , err := d . Session . ChannelMessageEdit (
2026-02-25 07:53:21 -08:00
channelID ,
2026-02-09 14:33:56 -08:00
discordid . ParseMessageID ( msg . EditTarget . ID ) ,
content ,
2026-02-25 10:10:56 -08:00
makeDiscordReferer ( guildID , parentChannelID , threadChannelID ) ,
2026-02-09 14:33:56 -08:00
)
if err != nil {
2026-03-10 20:21:33 -07:00
return d . tryWrappingError ( ctx , err )
2026-02-09 14:33:56 -08:00
}
return nil
2024-08-15 16:43:13 +03:00
}
2025-12-17 18:44:52 -08:00
func ( d * DiscordClient ) PreHandleMatrixReaction ( ctx context . Context , reaction * bridgev2 . MatrixReaction ) ( bridgev2 . MatrixReactionPreResponse , error ) {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . MatrixReactionPreResponse { } , bridgev2 . ErrNotLoggedIn
}
2026-02-16 21:00:15 -08:00
emojiID := reaction . Content . RelatesTo . Key
// Figure out if this is a custom emoji or not.
if strings . HasPrefix ( emojiID , "mxc://" ) {
customEmoji , err := d . connector . GetCustomEmojiByMXC ( ctx , emojiID )
if err != nil {
return bridgev2 . MatrixReactionPreResponse { } , fmt . Errorf ( "failed to get custom emoji by mxc: %w" , err )
} else if customEmoji == nil || customEmoji . ID == "" || customEmoji . Name == "" {
2026-10-19 14:00:49 +01:00
return bridgev2 . MatrixReactionPreResponse { } , bridgev2 . WrapErrorInStatus ( fmt . Errorf ( "unknown custom emoji mxc: %q" , emojiID ) ) .
WithMessage ( "that emoji can't be used on Discord" ) .
WithIsCertain ( true ) .
WithSendNotice ( true ) .
WithErrorReason ( event . MessageStatusUnsupported ) .
WithStatus ( event . MessageStatusFail )
2026-02-16 21:00:15 -08:00
}
emojiID = fmt . Sprintf ( "%s:%s" , customEmoji . Name , customEmoji . ID )
} else {
emojiID = variationselector . FullyQualify ( emojiID )
}
2025-12-17 18:44:52 -08:00
return bridgev2 . MatrixReactionPreResponse {
2026-02-01 21:03:17 -08:00
SenderID : discordid . UserLoginIDToUserID ( d . UserLogin . ID ) ,
2026-02-16 21:00:15 -08:00
EmojiID : discordid . MakeEmojiID ( emojiID ) ,
2025-12-17 18:44:52 -08:00
} , nil
2024-08-15 16:43:13 +03:00
}
2025-12-17 18:44:52 -08:00
func ( d * DiscordClient ) HandleMatrixReaction ( ctx context . Context , reaction * bridgev2 . MatrixReaction ) ( * database . Reaction , error ) {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return nil , bridgev2 . ErrNotLoggedIn
}
2025-12-17 18:44:52 -08:00
portal := reaction . Portal
2026-01-09 17:08:33 -08:00
meta := portal . Metadata . ( * discordid . PortalMetadata )
2026-02-25 07:53:21 -08:00
parentChannelID := discordid . ParseChannelPortalID ( portal . ID )
channelID := parentChannelID
threadChannelID := ""
if reaction . TargetMessage != nil && reaction . TargetMessage . ThreadRoot != "" {
thread , err := d . getThreadByRootMessageID ( ctx , discordid . ParseMessageID ( reaction . TargetMessage . ThreadRoot ) )
if err != nil {
return nil , err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
channelID = threadChannelID
}
}
2025-12-17 18:44:52 -08:00
2026-10-29 13:59:58 +00:00
if channelIsPendingMessageRequest ( d . channelWithID ( ctx , channelID ) ) {
return nil , bridgev2 . WrapErrorInStatus ( errPendingMessageRequest ) .
WithStatus ( event . MessageStatusFail ) .
WithIsCertain ( true ) .
WithMessage ( "Accept the message request before reacting." )
}
2026-03-10 20:21:33 -07:00
return nil , d . tryWrappingError ( ctx , d . Session . MessageReactionAddUser (
2026-02-04 12:41:32 -08:00
meta . GuildID ,
2026-02-25 07:53:21 -08:00
channelID ,
2026-02-04 12:41:32 -08:00
discordid . ParseMessageID ( reaction . TargetMessage . ID ) ,
discordid . ParseEmojiID ( reaction . PreHandleResp . EmojiID ) ,
2026-02-25 10:10:56 -08:00
makeDiscordReferer ( meta . GuildID , parentChannelID , threadChannelID ) ,
2026-03-10 20:21:33 -07:00
) )
2024-08-15 16:43:13 +03:00
}
2025-12-17 18:44:52 -08:00
func ( d * DiscordClient ) HandleMatrixReactionRemove ( ctx context . Context , removal * bridgev2 . MatrixReactionRemove ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
2025-12-17 18:44:52 -08:00
removing := removal . TargetReaction
emojiID := removing . EmojiID
2026-02-25 07:53:21 -08:00
parentChannelID := discordid . ParseChannelPortalID ( removal . Portal . ID )
channelID := parentChannelID
threadChannelID := ""
2026-01-09 17:08:33 -08:00
guildID := removal . Portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
2026-02-25 07:53:21 -08:00
targetMessage , err := d . UserLogin . Bridge . DB . Message . GetFirstPartByID ( ctx , d . UserLogin . ID , removing . MessageID )
if err != nil {
return err
}
if targetMessage != nil && targetMessage . ThreadRoot != "" {
thread , err := d . getThreadByRootMessageID ( ctx , discordid . ParseMessageID ( targetMessage . ThreadRoot ) )
if err != nil {
return err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
channelID = threadChannelID
}
}
2025-12-17 18:44:52 -08:00
2026-10-29 13:10:50 +00:00
err = d . Session . MessageReactionRemoveUser (
2026-02-25 07:53:21 -08:00
guildID ,
channelID ,
discordid . ParseMessageID ( removing . MessageID ) ,
discordid . ParseEmojiID ( emojiID ) ,
discordid . ParseUserLoginID ( d . UserLogin . ID ) ,
2026-02-25 10:10:56 -08:00
makeDiscordReferer ( guildID , parentChannelID , threadChannelID ) ,
2026-10-29 13:10:50 +00:00
)
if isUnknownMessage ( err ) {
return nil
}
return d . tryWrappingError ( ctx , err )
2024-08-15 16:43:13 +03:00
}
2025-12-17 19:04:46 -08:00
func ( d * DiscordClient ) HandleMatrixMessageRemove ( ctx context . Context , removal * bridgev2 . MatrixMessageRemove ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
2026-02-25 07:53:21 -08:00
guildID := removal . Portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
parentChannelID := discordid . ParseChannelPortalID ( removal . Portal . ID )
channelID := parentChannelID
threadChannelID := ""
if removal . TargetMessage != nil && removal . TargetMessage . ThreadRoot != "" {
thread , err := d . getThreadByRootMessageID ( ctx , discordid . ParseMessageID ( removal . TargetMessage . ThreadRoot ) )
if err != nil {
return err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
channelID = threadChannelID
}
}
2026-02-01 21:03:17 -08:00
messageID := discordid . ParseMessageID ( removal . TargetMessage . ID )
2026-10-29 13:10:50 +00:00
err := d . Session . ChannelMessageDelete ( channelID , messageID , makeDiscordReferer ( guildID , parentChannelID , threadChannelID ) )
if isUnknownMessage ( err ) {
return nil
}
return d . tryWrappingError ( ctx , err )
2024-08-15 16:43:13 +03:00
}
func ( d * DiscordClient ) HandleMatrixReadReceipt ( ctx context . Context , msg * bridgev2 . MatrixReadReceipt ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
2026-01-06 17:40:14 -08:00
log := msg . Portal . Log . With ( ) .
Str ( "event_id" , string ( msg . EventID ) ) .
Str ( "action" , "matrix read receipt" ) . Logger ( )
2026-02-25 07:53:21 -08:00
guildID := msg . Portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
parentChannelID := discordid . ParseChannelPortalID ( msg . Portal . ID )
threadChannelID := ""
threadRootRemoteID := ""
threadID := msg . Receipt . ThreadID
threadScoped := threadID != "" && threadID != event . ReadReceiptThreadMain
if threadScoped {
rootMsg , err := d . UserLogin . Bridge . DB . Message . GetPartByMXID ( ctx , threadID )
if err != nil {
log . Err ( err ) . Msg ( "Failed to resolve thread root event from receipt" )
return err
} else if rootMsg != nil {
threadRootRemoteID = discordid . ParseMessageID ( rootMsg . ID )
if rootMsg . ThreadRoot != "" {
threadRootRemoteID = discordid . ParseMessageID ( rootMsg . ThreadRoot )
}
thread , err := d . getThreadByRootMessageID ( ctx , threadRootRemoteID )
if err != nil {
log . Err ( err ) . Msg ( "Failed to resolve thread channel from thread root" )
return err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
}
}
}
if threadScoped && threadRootRemoteID == "" {
log . Debug ( ) . Stringer ( "receipt_thread_id" , threadID ) . Msg ( "Dropping thread-scoped read receipt: unknown thread root" )
return nil
}
var targetMessage * database . Message
2026-01-06 18:09:52 -08:00
var targetMessageID string
// Figure out the ID of the Discord message that we'll mark as read. If the
// receipt didn't exactly correspond with a message, try finding one close
// by to use as the target.
if msg . ExactMessage != nil {
2026-02-25 07:53:21 -08:00
targetMessage = msg . ExactMessage
targetMessageID = discordid . ParseMessageID ( targetMessage . ID )
2026-01-06 18:09:52 -08:00
log = log . With ( ) .
Str ( "message_id" , targetMessageID ) .
Logger ( )
} else {
2026-02-25 07:53:21 -08:00
var err error
if threadScoped && threadRootRemoteID != "" {
targetMessage , err = d . UserLogin . Bridge . DB . Message . GetLastThreadMessage ( ctx , msg . Portal . PortalKey , discordid . MakeMessageID ( threadRootRemoteID ) )
if err != nil {
log . Err ( err ) . Msg ( "Failed to find latest thread message" )
return err
}
if targetMessage != nil && targetMessage . Timestamp . After ( msg . ReadUpTo ) {
targetMessage = nil
}
} else {
targetMessage , err = d . UserLogin . Bridge . DB . Message . GetLastPartAtOrBeforeTime ( ctx , msg . Portal . PortalKey , msg . ReadUpTo )
if err != nil {
log . Err ( err ) . Msg ( "Failed to find closest message part" )
return err
}
}
2026-01-06 18:09:52 -08:00
2026-02-25 07:53:21 -08:00
if targetMessage != nil {
2026-01-06 18:09:52 -08:00
// The read receipt didn't specify an exact message but we were able to
// find one close by.
2026-02-25 07:53:21 -08:00
targetMessageID = discordid . ParseMessageID ( targetMessage . ID )
2026-01-06 18:09:52 -08:00
log = log . With ( ) .
Str ( "closest_message_id" , targetMessageID ) .
2026-02-25 07:53:21 -08:00
Str ( "closest_event_id" , targetMessage . MXID . String ( ) ) .
2026-01-06 18:09:52 -08:00
Logger ( )
2026-01-06 17:40:14 -08:00
log . Debug ( ) .
2026-01-06 18:09:52 -08:00
Msg ( "Read receipt target event not found, using closest message" )
2026-01-06 17:40:14 -08:00
} else {
2026-01-06 18:09:52 -08:00
log . Debug ( ) . Msg ( "Dropping read receipt: no messages found" )
return nil
2026-01-06 17:40:14 -08:00
}
}
2026-02-25 07:53:21 -08:00
if threadScoped && targetMessage != nil {
targetMsgThreadRoot := discordid . ParseMessageID ( targetMessage . ThreadRoot )
if targetMsgThreadRoot == "" {
targetMsgThreadRoot = discordid . ParseMessageID ( targetMessage . ID )
}
if threadRootRemoteID != "" && targetMsgThreadRoot != threadRootRemoteID {
log . Debug ( ) .
Str ( "receipt_thread_root" , threadRootRemoteID ) .
Str ( "target_thread_root" , targetMsgThreadRoot ) .
Msg ( "Dropping read receipt due to thread mismatch" )
return nil
}
if threadChannelID == "" && targetMsgThreadRoot != "" {
thread , err := d . getThreadByRootMessageID ( ctx , targetMsgThreadRoot )
if err != nil {
return err
} else if thread != nil {
threadChannelID = thread . ThreadChannelID
}
}
}
channelID := parentChannelID
if threadChannelID != "" {
channelID = threadChannelID
}
resp , err := d . Session . ChannelMessageAckNoToken (
channelID ,
targetMessageID ,
2026-02-25 10:10:56 -08:00
makeDiscordReferer ( guildID , parentChannelID , threadChannelID ) ,
2026-02-25 07:53:21 -08:00
)
2026-01-06 17:40:14 -08:00
if err != nil {
2026-01-06 18:09:52 -08:00
log . Err ( err ) . Msg ( "Failed to send read receipt to Discord" )
2026-01-06 17:40:14 -08:00
return err
2026-01-06 18:09:52 -08:00
} else if resp . Token != nil {
log . Debug ( ) .
Str ( "unexpected_resp_token" , * resp . Token ) .
Msg ( "Marked message as read on Discord (and got unexpected non-nil token)" )
} else {
log . Debug ( ) . Msg ( "Marked message as read on Discord" )
2026-01-06 17:40:14 -08:00
}
return nil
2024-08-15 16:43:13 +03:00
}
2026-01-08 16:11:26 -08:00
func ( d * DiscordClient ) viewingChannel ( ctx context . Context , portal * bridgev2 . Portal ) error {
2026-01-09 17:08:33 -08:00
if portal . Metadata . ( * discordid . PortalMetadata ) . GuildID != "" {
// Only private channels need this logic.
return nil
}
2026-01-08 16:11:26 -08:00
d . markedOpenedLock . Lock ( )
defer d . markedOpenedLock . Unlock ( )
2026-02-13 21:06:37 -08:00
channelID := discordid . ParseChannelPortalID ( portal . ID )
2026-01-08 16:11:26 -08:00
log := zerolog . Ctx ( ctx ) . With ( ) .
Str ( "channel_id" , channelID ) . Logger ( )
lastMarkedOpenedTs := d . markedOpened [ channelID ]
if lastMarkedOpenedTs . IsZero ( ) {
d . markedOpened [ channelID ] = time . Now ( )
err := d . Session . MarkViewing ( channelID )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to mark user as viewing channel" )
return err
}
log . Trace ( ) . Msg ( "Marked channel as being viewed" )
} else {
log . Trace ( ) . Str ( "channel_id" , channelID ) .
Msg ( "Already marked channel as viewed, not doing so" )
}
return nil
}
2024-08-15 16:43:13 +03:00
func ( d * DiscordClient ) HandleMatrixTyping ( ctx context . Context , msg * bridgev2 . MatrixTyping ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
2026-10-19 14:09:46 +01:00
} else if ! msg . IsTyping {
return nil
2026-03-10 22:31:16 -07:00
}
2026-01-08 16:11:26 -08:00
log := zerolog . Ctx ( ctx )
// Don't mind if this fails.
_ = d . viewingChannel ( ctx , msg . Portal )
2026-01-09 17:08:33 -08:00
guildID := msg . Portal . Metadata . ( * discordid . PortalMetadata ) . GuildID
2026-02-13 21:06:37 -08:00
channelID := discordid . ParseChannelPortalID ( msg . Portal . ID )
2026-10-29 13:59:58 +00:00
if channelIsPendingMessageRequest ( d . channelWithID ( ctx , channelID ) ) {
return nil
}
2026-02-25 10:10:56 -08:00
err := d . Session . ChannelTyping ( channelID , makeDiscordReferer ( guildID , channelID , "" ) )
2026-01-08 16:11:26 -08:00
if err != nil {
log . Warn ( ) . Err ( err ) . Msg ( "Failed to mark user as typing" )
return err
}
log . Debug ( ) . Msg ( "Marked user as typing" )
return nil
2024-08-15 16:43:13 +03:00
}
2026-03-05 14:05:35 -08:00
func ( d * DiscordClient ) HandleMute ( ctx context . Context , msg * bridgev2 . MatrixMute ) error {
2026-03-10 22:31:16 -07:00
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
2026-03-05 14:05:35 -08:00
channelID := discordid . ParseChannelPortalID ( msg . Portal . ID )
log := zerolog . Ctx ( ctx ) . With ( ) .
Str ( "muting_channel_id" , channelID ) .
Int64 ( "muting_until" , msg . Content . MutedUntil ) .
Logger ( )
ctx = log . WithContext ( ctx )
log . Debug ( ) . Msg ( "Handling Matrix mute" )
ch := d . channelWithID ( ctx , channelID )
if ch == nil {
log . Error ( ) . Msg ( "Failed to find channel to mute" )
return fmt . Errorf ( "failed to mute non-existent channel %s" , channelID )
}
mutedUntil := msg . Content . GetMutedUntilTime ( )
isMuting := mutedUntil . After ( time . Now ( ) )
override := discordgo . UserGuildSettingsChannelOverrideEdit {
Muted : ptr . Ptr ( isMuting ) ,
}
if isMuting && mutedUntil != event . MutedForever {
// At the time of writing, arbitrary mute durations are supported by
// Discord; you aren't restricted to the official client's choices
// of 15 minutes, 1 hour, 3 hours, 8 hours, and 24 hours.
secs := int ( math . Round ( msg . Content . GetMuteDuration ( ) . Seconds ( ) ) )
override . MuteConfig = & discordgo . MuteConfig {
EndTime : & mutedUntil ,
SelectedTimeWindow : & secs ,
}
}
overrides := make ( map [ string ] * discordgo . UserGuildSettingsChannelOverrideEdit )
overrides [ ch . ID ] = & override
edit := discordgo . UserGuildSettingsEdit {
ChannelOverrides : overrides ,
}
log . Debug ( ) . Interface ( "muting_override" , override ) . Msg ( "Computed channel override for mute" )
guildID := ch . GuildID
if guildID == "" {
// Target private channels properly.
guildID = "@me"
}
_ , err := d . Session . UserGuildSettingsEdit ( guildID , & edit )
if err != nil {
return fmt . Errorf ( "failed to edit guild settings in response to mute: %w" , err )
}
return nil
}
2026-10-05 18:26:10 +03:00
func ( d * DiscordClient ) HandleMatrixBlockUser ( ctx context . Context , msg * bridgev2 . MatrixBlockUser ) error {
if ! d . IsLoggedIn ( ) {
return bridgev2 . ErrNotLoggedIn
}
if msg . Content . ReportSpam {
2026-10-19 14:00:49 +01:00
return bridgev2 . WrapErrorInStatus ( errors . New ( "reporting spam is not supported on Discord" ) ) . WithIsCertain ( true ) . WithErrorAsMessage ( ) . WithErrorReason ( event . MessageStatusUnsupported ) . WithStatus ( event . MessageStatusFail )
2026-10-05 18:26:10 +03:00
}
ch , err := d . Session . State . Channel ( discordid . ParseChannelPortalID ( msg . Portal . ID ) )
if err != nil {
return err
}
recipient := dmChannelRecipientID ( ch )
if ch . Type != discordgo . ChannelTypeDM || recipient == nil {
return bridgev2 . ErrNonDMBlockUser
}
if msg . Content . Block {
err = d . Session . RelationshipUserBlock ( * recipient )
} else {
2026-10-05 18:56:06 +03:00
rel := d . relationshipWithUserID ( * recipient )
if rel == nil || rel . Type != discordgo . RelationshipBlocked {
return nil
}
2026-10-05 18:26:10 +03:00
err = d . Session . RelationshipDelete ( * recipient )
}
2026-10-29 13:10:50 +00:00
return d . tryWrappingError ( ctx , err )
2026-10-05 18:26:10 +03:00
}