package main import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "reflect" "regexp" "strconv" "strings" "sync" "syscall" "time" "github.com/bwmarrin/discordgo" "github.com/gabriel-vasile/mimetype" "github.com/gorilla/mux" "github.com/rs/zerolog" "go.mau.fi/util/exsync" "go.mau.fi/util/variationselector" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-discord/config" "go.mau.fi/mautrix-discord/database" ) type portalDiscordMessage struct { msg interface{} user *User thread *Thread } type portalMatrixMessage struct { evt *event.Event user *User } var relayClient, _ = discordgo.New("") type Portal struct { *database.Portal Parent *Portal Guild *Guild bridge *DiscordBridge log zerolog.Logger roomCreateLock sync.Mutex encryptLock sync.Mutex discordMessages chan portalDiscordMessage matrixMessages chan portalMatrixMessage recentMessages *exsync.RingBuffer[string, *discordgo.Message] commands map[string]*discordgo.ApplicationCommand commandsLock sync.RWMutex forwardBackfillLock sync.Mutex currentlyTyping []id.UserID currentlyTypingLock sync.Mutex } const recentMessageBufferSize = 32 var _ bridge.Portal = (*Portal)(nil) var _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) var _ bridge.MembershipHandlingPortal = (*Portal)(nil) var _ bridge.TypingPortal = (*Portal)(nil) //var _ bridge.MetaHandlingPortal = (*Portal)(nil) //var _ bridge.DisappearingPortal = (*Portal)(nil) func (portal *Portal) IsEncrypted() bool { return portal.Encrypted } func (portal *Portal) MarkEncrypted() { portal.Encrypted = true portal.Update() } func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.RelayWebhookID != "" { portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt} } } var ( portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType} ) func (br *DiscordBridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey, chanType discordgo.ChannelType) *Portal { if dbPortal == nil { if key == nil || chanType < 0 { return nil } dbPortal = br.DB.Portal.New() dbPortal.Key = *key dbPortal.Type = chanType dbPortal.Insert() } portal := br.NewPortal(dbPortal) br.portalsByID[portal.Key] = portal if portal.MXID != "" { br.portalsByMXID[portal.MXID] = portal } if portal.GuildID != "" { portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true) } if portal.ParentID != "" { parentKey := database.NewPortalKey(portal.ParentID, "") var ok bool portal.Parent, ok = br.portalsByID[parentKey] if !ok { portal.Parent = br.loadPortal(br.DB.Portal.GetByID(parentKey), nil, -1) } } return portal } func (br *DiscordBridge) GetPortalByMXID(mxid id.RoomID) *Portal { br.portalsLock.Lock() defer br.portalsLock.Unlock() portal, ok := br.portalsByMXID[mxid] if !ok { return br.loadPortal(br.DB.Portal.GetByMXID(mxid), nil, -1) } return portal } func (user *User) GetPortalByMeta(meta *discordgo.Channel) *Portal { return user.GetPortalByID(meta.ID, meta.Type) } func (user *User) GetExistingPortalByID(id string) *Portal { return user.bridge.GetExistingPortalByID(database.NewPortalKey(id, user.DiscordID)) } func (user *User) GetPortalByID(id string, chanType discordgo.ChannelType) *Portal { return user.bridge.GetPortalByID(database.NewPortalKey(id, user.DiscordID), chanType) } func (user *User) FindPrivateChatWith(userID string) *Portal { user.bridge.portalsLock.Lock() defer user.bridge.portalsLock.Unlock() dbPortal := user.bridge.DB.Portal.FindPrivateChatBetween(userID, user.DiscordID) if dbPortal == nil { return nil } existing, ok := user.bridge.portalsByID[dbPortal.Key] if ok { return existing } return user.bridge.loadPortal(dbPortal, nil, discordgo.ChannelTypeDM) } func (br *DiscordBridge) GetExistingPortalByID(key database.PortalKey) *Portal { br.portalsLock.Lock() defer br.portalsLock.Unlock() portal, ok := br.portalsByID[key] if !ok { if key.Receiver != "" { portal, ok = br.portalsByID[database.NewPortalKey(key.ChannelID, "")] } if !ok { return br.loadPortal(br.DB.Portal.GetByID(key), nil, -1) } } return portal } func (br *DiscordBridge) GetPortalByID(key database.PortalKey, chanType discordgo.ChannelType) *Portal { br.portalsLock.Lock() defer br.portalsLock.Unlock() if chanType != discordgo.ChannelTypeDM { key.Receiver = "" } portal, ok := br.portalsByID[key] if !ok { return br.loadPortal(br.DB.Portal.GetByID(key), &key, chanType) } return portal } func (br *DiscordBridge) GetAllPortals() []*Portal { return br.dbPortalsToPortals(br.DB.Portal.GetAll()) } func (br *DiscordBridge) GetAllPortalsInGuild(guildID string) []*Portal { return br.dbPortalsToPortals(br.DB.Portal.GetAllInGuild(guildID)) } func (br *DiscordBridge) GetAllIPortals() (iportals []bridge.Portal) { portals := br.GetAllPortals() iportals = make([]bridge.Portal, len(portals)) for i, portal := range portals { iportals[i] = portal } return iportals } func (br *DiscordBridge) GetDMPortalsWith(otherUserID string) []*Portal { return br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(otherUserID)) } func (br *DiscordBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { br.portalsLock.Lock() defer br.portalsLock.Unlock() output := make([]*Portal, len(dbPortals)) for index, dbPortal := range dbPortals { if dbPortal == nil { continue } portal, ok := br.portalsByID[dbPortal.Key] if !ok { portal = br.loadPortal(dbPortal, nil, -1) } output[index] = portal } return output } func (br *DiscordBridge) NewPortal(dbPortal *database.Portal) *Portal { portal := &Portal{ Portal: dbPortal, bridge: br, log: br.ZLog.With(). Str("channel_id", dbPortal.Key.ChannelID). Str("channel_receiver", dbPortal.Key.Receiver). Str("room_id", dbPortal.MXID.String()). Logger(), discordMessages: make(chan portalDiscordMessage, br.Config.Bridge.PortalMessageBuffer), matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), recentMessages: exsync.NewRingBuffer[string, *discordgo.Message](recentMessageBufferSize), commands: make(map[string]*discordgo.ApplicationCommand), } go portal.messageLoop() return portal } func (portal *Portal) messageLoop() { for { select { case msg := <-portal.matrixMessages: portal.handleMatrixMessages(msg) case msg := <-portal.discordMessages: portal.handleDiscordMessages(msg) } } } func (portal *Portal) IsPrivateChat() bool { return portal.Type == discordgo.ChannelTypeDM } func (portal *Portal) MainIntent() *appservice.IntentAPI { if portal.IsPrivateChat() && portal.OtherUserID != "" { return portal.bridge.GetPuppetByID(portal.OtherUserID).DefaultIntent() } return portal.bridge.Bot } type CustomBridgeInfoContent struct { event.BridgeEventContent RoomType string `json:"com.beeper.room_type,omitempty"` } func init() { event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) } func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { bridgeInfo := event.BridgeEventContent{ BridgeBot: portal.bridge.Bot.UserID, Creator: portal.MainIntent().UserID, Protocol: event.BridgeInfoSection{ ID: "discordgo", DisplayName: "Discord", AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), ExternalURL: "https://discord.com/", }, Channel: event.BridgeInfoSection{ ID: portal.Key.ChannelID, DisplayName: portal.Name, }, } var bridgeInfoStateKey string if portal.GuildID == "" { bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/dm/%s", portal.Key.ChannelID) bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/@me/%s", portal.Key.ChannelID) } else { bridgeInfo.Network = &event.BridgeInfoSection{ ID: portal.GuildID, } if portal.Guild != nil { bridgeInfo.Network.DisplayName = portal.Guild.Name bridgeInfo.Network.AvatarURL = portal.Guild.AvatarURL.CUString() // TODO is it possible to find the URL? } bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/%s/%s", portal.GuildID, portal.Key.ChannelID) bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/%s/%s", portal.GuildID, portal.Key.ChannelID) } var roomType string if portal.Type == discordgo.ChannelTypeDM || portal.Type == discordgo.ChannelTypeGroupDM { roomType = "dm" } return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType} } func (portal *Portal) UpdateBridgeInfo() { if len(portal.MXID) == 0 { portal.log.Debug().Msg("Not updating bridge info: no Matrix room created") return } portal.log.Debug().Msg("Updating bridge info...") stateKey, content := portal.getBridgeInfo() _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content) if err != nil { portal.log.Warn().Err(err).Msg("Failed to update m.bridge") } // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content) if err != nil { portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge") } } func (portal *Portal) shouldSetDMRoomMetadata() bool { return !portal.IsPrivateChat() || portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") } func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { evt.RotationPeriodMillis = rot.Milliseconds evt.RotationPeriodMessages = rot.Messages } return } func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) error { portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() if portal.MXID != "" { portal.ensureUserInvited(user, false) return nil } portal.log.Info().Msg("Creating Matrix room for channel") channel = portal.UpdateInfo(user, channel) if channel == nil { return fmt.Errorf("didn't find channel metadata") } intent := portal.MainIntent() if err := intent.EnsureRegistered(); err != nil { return err } bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() initialState := []*event.Event{{ Type: event.StateBridge, Content: event.Content{Parsed: bridgeInfo}, StateKey: &bridgeInfoStateKey, }, { // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec Type: event.StateHalfShotBridge, Content: event.Content{Parsed: bridgeInfo}, StateKey: &bridgeInfoStateKey, }} var invite []id.UserID if portal.bridge.Config.Bridge.Encryption.Default { initialState = append(initialState, &event.Event{ Type: event.StateEncryption, Content: event.Content{ Parsed: portal.GetEncryptionEventContent(), }, }) portal.Encrypted = true if portal.IsPrivateChat() { invite = append(invite, portal.bridge.Bot.UserID) } } if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() { initialState = append(initialState, &event.Event{ Type: event.StateRoomAvatar, Content: event.Content{Parsed: &event.RoomAvatarEventContent{ URL: portal.AvatarURL, }}, }) portal.AvatarSet = true } else { portal.AvatarSet = false } creationContent := make(map[string]interface{}) if portal.Type == discordgo.ChannelTypeGuildCategory { creationContent["type"] = event.RoomTypeSpace } if !portal.bridge.Config.Bridge.FederateRooms { creationContent["m.federate"] = false } spaceID := portal.ExpectedSpaceID() if spaceID != "" { spaceIDStr := spaceID.String() initialState = append(initialState, &event.Event{ Type: event.StateSpaceParent, StateKey: &spaceIDStr, Content: event.Content{Parsed: &event.SpaceParentEventContent{ Via: []string{portal.bridge.AS.HomeserverDomain}, Canonical: true, }}, }) } if portal.bridge.Config.Bridge.RestrictedRooms && portal.Guild != nil && portal.Guild.MXID != "" { // TODO don't do this for private channels in guilds initialState = append(initialState, &event.Event{ Type: event.StateJoinRules, Content: event.Content{Parsed: &event.JoinRulesEventContent{ JoinRule: event.JoinRuleRestricted, Allow: []event.JoinRuleAllow{{ RoomID: portal.Guild.MXID, Type: event.JoinRuleAllowRoomMembership, }}, }}, }) } req := &mautrix.ReqCreateRoom{ Visibility: "private", Name: portal.Name, Topic: portal.Topic, Invite: invite, Preset: "private_chat", IsDirect: portal.IsPrivateChat(), InitialState: initialState, CreationContent: creationContent, } if !portal.shouldSetDMRoomMetadata() && !portal.FriendNick { req.Name = "" } var backfillStarted bool portal.forwardBackfillLock.Lock() defer func() { if !backfillStarted { portal.log.Debug().Msg("Backfill wasn't started, unlocking forward backfill lock") portal.forwardBackfillLock.Unlock() } }() resp, err := intent.CreateRoom(req) if err != nil { portal.log.Warn().Err(err).Msg("Failed to create room") return err } portal.NameSet = len(req.Name) > 0 portal.TopicSet = len(req.Topic) > 0 portal.MXID = resp.RoomID portal.log = portal.bridge.ZLog.With(). Str("channel_id", portal.Key.ChannelID). Str("channel_receiver", portal.Key.Receiver). Str("room_id", portal.MXID.String()). Logger() portal.bridge.portalsLock.Lock() portal.bridge.portalsByMXID[portal.MXID] = portal portal.bridge.portalsLock.Unlock() portal.Update() portal.log.Info().Msg("Matrix room created") if portal.Encrypted && portal.IsPrivateChat() { err = portal.bridge.Bot.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) if err != nil { portal.log.Err(err).Msg("Failed to ensure bridge bot is joined to encrypted private chat portal") } } if portal.GuildID == "" { user.addPrivateChannelToSpace(portal) } else { portal.updateSpace(user) } portal.ensureUserInvited(user, true) user.syncChatDoublePuppetDetails(portal, true) portal.syncParticipants(user, channel.Recipients) if portal.IsPrivateChat() { puppet := user.bridge.GetPuppetByID(portal.Key.Receiver) chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}} user.updateDirectChats(chats) } firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, portalCreationDummyEvent, struct{}{}) if err != nil { portal.log.Err(err).Msg("Failed to send dummy event to mark portal creation") } else { portal.FirstEventID = firstEventResp.EventID portal.Update() } go portal.forwardBackfillInitial(user, nil) backfillStarted = true return nil } func (portal *Portal) handleDiscordMessages(msg portalDiscordMessage) { if portal.MXID == "" { msgCreate, ok := msg.msg.(*discordgo.MessageCreate) if !ok { portal.log.Warn().Msg("Can't create Matrix room from non new message event") return } portal.log.Debug(). Str("message_id", msgCreate.ID). Msg("Creating Matrix room from incoming message") if err := portal.CreateMatrixRoom(msg.user, nil); err != nil { portal.log.Err(err).Msg("Failed to create portal room") return } } portal.forwardBackfillLock.Lock() defer portal.forwardBackfillLock.Unlock() switch convertedMsg := msg.msg.(type) { case *discordgo.MessageCreate: portal.handleDiscordMessageCreate(msg.user, convertedMsg.Message, msg.thread) case *discordgo.MessageUpdate: portal.handleDiscordMessageUpdate(msg.user, convertedMsg.Message) case *discordgo.MessageDelete: portal.handleDiscordMessageDelete(msg.user, convertedMsg.Message) case *discordgo.MessageDeleteBulk: portal.handleDiscordMessageDeleteBulk(msg.user, convertedMsg.Messages) case *discordgo.MessageReactionAdd: portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, true, msg.thread, convertedMsg.Member) case *discordgo.MessageReactionRemove: portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, false, msg.thread, nil) default: portal.log.Warn().Type("message_type", msg.msg).Msg("Unknown message type in handleDiscordMessages") } } func (portal *Portal) ensureUserInvited(user *User, ignoreCache bool) bool { return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat(), ignoreCache) } func (portal *Portal) markMessageHandled(discordID string, authorID string, timestamp time.Time, threadID string, senderMXID id.UserID, parts []database.MessagePart) *database.Message { msg := portal.bridge.DB.Message.New() msg.Channel = portal.Key msg.DiscordID = discordID msg.SenderID = authorID msg.Timestamp = timestamp msg.ThreadID = threadID msg.SenderMXID = senderMXID msg.MassInsertParts(parts) msg.MXID = parts[0].MXID msg.AttachmentID = parts[0].AttachmentID return msg } func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message, thread *Thread) { switch msg.Type { case discordgo.MessageTypeChannelNameChange, discordgo.MessageTypeChannelIconChange, discordgo.MessageTypeChannelPinnedMessage: // These are handled via channel updates return } log := portal.log.With(). Str("message_id", msg.ID). Int("message_type", int(msg.Type)). Str("author_id", msg.Author.ID). Str("action", "discord message create"). Logger() ctx := log.WithContext(context.Background()) portal.recentMessages.Push(msg.ID, msg) existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID) if existing != nil { log.Debug().Msg("Dropping duplicate message") return } handlingStartTime := time.Now() puppet := portal.bridge.GetPuppetByID(msg.Author.ID) puppet.UpdateInfo(user, msg.Author, msg) intent := puppet.IntentFor(portal) var discordThreadID string var threadRootEvent, lastThreadEvent id.EventID if thread != nil { discordThreadID = thread.ID threadRootEvent = thread.RootMXID lastThreadEvent = threadRootEvent lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID) if lastInThread != nil { lastThreadEvent = lastInThread.MXID } } replyTo := portal.getReplyTarget(user, discordThreadID, msg.MessageReference, msg.Embeds, false) mentions := portal.convertDiscordMentions(msg, true) ts, _ := discordgo.SnowflakeTimestamp(msg.ID) parts := portal.convertDiscordMessage(ctx, puppet, intent, msg) dbParts := make([]database.MessagePart, 0, len(parts)) eventIDs := zerolog.Dict() for i, part := range parts { if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil { part.Content.RelatesTo = &event.RelatesTo{} } if threadRootEvent != "" { part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent) } if replyTo != nil { part.Content.RelatesTo.SetReplyTo(replyTo.EventID) if replyTo.UnstableRoomID != "" { part.Content.RelatesTo.InReplyTo.UnstableRoomID = replyTo.UnstableRoomID } // Only set reply for first event replyTo = nil } part.Content.Mentions = mentions // Only set mentions for first event, but keep empty object for rest mentions = &event.Mentions{} resp, err := portal.sendMatrixMessage(intent, part.Type, part.Content, part.Extra, ts.UnixMilli()) if err != nil { log.Err(err). Int("part_index", i). Str("attachment_id", part.AttachmentID). Msg("Failed to send part of message to Matrix") continue } lastThreadEvent = resp.EventID dbParts = append(dbParts, database.MessagePart{AttachmentID: part.AttachmentID, MXID: resp.EventID}) eventIDs.Str(part.AttachmentID, resp.EventID.String()) } log = log.With().Dur("handling_time", time.Since(handlingStartTime)).Logger() if len(parts) == 0 { log.Warn().Msg("Unhandled message") } else if len(dbParts) == 0 { log.Warn().Msg("All parts of message failed to send to Matrix") } else { log.Debug().Dict("event_ids", eventIDs).Msg("Finished handling Discord message") firstDBMessage := portal.markMessageHandled(msg.ID, msg.Author.ID, ts, discordThreadID, intent.UserID, dbParts) if msg.Flags == discordgo.MessageFlagsHasThread { portal.bridge.threadFound(ctx, user, firstDBMessage, msg.ID, msg.Thread) } } } var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`) func isReplyEmbed(embed *discordgo.MessageEmbed) bool { return hackyReplyPattern.MatchString(embed.Description) } func (portal *Portal) getReplyTarget(source *User, threadID string, ref *discordgo.MessageReference, embeds []*discordgo.MessageEmbed, allowNonExistent bool) *event.InReplyTo { if ref == nil && len(embeds) > 0 { match := hackyReplyPattern.FindStringSubmatch(embeds[0].Description) if match != nil && match[1] == portal.GuildID && (match[2] == portal.Key.ChannelID || match[2] == threadID) { ref = &discordgo.MessageReference{ MessageID: match[3], ChannelID: match[2], GuildID: match[1], } } } if ref == nil { return nil } // TODO add config option for cross-room replies crossRoomReplies := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry targetPortal := portal if ref.ChannelID != portal.Key.ChannelID && ref.ChannelID != threadID && crossRoomReplies { targetPortal = portal.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: ref.ChannelID, Receiver: source.DiscordID}) if targetPortal == nil { return nil } } replyToMsg := portal.bridge.DB.Message.GetByDiscordID(targetPortal.Key, ref.MessageID) if len(replyToMsg) > 0 { if !crossRoomReplies { return &event.InReplyTo{EventID: replyToMsg[0].MXID} } return &event.InReplyTo{ EventID: replyToMsg[0].MXID, UnstableRoomID: targetPortal.MXID, } } else if allowNonExistent { return &event.InReplyTo{ EventID: targetPortal.deterministicEventID(ref.MessageID, ""), UnstableRoomID: targetPortal.MXID, } } return nil } const JoinThreadReaction = "join thread" func (portal *Portal) sendThreadCreationNotice(ctx context.Context, thread *Thread) { thread.creationNoticeLock.Lock() defer thread.creationNoticeLock.Unlock() if thread.CreationNoticeMXID != "" { return } creationNotice := "Thread created. React to this message with \"join thread\" to join the thread on Discord." if portal.bridge.Config.Bridge.AutojoinThreadOnOpen { creationNotice = "Thread created. Opening this thread will auto-join you to it on Discord." } log := zerolog.Ctx(ctx) resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{ Body: creationNotice, MsgType: event.MsgNotice, RelatesTo: (&event.RelatesTo{}).SetThread(thread.RootMXID, thread.RootMXID), }, nil, time.Now().UnixMilli()) if err != nil { log.Err(err).Msg("Failed to send thread creation notice") return } portal.bridge.threadsLock.Lock() thread.CreationNoticeMXID = resp.EventID portal.bridge.threadsByCreationNoticeMXID[resp.EventID] = thread portal.bridge.threadsLock.Unlock() thread.Update() log.Debug(). Str("creation_notice_mxid", thread.CreationNoticeMXID.String()). Msg("Sent thread creation notice") resp, err = portal.MainIntent().SendMessageEvent(portal.MXID, event.EventReaction, &event.ReactionEventContent{ RelatesTo: event.RelatesTo{ Type: event.RelAnnotation, EventID: thread.CreationNoticeMXID, Key: JoinThreadReaction, }, }) if err != nil { log.Err(err).Msg("Failed to send prefilled reaction to thread creation notice") } else { log.Debug(). Str("reaction_event_id", resp.EventID.String()). Str("creation_notice_mxid", thread.CreationNoticeMXID.String()). Msg("Sent prefilled reaction to thread creation notice") } } func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Message) { log := portal.log.With(). Str("message_id", msg.ID). Str("action", "discord message update"). Logger() ctx := log.WithContext(context.Background()) if portal.MXID == "" { log.Warn().Msg("handle message called without a valid portal") return } existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID) if existing == nil { log.Warn().Msg("Dropping update of unknown message") return } if msg.EditedTimestamp != nil && !msg.EditedTimestamp.After(existing[0].EditTimestamp) { log.Debug(). Time("received_edit_ts", *msg.EditedTimestamp). Time("db_edit_ts", existing[0].EditTimestamp). Msg("Dropping update of message with older or equal edit timestamp") return } if msg.Flags == discordgo.MessageFlagsHasThread { portal.bridge.threadFound(ctx, user, existing[0], msg.ID, msg.Thread) } if msg.Author == nil { creationMessage, ok := portal.recentMessages.Get(msg.ID) if !ok { log.Debug().Msg("Dropping edit with no author of non-recent message") return } else if creationMessage.Type == discordgo.MessageTypeCall { log.Debug().Msg("Dropping edit with of call message") return } log.Debug().Msg("Found original message in cache for edit without author") if len(msg.Embeds) > 0 { creationMessage.Embeds = msg.Embeds } if len(msg.Attachments) > 0 { creationMessage.Attachments = msg.Attachments } if len(msg.Components) > 0 { creationMessage.Components = msg.Components } // TODO are there other fields that need copying? msg = creationMessage } else { portal.recentMessages.Replace(msg.ID, msg) } if msg.Author.ID == portal.RelayWebhookID { log.Debug(). Str("message_id", msg.ID). Str("author_id", msg.Author.ID). Msg("Dropping edit from relay webhook") return } puppet := portal.bridge.GetPuppetByID(msg.Author.ID) intent := puppet.IntentFor(portal) redactions := zerolog.Dict() attachmentMap := map[string]*database.Message{} for _, existingPart := range existing { if existingPart.AttachmentID != "" { attachmentMap[existingPart.AttachmentID] = existingPart } } for _, remainingAttachment := range msg.Attachments { if _, found := attachmentMap[remainingAttachment.ID]; found { delete(attachmentMap, remainingAttachment.ID) } } for _, remainingSticker := range msg.StickerItems { if _, found := attachmentMap[remainingSticker.ID]; found { delete(attachmentMap, remainingSticker.ID) } } for _, remainingEmbed := range msg.Embeds { // Other types of embeds are sent inline with the text message part if getEmbedType(nil, remainingEmbed) != EmbedVideo { continue } embedID := "video_" + remainingEmbed.URL if _, found := attachmentMap[embedID]; found { delete(attachmentMap, embedID) } } for _, deletedAttachment := range attachmentMap { resp, err := intent.RedactEvent(portal.MXID, deletedAttachment.MXID) if err != nil { log.Err(err). Str("event_id", deletedAttachment.MXID.String()). Msg("Failed to redact attachment") } else { redactions.Str(deletedAttachment.AttachmentID, resp.EventID.String()) } deletedAttachment.Delete() } var converted *ConvertedMessage // Slightly hacky special case: messages with gif links will get an embed with the gif. // The link isn't rendered on Discord, so just edit the link message into a gif message on Matrix too. if isPlainGifMessage(msg) { converted = portal.convertDiscordVideoEmbed(ctx, intent, msg.Embeds[0]) } else { converted = portal.convertDiscordTextMessage(ctx, intent, msg) } if converted == nil { log.Debug(). Bool("has_message_on_matrix", existing[0].AttachmentID == ""). Bool("has_text_on_discord", len(msg.Content) > 0). Msg("Dropping non-text edit") return } puppet.addWebhookMeta(converted, msg) puppet.addMemberMeta(converted, msg) converted.Content.Mentions = portal.convertDiscordMentions(msg, false) converted.Content.SetEdit(existing[0].MXID) // Never actually mention new users of edits, only include mentions inside m.new_content converted.Content.Mentions = &event.Mentions{} if converted.Extra != nil { converted.Extra = map[string]any{ "m.new_content": converted.Extra, } } var editTS int64 if msg.EditedTimestamp != nil { editTS = msg.EditedTimestamp.UnixMilli() } // TODO figure out some way to deduplicate outgoing edits resp, err := portal.sendMatrixMessage(intent, event.EventMessage, converted.Content, converted.Extra, editTS) if err != nil { log.Err(err).Msg("Failed to send edit to Matrix") return } portal.sendDeliveryReceipt(resp.EventID) if msg.EditedTimestamp != nil { existing[0].UpdateEditTimestamp(*msg.EditedTimestamp) } log.Debug(). Str("event_id", resp.EventID.String()). Dict("redacted_attachments", redactions). Msg("Finished handling Discord edit") } func (portal *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message) { lastResp := portal.redactAllParts(portal.MainIntent(), msg.ID) if lastResp != "" { portal.sendDeliveryReceipt(lastResp) } } func (portal *Portal) handleDiscordMessageDeleteBulk(user *User, messages []string) { intent := portal.MainIntent() var lastResp id.EventID for _, msgID := range messages { newLastResp := portal.redactAllParts(intent, msgID) if newLastResp != "" { lastResp = newLastResp } } if lastResp != "" { portal.sendDeliveryReceipt(lastResp) } } func (portal *Portal) redactAllParts(intent *appservice.IntentAPI, msgID string) (lastResp id.EventID) { existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msgID) for _, dbMsg := range existing { resp, err := intent.RedactEvent(portal.MXID, dbMsg.MXID) if err != nil { portal.log.Err(err). Str("message_id", msgID). Str("event_id", dbMsg.MXID.String()). Msg("Failed to redact Matrix message") } else if resp != nil && resp.EventID != "" { lastResp = resp.EventID } dbMsg.Delete() } return } func (portal *Portal) handleDiscordTyping(evt *discordgo.TypingStart) { puppet := portal.bridge.GetPuppetByID(evt.UserID) if puppet.Name == "" { // Puppet hasn't been synced yet return } log := portal.log.With(). Str("ghost_mxid", puppet.MXID.String()). Str("action", "discord typing"). Logger() intent := puppet.IntentFor(portal) err := intent.EnsureJoined(portal.MXID) if err != nil { log.Warn().Err(err).Msg("Failed to ensure ghost is joined for typing notification") return } _, err = intent.UserTyping(portal.MXID, true, 12*time.Second) if err != nil { log.Warn().Err(err).Msg("Failed to send typing notification to Matrix") } } func (portal *Portal) syncParticipant(source *User, participant *discordgo.User, remove bool) { puppet := portal.bridge.GetPuppetByID(participant.ID) puppet.UpdateInfo(source, participant, nil) log := portal.log.With(). Str("participant_id", participant.ID). Str("ghost_mxid", puppet.MXID.String()). Logger() user := portal.bridge.GetUserByID(participant.ID) if user != nil { log.Debug().Msg("Ensuring Matrix user is invited or joined to room") portal.ensureUserInvited(user, false) } if remove { _, err := puppet.DefaultIntent().LeaveRoom(portal.MXID) if err != nil { log.Warn().Err(err).Msg("Failed to make ghost leave room after member remove event") } } else if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil { log.Warn().Err(err).Msg("Failed to add ghost to room") } } } func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) { for _, participant := range participants { puppet := portal.bridge.GetPuppetByID(participant.ID) puppet.UpdateInfo(source, participant, nil) var user *User if participant.ID != portal.OtherUserID { user = portal.bridge.GetUserByID(participant.ID) if user != nil { portal.ensureUserInvited(user, false) } } if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil { portal.log.Warn().Err(err). Str("participant_id", participant.ID). Msg("Failed to add ghost to room") } } } } func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { if !portal.Encrypted || portal.bridge.Crypto == nil { return eventType, nil } intent.AddDoublePuppetValue(content) // TODO maybe the locking should be inside mautrix-go? portal.encryptLock.Lock() err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) portal.encryptLock.Unlock() if err != nil { return eventType, fmt.Errorf("failed to encrypt event: %w", err) } return event.EventEncrypted, nil } func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { wrappedContent := event.Content{Parsed: content, Raw: extraContent} var err error eventType, err = portal.encrypt(intent, &wrappedContent, eventType) if err != nil { return nil, err } _, _ = intent.UserTyping(portal.MXID, false, 0) if timestamp == 0 { return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) } else { return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) } } func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { portal.forwardBackfillLock.Lock() defer portal.forwardBackfillLock.Unlock() switch msg.evt.Type { case event.EventMessage, event.EventSticker: portal.handleMatrixMessage(msg.user, msg.evt) case event.EventRedaction: portal.handleMatrixRedaction(msg.user, msg.evt) case event.EventReaction: portal.handleMatrixReaction(msg.user, msg.evt) default: portal.log.Warn().Str("event_type", msg.evt.Type.Type).Msg("Unknown event type in handleMatrixMessages") } } const discordEpoch = 1420070400000 func generateNonce() string { snowflake := (time.Now().UnixMilli() - discordEpoch) << 22 // Nonce snowflakes don't have internal IDs or increments return strconv.FormatInt(snowflake, 10) } func (portal *Portal) getEvent(mxid id.EventID) (*event.Event, error) { evt, err := portal.MainIntent().GetEvent(portal.MXID, mxid) if err != nil { return nil, err } _ = evt.Content.ParseRaw(evt.Type) if evt.Type == event.EventEncrypted { decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt) if err != nil { return nil, fmt.Errorf("failed to decrypt event: %w", err) } else { evt = decryptedEvt } } return evt, nil } func genThreadName(evt *event.Event) string { body := evt.Content.AsMessage().Body if len(body) == 0 { return "thread" } fields := strings.Fields(body) var title string for _, field := range fields { if len(title)+len(field) < 40 { title += field title += " " continue } if len(title) == 0 { title = field[:40] } break } return title } func (portal *Portal) startThreadFromMatrix(sender *User, threadRoot id.EventID) (string, error) { rootEvt, err := portal.getEvent(threadRoot) if err != nil { return "", fmt.Errorf("failed to get root event: %w", err) } threadName := genThreadName(rootEvt) existingMsg := portal.bridge.DB.Message.GetByMXID(portal.Key, threadRoot) if existingMsg == nil { return "", fmt.Errorf("unknown root event") } else if existingMsg.ThreadID != "" { return "", fmt.Errorf("root event is already in a thread") } else { var ch *discordgo.Channel ch, err = sender.Session.MessageThreadStartComplex(portal.Key.ChannelID, existingMsg.DiscordID, &discordgo.ThreadStart{ Name: threadName, AutoArchiveDuration: 24 * 60, Type: discordgo.ChannelTypeGuildPublicThread, Location: "Message", }) if err != nil { return "", fmt.Errorf("error starting thread: %v", err) } portal.log.Debug(). Str("thread_root_mxid", threadRoot.String()). Str("thread_id", ch.ID). Msg("Created Discord thread") portal.bridge.GetThreadByID(existingMsg.DiscordID, existingMsg) return ch.ID, nil } } func (portal *Portal) sendErrorMessage(evt *event.Event, msgType, message string, confirmed bool) id.EventID { if !portal.bridge.Config.Bridge.MessageErrorNotices { return "" } certainty := "may not have been" if confirmed { certainty = "was not" } if portal.RelayWebhookSecret != "" { message = strings.ReplaceAll(message, portal.RelayWebhookSecret, "") } content := &event.MessageEventContent{ MsgType: event.MsgNotice, Body: fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, message), } relatable, ok := evt.Content.Parsed.(event.Relatable) if ok && relatable.OptionalGetRelatesTo().GetThreadParent() != "" { content.GetRelatesTo().SetThread(relatable.OptionalGetRelatesTo().GetThreadParent(), evt.ID) } resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) if err != nil { portal.log.Warn().Err(err).Msg("Failed to send bridging error message") return "" } return resp.EventID } var ( errUnknownMsgType = errors.New("unknown msgtype") errUnexpectedParsedContentType = errors.New("unexpected parsed content type") errUserNotReceiver = errors.New("user is not portal receiver") errUserNotLoggedIn = errors.New("user is not logged in and portal doesn't have webhook") errUnknownEditTarget = errors.New("unknown edit target") errUnknownRelationType = errors.New("unknown relation type") errTargetNotFound = errors.New("target event not found") errUnknownEmoji = errors.New("unknown emoji") errCantStartThread = errors.New("can't create thread without being logged into Discord") ) func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string, checkpointError error) { var restErr *discordgo.RESTError switch { case errors.Is(err, errUnknownMsgType), errors.Is(err, errUnknownRelationType), errors.Is(err, errUnexpectedParsedContentType), errors.Is(err, errUnknownEmoji), errors.Is(err, id.InvalidContentURI), errors.Is(err, attachment.UnsupportedVersion), errors.Is(err, attachment.UnsupportedAlgorithm), errors.Is(err, errCantStartThread): return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "", nil case errors.Is(err, attachment.HashMismatch), errors.Is(err, attachment.InvalidKey), errors.Is(err, attachment.InvalidInitVector): return event.MessageStatusUndecryptable, event.MessageStatusFail, true, true, "", nil case errors.Is(err, errUserNotReceiver), errors.Is(err, errUserNotLoggedIn): return event.MessageStatusNoPermission, event.MessageStatusFail, true, false, "", nil case errors.Is(err, errUnknownEditTarget): return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil case errors.Is(err, errTargetNotFound): return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil case errors.As(err, &restErr): if restErr.Message != nil && (restErr.Message.Code != 0 || len(restErr.Message.Message) > 0) { reason, humanMessage = restErrorToStatusReason(restErr.Message) status = event.MessageStatusFail isCertain = true sendNotice = true checkpointError = fmt.Errorf("HTTP %d: %d: %s", restErr.Response.StatusCode, restErr.Message.Code, restErr.Message.Message) if len(restErr.Message.Errors) > 0 { jsonExtraErrors, _ := json.Marshal(restErr.Message.Errors) checkpointError = fmt.Errorf("%w (%s)", checkpointError, jsonExtraErrors) } return } else if restErr.Response.StatusCode == http.StatusBadRequest && bytes.HasPrefix(restErr.ResponseBody, []byte(`{"captcha_key"`)) { return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Captcha error", errors.New("captcha required") } else if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusServiceUnavailable || restErr.Response.StatusCode == http.StatusBadGateway || restErr.Response.StatusCode == http.StatusGatewayTimeout) { return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, fmt.Sprintf("HTTP %s", restErr.Response.Status), fmt.Errorf("HTTP %d", restErr.Response.StatusCode) } fallthrough case errors.Is(err, context.DeadlineExceeded): return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", context.DeadlineExceeded case strings.HasSuffix(err.Error(), "(Client.Timeout exceeded while awaiting headers)"): return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", errors.New("HTTP request timed out") case errors.Is(err, syscall.ECONNRESET): return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", errors.New("connection reset") default: return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", nil } } func restErrorToStatusReason(msg *discordgo.APIErrorMessage) (reason event.MessageStatusReason, humanMessage string) { switch msg.Code { case discordgo.ErrCodeRequestEntityTooLarge: return event.MessageStatusUnsupported, "Attachment is too large" case discordgo.ErrCodeUnknownEmoji: return event.MessageStatusUnsupported, "Unsupported emoji" case discordgo.ErrCodeMissingPermissions, discordgo.ErrCodeMissingAccess: return event.MessageStatusUnsupported, "You don't have the permissions to do that" case discordgo.ErrCodeCannotSendMessagesToThisUser: return event.MessageStatusUnsupported, "You can't send messages to this user" case discordgo.ErrCodeCannotSendMessagesInVoiceChannel: return event.MessageStatusUnsupported, "You can't send messages in a non-text channel" case discordgo.ErrCodeInvalidFormBody: contentErrs := msg.Errors["content"].Errors if len(contentErrs) == 1 && contentErrs[0].Code == "BASE_TYPE_MAX_LENGTH" { return event.MessageStatusUnsupported, "Message is too long: " + contentErrs[0].Message } } return event.MessageStatusGenericError, fmt.Sprintf("%d: %s", msg.Code, msg.Message) } func (portal *Portal) sendStatusEvent(evtID id.EventID, err error) { if !portal.bridge.Config.Bridge.MessageStatusEvents { return } intent := portal.bridge.Bot if !portal.Encrypted { // Bridge bot isn't present in unencrypted DMs intent = portal.MainIntent() } stateKey, _ := portal.getBridgeInfo() content := event.BeeperMessageStatusEventContent{ Network: stateKey, RelatesTo: event.RelatesTo{ Type: event.RelReference, EventID: evtID, }, Status: event.MessageStatusSuccess, } if err == nil { content.Status = event.MessageStatusSuccess } else { var checkpointErr error content.Reason, content.Status, _, _, content.Message, checkpointErr = errorToStatusReason(err) if checkpointErr != nil { content.Error = checkpointErr.Error() } else { content.Error = err.Error() } } _, err = intent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content) if err != nil { portal.log.Err(err).Str("event_id", evtID.String()).Msg("Failed to send message status event") } } func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string) { var msgType string switch evt.Type { case event.EventMessage, event.EventSticker: msgType = "message" case event.EventReaction: msgType = "reaction" case event.EventRedaction: msgType = "redaction" default: msgType = "unknown event" } level := zerolog.DebugLevel if err != nil && part != "Ignoring" { level = zerolog.ErrorLevel } logEvt := portal.log.WithLevel(level). Str("action", "send matrix message metrics"). Str("event_type", evt.Type.Type). Str("event_id", evt.ID.String()). Str("sender", evt.Sender.String()) if evt.Type == event.EventRedaction { logEvt.Str("redacts", evt.Redacts.String()) } if err != nil { logEvt.Err(err). Str("result", fmt.Sprintf("%s event", part)). Msg("Matrix event not handled") reason, statusCode, isCertain, sendNotice, humanMessage, checkpointErr := errorToStatusReason(err) if checkpointErr == nil { checkpointErr = err } checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode) portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, checkpointErr, checkpointStatus, 0) if sendNotice { if humanMessage == "" { humanMessage = err.Error() } portal.sendErrorMessage(evt, msgType, humanMessage, isCertain) } portal.sendStatusEvent(evt.ID, err) } else { logEvt.Err(err).Msg("Matrix event handled successfully") portal.sendDeliveryReceipt(evt.ID) portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) portal.sendStatusEvent(evt.ID, nil) } } func (br *DiscordBridge) serveMediaProxy(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) mxc := id.ContentURI{ Homeserver: vars["server"], FileID: vars["mediaID"], } checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"]) if err != nil || len(checksum) != 32 { w.WriteHeader(http.StatusNotFound) return } _, expectedChecksum := br.hashMediaProxyURL(mxc) if !hmac.Equal(checksum, expectedChecksum) { w.WriteHeader(http.StatusNotFound) return } reader, err := br.Bot.Download(mxc) if err != nil { br.ZLog.Warn().Err(err).Msg("Failed to download media to proxy") w.WriteHeader(http.StatusInternalServerError) return } buf := make([]byte, 32*1024) n, err := io.ReadFull(reader, buf) if err != nil && (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { br.ZLog.Warn().Err(err).Msg("Failed to read first part of media to proxy") w.WriteHeader(http.StatusBadGateway) return } w.Header().Add("Content-Type", http.DetectContentType(buf[:n])) if n < len(buf) { w.Header().Add("Content-Length", strconv.Itoa(n)) } w.WriteHeader(http.StatusOK) _, err = w.Write(buf[:n]) if err != nil { return } if n >= len(buf) { _, _ = io.CopyBuffer(w, reader, buf) } } func (br *DiscordBridge) hashMediaProxyURL(mxc id.ContentURI) (string, []byte) { path := fmt.Sprintf("/mautrix-discord/avatar/%s/%s/", mxc.Homeserver, mxc.FileID) checksum := hmac.New(sha256.New, []byte(br.Config.Bridge.AvatarProxyKey)) checksum.Write([]byte(path)) return path, checksum.Sum(nil) } func (br *DiscordBridge) makeMediaProxyURL(mxc id.ContentURI) string { if br.Config.Bridge.PublicAddress == "" { return "" } path, checksum := br.hashMediaProxyURL(mxc) return br.Config.Bridge.PublicAddress + path + base64.RawURLEncoding.EncodeToString(checksum) } func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) { member := portal.bridge.StateStore.GetMember(portal.MXID, sender.MXID) name = member.Displayname if name == "" { name = sender.MXID.String() } mxc := member.AvatarURL.ParseOrIgnore() if !mxc.IsEmpty() && portal.bridge.Config.Bridge.PublicAddress != "" { avatarURL = portal.bridge.makeMediaProxyURL(mxc) } return } const replyEmbedMaxLines = 1 const replyEmbedMaxChars = 72 func cutBody(body string) string { lines := strings.Split(strings.TrimSpace(body), "\n") var output string for i, line := range lines { if i >= replyEmbedMaxLines { output += " […]" break } if i > 0 { output += "\n" } output += line if len(output) > replyEmbedMaxChars { output = output[:replyEmbedMaxChars] + "…" break } } return output } func (portal *Portal) convertReplyMessageToEmbed(eventID id.EventID, url string) (*discordgo.MessageEmbed, error) { evt, err := portal.getEvent(eventID) if err != nil { return nil, fmt.Errorf("failed to get reply target event: %w", err) } content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok { return nil, fmt.Errorf("unsupported event type %s / %T", evt.Type.String(), evt.Content.Parsed) } content.RemoveReplyFallback() var targetUser string puppet := portal.bridge.GetPuppetByMXID(evt.Sender) if puppet != nil { targetUser = fmt.Sprintf("<@%s>", puppet.ID) } else if user := portal.bridge.GetUserByMXID(evt.Sender); user != nil && user.DiscordID != "" { targetUser = fmt.Sprintf("<@%s>", user.DiscordID) } else if member := portal.bridge.StateStore.GetMember(portal.MXID, evt.Sender); member != nil && member.Displayname != "" { targetUser = member.Displayname } else { targetUser = evt.Sender.String() } body := escapeDiscordMarkdown(cutBody(content.Body)) body = fmt.Sprintf("**[Replying to](%s) %s**\n%s", url, targetUser, body) embed := &discordgo.MessageEmbed{Description: body} return embed, nil } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") return } channelID := portal.Key.ChannelID sess := sender.Session if sess == nil && portal.RelayWebhookID == "" { go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") return } isWebhookSend := sess == nil var threadID string if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil { edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID) if edits != nil { discordContent, allowedMentions := portal.parseMatrixHTML(content.NewContent) var err error var msg *discordgo.Message if !isWebhookSend { // TODO save edit in message table msg, err = sess.ChannelMessageEdit(edits.DiscordProtoChannelID(), edits.DiscordID, discordContent) } else { msg, err = relayClient.WebhookMessageEdit(portal.RelayWebhookID, portal.RelayWebhookSecret, edits.DiscordID, &discordgo.WebhookEdit{ Content: &discordContent, AllowedMentions: allowedMentions, }) } go portal.sendMessageMetrics(evt, err, "Failed to edit") if msg.EditedTimestamp != nil { edits.UpdateEditTimestamp(*msg.EditedTimestamp) } } else { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEditTarget, editMXID), "Ignoring") } return } else if threadRoot := content.GetRelatesTo().GetThreadParent(); threadRoot != "" { existingThread := portal.bridge.GetThreadByRootMXID(threadRoot) if existingThread != nil { threadID = existingThread.ID existingThread.initialBackfillAttempted = true } else { if isWebhookSend { // TODO start thread with bot? go portal.sendMessageMetrics(evt, errCantStartThread, "Dropping") return } var err error threadID, err = portal.startThreadFromMatrix(sender, threadRoot) if err != nil { portal.log.Warn().Err(err). Str("thread_root_mxid", threadRoot.String()). Msg("Failed to start thread from Matrix") } } } if threadID != "" { channelID = threadID } var sendReq discordgo.MessageSend var description string if evt.Type == event.EventSticker { content.MsgType = event.MsgImage if mimeData := mimetype.Lookup(content.Info.MimeType); mimeData != nil { description = content.Body content.Body = "sticker" + mimeData.Extension() } } if replyToMXID := content.RelatesTo.GetNonFallbackReplyTo(); replyToMXID != "" { replyTo := portal.bridge.DB.Message.GetByMXID(portal.Key, replyToMXID) if replyTo != nil && replyTo.ThreadID == threadID { if isWebhookSend { messageURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", portal.GuildID, channelID, replyTo.DiscordID) embed, err := portal.convertReplyMessageToEmbed(replyTo.MXID, messageURL) if err != nil { portal.log.Warn().Err(err).Msg("Failed to convert reply message to embed for webhook send") } else if embed != nil { sendReq.Embeds = []*discordgo.MessageEmbed{embed} } } else { sendReq.Reference = &discordgo.MessageReference{ ChannelID: channelID, MessageID: replyTo.DiscordID, } } } } switch content.MsgType { case event.MsgText, event.MsgEmote, event.MsgNotice: sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content) if content.MsgType == event.MsgEmote { sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content) } case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: data, err := downloadMatrixAttachment(portal.MainIntent(), content) if err != nil { go portal.sendMessageMetrics(evt, err, "Error downloading media in") return } filename := content.Body if content.FileName != "" && content.FileName != content.Body { filename = content.FileName sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content) } if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser { att := &discordgo.MessageAttachment{ ID: "0", Filename: filename, Description: description, } sendReq.Attachments = []*discordgo.MessageAttachment{att} prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ Files: []*discordgo.FilePrepare{{ Size: len(data), Name: att.Filename, ID: sender.NextDiscordUploadID(), }}, }) if err != nil { go portal.sendMessageMetrics(evt, err, "Error preparing to reupload media in") return } prepared := prep.Attachments[0] att.UploadedFilename = prepared.UploadFilename err = uploadDiscordAttachment(prepared.UploadURL, data) if err != nil { go portal.sendMessageMetrics(evt, err, "Error reuploading media in") return } } else { sendReq.Files = []*discordgo.File{{ Name: filename, ContentType: content.Info.MimeType, Reader: bytes.NewReader(data), }} } default: go portal.sendMessageMetrics(evt, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType), "Ignoring") return } if !isWebhookSend { // AllowedMentions must not be set for real users, and it's also not that useful for personal bots. // It's only important for relaying, where the webhook may have higher permissions than the user on Matrix. sendReq.AllowedMentions = nil } else if strings.Contains(sendReq.Content, "@everyone") || strings.Contains(sendReq.Content, "@here") { powerLevels, err := portal.MainIntent().PowerLevels(portal.MXID) if err != nil { portal.log.Warn().Err(err). Str("user_id", sender.MXID.String()). Msg("Failed to get power levels to check if user can use @everyone") } else if powerLevels.GetUserLevel(sender.MXID) >= powerLevels.Notifications.Room() { sendReq.AllowedMentions.Parse = append(sendReq.AllowedMentions.Parse, discordgo.AllowedMentionTypeEveryone) } } sendReq.Nonce = generateNonce() var msg *discordgo.Message var err error if !isWebhookSend { msg, err = sess.ChannelMessageSendComplex(channelID, &sendReq) } else { username, avatarURL := portal.getRelayUserMeta(sender) msg, err = relayClient.WebhookThreadExecute(portal.RelayWebhookID, portal.RelayWebhookSecret, true, threadID, &discordgo.WebhookParams{ Content: sendReq.Content, Username: username, AvatarURL: avatarURL, TTS: sendReq.TTS, Files: sendReq.Files, Components: sendReq.Components, Embeds: sendReq.Embeds, AllowedMentions: sendReq.AllowedMentions, }) } go portal.sendMessageMetrics(evt, err, "Error sending") if msg != nil { dbMsg := portal.bridge.DB.Message.New() dbMsg.Channel = portal.Key dbMsg.DiscordID = msg.ID if len(msg.Attachments) > 0 { dbMsg.AttachmentID = msg.Attachments[0].ID } dbMsg.MXID = evt.ID if sess != nil { dbMsg.SenderID = sender.DiscordID } else { dbMsg.SenderID = portal.RelayWebhookID } dbMsg.SenderMXID = sender.MXID dbMsg.Timestamp, _ = discordgo.SnowflakeTimestamp(msg.ID) dbMsg.ThreadID = threadID dbMsg.Insert() } } func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) { if portal.bridge.Config.Bridge.DeliveryReceipts { err := portal.bridge.Bot.MarkRead(portal.MXID, eventID) if err != nil { portal.log.Warn().Err(err). Str("event_id", eventID.String()). Msg("Failed to send delivery receipt") } } } func (portal *Portal) HandleMatrixLeave(brSender bridge.User) { sender := brSender.(*User) if portal.IsPrivateChat() && sender.DiscordID == portal.Key.Receiver { portal.log.Debug().Msg("User left private chat portal, cleaning up and deleting...") portal.cleanup(false) portal.RemoveMXID() } else { portal.cleanupIfEmpty() } } func (portal *Portal) HandleMatrixKick(brSender bridge.User, brTarget bridge.Ghost) {} func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brTarget bridge.Ghost) {} func (portal *Portal) Delete() { portal.Portal.Delete() portal.bridge.portalsLock.Lock() delete(portal.bridge.portalsByID, portal.Key) if portal.MXID != "" { delete(portal.bridge.portalsByMXID, portal.MXID) } portal.bridge.portalsLock.Unlock() } func (portal *Portal) cleanupIfEmpty() { if portal.MXID == "" { return } users, err := portal.getMatrixUsers() if err != nil { portal.log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up") return } if len(users) == 0 { portal.log.Info().Msg("Room seems to be empty, cleaning up...") portal.cleanup(false) portal.RemoveMXID() } } func (portal *Portal) RemoveMXID() { portal.bridge.portalsLock.Lock() defer portal.bridge.portalsLock.Unlock() if portal.MXID == "" { return } delete(portal.bridge.portalsByMXID, portal.MXID) portal.MXID = "" portal.log = portal.bridge.ZLog.With(). Str("channel_id", portal.Key.ChannelID). Str("channel_receiver", portal.Key.Receiver). Str("room_id", portal.MXID.String()). Logger() portal.AvatarSet = false portal.NameSet = false portal.TopicSet = false portal.Encrypted = false portal.InSpace = "" portal.FirstEventID = "" portal.Update() portal.bridge.DB.Message.DeleteAll(portal.Key) } func (portal *Portal) cleanup(puppetsOnly bool) { if portal.MXID == "" { return } intent := portal.MainIntent() if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { err := intent.BeeperDeleteRoom(portal.MXID) if err != nil && !errors.Is(err, mautrix.MNotFound) { portal.log.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint") } return } if portal.IsPrivateChat() { _, err := portal.MainIntent().LeaveRoom(portal.MXID) if err != nil { portal.log.Warn().Err(err).Msg("Failed to leave private chat portal with main intent") } return } portal.bridge.cleanupRoom(intent, portal.MXID, puppetsOnly, portal.log) } func (br *DiscordBridge) cleanupRoom(intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool, log zerolog.Logger) { members, err := intent.JoinedMembers(mxid) if err != nil { log.Err(err).Msg("Failed to get portal members for cleanup") return } for member := range members.Joined { if member == intent.UserID { continue } puppet := br.GetPuppetByMXID(member) if puppet != nil { _, err = puppet.DefaultIntent().LeaveRoom(mxid) if err != nil { log.Err(err).Msg("Error leaving as puppet while cleaning up portal") } } else if !puppetsOnly { _, err = intent.KickUser(mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) if err != nil { log.Err(err).Msg("Error kicking user while cleaning up portal") } } } _, err = intent.LeaveRoom(mxid) if err != nil { log.Err(err).Msg("Error leaving with main intent while cleaning up portal") } } func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { members, err := portal.MainIntent().JoinedMembers(portal.MXID) if err != nil { return nil, fmt.Errorf("failed to get member list: %w", err) } var users []id.UserID for userID := range members.Joined { _, isPuppet := portal.bridge.ParsePuppetMXID(userID) if !isPuppet && userID != portal.bridge.Bot.UserID { users = append(users, userID) } } return users, nil } func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } else if !sender.IsLoggedIn() { //go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring") return } reaction := evt.Content.AsReaction() if reaction.RelatesTo.Type != event.RelAnnotation { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownRelationType, reaction.RelatesTo.Type), "Ignoring") return } if reaction.RelatesTo.Key == JoinThreadReaction { thread := portal.bridge.GetThreadByRootOrCreationNoticeMXID(reaction.RelatesTo.EventID) if thread == nil { go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring thread join") return } thread.Join(sender) return } msg := portal.bridge.DB.Message.GetByMXID(portal.Key, reaction.RelatesTo.EventID) if msg == nil { go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring") return } firstMsg := msg if msg.AttachmentID != "" { firstMsg = portal.bridge.DB.Message.GetFirstByDiscordID(portal.Key, msg.DiscordID) // TODO should the emoji be rerouted to the first message if it's different? } // Figure out if this is a custom emoji or not. emojiID := reaction.RelatesTo.Key if strings.HasPrefix(emojiID, "mxc://") { uri, _ := id.ParseContentURI(emojiID) emojiInfo := portal.bridge.DMA.GetEmojiInfo(uri) if emojiInfo != nil { emojiID = fmt.Sprintf("%s:%d", emojiInfo.Name, emojiInfo.EmojiID) } else if emojiFile := portal.bridge.DB.File.GetEmojiByMXC(uri); emojiFile != nil && emojiFile.ID != "" && emojiFile.EmojiName != "" { emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID) } else { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring") return } } else { emojiID = variationselector.FullyQualify(emojiID) } existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, msg.DiscordID, sender.DiscordID, emojiID) if existing != nil { portal.log.Debug(). Str("event_id", evt.ID.String()). Str("existing_reaction_mxid", existing.MXID.String()). Msg("Dropping duplicate Matrix reaction") go portal.sendMessageMetrics(evt, nil, "") return } err := sender.Session.MessageReactionAdd(msg.DiscordProtoChannelID(), msg.DiscordID, emojiID) go portal.sendMessageMetrics(evt, err, "Error sending") if err == nil { dbReaction := portal.bridge.DB.Reaction.New() dbReaction.Channel = portal.Key dbReaction.MessageID = msg.DiscordID dbReaction.FirstAttachmentID = firstMsg.AttachmentID dbReaction.Sender = sender.DiscordID dbReaction.EmojiName = emojiID dbReaction.ThreadID = msg.ThreadID dbReaction.MXID = evt.ID dbReaction.Insert() } } func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool, thread *Thread, member *discordgo.Member) { puppet := portal.bridge.GetPuppetByID(reaction.UserID) if member != nil { puppet.UpdateInfo(user, member.User, nil) } intent := puppet.IntentFor(portal) log := portal.log.With(). Str("message_id", reaction.MessageID). Str("author_id", reaction.UserID). Bool("add", add). Str("action", "discord reaction"). Logger() var discordID string var matrixReaction string if reaction.Emoji.ID != "" { reactionMXC := portal.getEmojiMXCByDiscordID(reaction.Emoji.ID, reaction.Emoji.Name, reaction.Emoji.Animated) if reactionMXC.IsEmpty() { return } matrixReaction = reactionMXC.String() discordID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID) } else { discordID = reaction.Emoji.Name matrixReaction = variationselector.Add(reaction.Emoji.Name) } // Find the message that we're working with. message := portal.bridge.DB.Message.GetByDiscordID(portal.Key, reaction.MessageID) if message == nil { log.Debug().Msg("Failed to add reaction to message: message not found") return } // Lookup an existing reaction existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, message[0].DiscordID, reaction.UserID, discordID) if !add { if existing == nil { log.Debug().Msg("Failed to remove reaction: reaction not found") return } resp, err := intent.RedactEvent(portal.MXID, existing.MXID) if err != nil { log.Err(err).Msg("Failed to remove reaction") } else { go portal.sendDeliveryReceipt(resp.EventID) } existing.Delete() return } else if existing != nil { log.Debug().Msg("Ignoring duplicate reaction") return } content := event.ReactionEventContent{ RelatesTo: event.RelatesTo{ EventID: message[0].MXID, Type: event.RelAnnotation, Key: matrixReaction, }, } extraContent := map[string]any{} if reaction.Emoji.ID != "" { extraContent["fi.mau.discord.reaction"] = map[string]any{ "id": reaction.Emoji.ID, "name": reaction.Emoji.Name, "mxc": matrixReaction, } wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name) extraContent["com.beeper.reaction.shortcode"] = wrappedShortcode if !portal.bridge.Config.Bridge.CustomEmojiReactions { content.RelatesTo.Key = wrappedShortcode } } resp, err := intent.SendMessageEvent(portal.MXID, event.EventReaction, &event.Content{ Parsed: &content, Raw: extraContent, }) if err != nil { log.Err(err).Msg("Failed to send reaction") return } if existing == nil { dbReaction := portal.bridge.DB.Reaction.New() dbReaction.Channel = portal.Key dbReaction.MessageID = message[0].DiscordID dbReaction.FirstAttachmentID = message[0].AttachmentID dbReaction.Sender = reaction.UserID dbReaction.EmojiName = discordID dbReaction.MXID = resp.EventID if thread != nil { dbReaction.ThreadID = thread.ID } dbReaction.Insert() portal.sendDeliveryReceipt(dbReaction.MXID) } } func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } sess := sender.Session if sess == nil && portal.RelayWebhookID == "" { go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") return } message := portal.bridge.DB.Message.GetByMXID(portal.Key, evt.Redacts) if message != nil { var err error // TODO add support for deleting individual attachments from messages if sess != nil { err = sess.ChannelMessageDelete(message.DiscordProtoChannelID(), message.DiscordID) } else { // TODO pre-validate that the message was sent by the webhook? err = relayClient.WebhookMessageDelete(portal.RelayWebhookID, portal.RelayWebhookSecret, message.DiscordID) } go portal.sendMessageMetrics(evt, err, "Error sending") if err == nil { message.Delete() } return } if sess != nil { reaction := portal.bridge.DB.Reaction.GetByMXID(evt.Redacts) if reaction != nil && reaction.Channel == portal.Key { err := sess.MessageReactionRemove(reaction.DiscordProtoChannelID(), reaction.MessageID, reaction.EmojiName, reaction.Sender) go portal.sendMessageMetrics(evt, err, "Error sending") if err == nil { reaction.Delete() } return } } go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring") } func (portal *Portal) HandleMatrixReadReceipt(brUser bridge.User, eventID id.EventID, receipt event.ReadReceipt) { sender := brUser.(*User) if sender.Session == nil { return } var thread *Thread discordThreadID := "" if receipt.ThreadID != "" && receipt.ThreadID != event.ReadReceiptThreadMain { thread = portal.bridge.GetThreadByRootMXID(receipt.ThreadID) if thread != nil { discordThreadID = thread.ID } } log := portal.log.With(). Str("sender", brUser.GetMXID().String()). Str("event_id", eventID.String()). Str("action", "matrix read receipt"). Str("discord_thread_id", discordThreadID). Logger() if thread != nil { if portal.bridge.Config.Bridge.AutojoinThreadOnOpen { thread.Join(sender) } if eventID == thread.CreationNoticeMXID { log.Debug().Msg("Dropping read receipt for thread creation notice") return } } if !sender.Session.IsUser { // Drop read receipts from bot users (after checking for the thread auto-join stuff) return } msg := portal.bridge.DB.Message.GetByMXID(portal.Key, eventID) if msg == nil { msg = portal.bridge.DB.Message.GetClosestBefore(portal.Key, discordThreadID, receipt.Timestamp) if msg == nil { log.Debug().Msg("Dropping read receipt: no messages found") return } else { log = log.With(). Str("closest_event_id", msg.MXID.String()). Str("closest_message_id", msg.DiscordID). Logger() log.Debug().Msg("Read receipt target event not found, using closest message") } } else { log = log.With(). Str("message_id", msg.DiscordID). Logger() } if receipt.ThreadID != "" && msg.ThreadID != discordThreadID { log.Debug(). Str("receipt_thread_event_id", receipt.ThreadID.String()). Str("message_discord_thread_id", msg.ThreadID). Msg("Dropping read receipt: thread ID mismatch") return } resp, err := sender.Session.ChannelMessageAckNoToken(msg.DiscordProtoChannelID(), msg.DiscordID) if err != nil { log.Err(err).Msg("Failed to send read receipt to Discord") } 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") } } func typingDiff(prev, new []id.UserID) (started []id.UserID) { OuterNew: for _, userID := range new { for _, previousUserID := range prev { if userID == previousUserID { continue OuterNew } } started = append(started, userID) } return } func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) { portal.currentlyTypingLock.Lock() defer portal.currentlyTypingLock.Unlock() startedTyping := typingDiff(portal.currentlyTyping, newTyping) portal.currentlyTyping = newTyping for _, userID := range startedTyping { user := portal.bridge.GetUserByMXID(userID) if user != nil && user.Session != nil { user.ViewingChannel(portal) err := user.Session.ChannelTyping(portal.Key.ChannelID) if err != nil { portal.log.Warn().Err(err). Str("user_id", user.MXID.String()). Msg("Failed to mark user as typing") } else { portal.log.Debug(). Str("user_id", user.MXID.String()). Msg("Marked user as typing") } } } } func (portal *Portal) UpdateName(meta *discordgo.Channel) bool { var parentName, guildName string if portal.Parent != nil { parentName = portal.Parent.PlainName } if portal.Guild != nil { guildName = portal.Guild.PlainName } plainNameChanged := portal.PlainName != meta.Name portal.PlainName = meta.Name return portal.UpdateNameDirect(portal.bridge.Config.Bridge.FormatChannelName(config.ChannelNameParams{ Name: meta.Name, ParentName: parentName, GuildName: guildName, NSFW: meta.NSFW, Type: meta.Type, }), false) || plainNameChanged } func (portal *Portal) UpdateNameDirect(name string, isFriendNick bool) bool { if portal.FriendNick && !isFriendNick { return false } else if portal.Name == name && (portal.NameSet || portal.MXID == "" || (!portal.shouldSetDMRoomMetadata() && !isFriendNick)) { return false } portal.log.Debug(). Str("old_name", portal.Name). Str("new_name", name). Msg("Updating portal name") portal.Name = name portal.NameSet = false portal.updateRoomName() return true } func (portal *Portal) updateRoomName() { if portal.MXID != "" && (portal.shouldSetDMRoomMetadata() || portal.FriendNick) { _, err := portal.MainIntent().SetRoomName(portal.MXID, portal.Name) if err != nil { portal.log.Err(err).Msg("Failed to update room name") } else { portal.NameSet = true } } } func (portal *Portal) UpdateAvatarFromPuppet(puppet *Puppet) bool { if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (puppet.Avatar == "" || portal.AvatarSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { return false } portal.log.Debug(). Str("old_avatar_id", portal.Avatar). Str("new_avatar_id", puppet.Avatar). Msg("Updating avatar from puppet") portal.Avatar = puppet.Avatar portal.AvatarURL = puppet.AvatarURL portal.AvatarSet = false portal.updateRoomAvatar() return true } func (portal *Portal) UpdateGroupDMAvatar(iconID string) bool { if portal.Avatar == iconID && (iconID == "") == portal.AvatarURL.IsEmpty() && (iconID == "" || portal.AvatarSet || portal.MXID == "") { return false } portal.log.Debug(). Str("old_avatar_id", portal.Avatar). Str("new_avatar_id", portal.Avatar). Msg("Updating group DM avatar") portal.Avatar = iconID portal.AvatarSet = false portal.AvatarURL = id.ContentURI{} if portal.Avatar != "" { // TODO direct media support copied, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), discordgo.EndpointGroupIcon(portal.Key.ChannelID, portal.Avatar), false, AttachmentMeta{ AttachmentID: fmt.Sprintf("private_channel_avatar/%s/%s", portal.Key.ChannelID, iconID), }) if err != nil { portal.log.Err(err).Str("avatar_id", iconID).Msg("Failed to reupload channel avatar") return true } portal.AvatarURL = copied.MXC } portal.updateRoomAvatar() return true } func (portal *Portal) updateRoomAvatar() { if portal.MXID == "" || portal.AvatarURL.IsEmpty() || !portal.shouldSetDMRoomMetadata() { return } _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) if err != nil { portal.log.Err(err).Msg("Failed to update room avatar") } else { portal.AvatarSet = true } } func (portal *Portal) UpdateTopic(topic string) bool { if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { return false } portal.log.Debug(). Str("old_topic", portal.Topic). Str("new_topic", topic). Msg("Updating portal topic") portal.Topic = topic portal.TopicSet = false portal.updateRoomTopic() return true } func (portal *Portal) updateRoomTopic() { if portal.MXID != "" { _, err := portal.MainIntent().SetRoomTopic(portal.MXID, portal.Topic) if err != nil { portal.log.Err(err).Msg("Failed to update room topic") } else { portal.TopicSet = true } } } func (portal *Portal) removeFromSpace() { if portal.InSpace == "" { return } log := portal.log.With().Str("space_mxid", portal.InSpace.String()).Logger() log.Debug().Msg("Removing room from space") _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, portal.InSpace.String(), struct{}{}) if err != nil { log.Warn().Err(err).Msg("Failed to clear m.space.parent event in room") } _, err = portal.bridge.Bot.SendStateEvent(portal.InSpace, event.StateSpaceChild, portal.MXID.String(), struct{}{}) if err != nil { log.Warn().Err(err).Msg("Failed to clear m.space.child event in space") } portal.InSpace = "" } func (portal *Portal) addToSpace(mxid id.RoomID) bool { if portal.InSpace == mxid { return false } portal.removeFromSpace() if mxid == "" { return true } log := portal.log.With().Str("space_mxid", mxid.String()).Logger() _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, mxid.String(), &event.SpaceParentEventContent{ Via: []string{portal.bridge.AS.HomeserverDomain}, Canonical: true, }) if err != nil { log.Warn().Err(err).Msg("Failed to set m.space.parent event in room") } _, err = portal.bridge.Bot.SendStateEvent(mxid, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ Via: []string{portal.bridge.AS.HomeserverDomain}, // TODO order }) if err != nil { log.Warn().Err(err).Msg("Failed to set m.space.child event in space") } else { portal.InSpace = mxid } return true } func (portal *Portal) UpdateParent(parentID string) bool { if portal.ParentID == parentID { return false } portal.log.Debug(). Str("old_parent_id", portal.ParentID). Str("new_parent_id", parentID). Msg("Updating parent ID") portal.ParentID = parentID if portal.ParentID != "" { portal.Parent = portal.bridge.GetPortalByID(database.NewPortalKey(parentID, ""), discordgo.ChannelTypeGuildCategory) } else { portal.Parent = nil } return true } func (portal *Portal) ExpectedSpaceID() id.RoomID { if portal.Parent != nil { return portal.Parent.MXID } else if portal.Guild != nil { return portal.Guild.MXID } return "" } func (portal *Portal) updateSpace(source *User) bool { if portal.MXID == "" { return false } if portal.Parent != nil { if portal.Parent.MXID != "" { portal.log.Warn().Str("parent_id", portal.ParentID).Msg("Parent portal has no Matrix room, creating...") err := portal.Parent.CreateMatrixRoom(source, nil) if err != nil { portal.log.Err(err).Str("parent_id", portal.ParentID).Msg("Failed to create Matrix room for parent") return false } } return portal.addToSpace(portal.Parent.MXID) } else if portal.Guild != nil { return portal.addToSpace(portal.Guild.MXID) } return false } func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discordgo.Channel { changed := false log := portal.log.With(). Str("action", "update info"). Str("through_user_mxid", source.MXID.String()). Str("through_user_dcid", source.DiscordID). Logger() if meta == nil { log.Debug().Msg("UpdateInfo called without metadata, fetching from user's state cache") meta, _ = source.Session.State.Channel(portal.Key.ChannelID) if meta == nil { log.Warn().Msg("No metadata found in state cache, fetching from server via user") var err error meta, err = source.Session.Channel(portal.Key.ChannelID) if err != nil { log.Err(err).Msg("Failed to fetch meta via user") return nil } } } if portal.Type != meta.Type { log.Warn(). Int("old_type", int(portal.Type)). Int("new_type", int(meta.Type)). Msg("Portal type changed") portal.Type = meta.Type changed = true } if portal.OtherUserID == "" && portal.IsPrivateChat() { if len(meta.Recipients) == 0 { var err error meta, err = source.Session.Channel(meta.ID) if err != nil { log.Err(err).Msg("Failed to fetch DM channel info to find other user ID") } } if len(meta.Recipients) > 0 { portal.OtherUserID = meta.Recipients[0].ID log.Info().Str("other_user_id", portal.OtherUserID).Msg("Found other user ID") changed = true } } if meta.GuildID != "" && portal.GuildID == "" { portal.GuildID = meta.GuildID portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true) changed = true } switch portal.Type { case discordgo.ChannelTypeDM: if portal.OtherUserID != "" { puppet := portal.bridge.GetPuppetByID(portal.OtherUserID) changed = portal.UpdateAvatarFromPuppet(puppet) || changed if rel, ok := source.relationships[portal.OtherUserID]; ok && rel.Nickname != "" { portal.FriendNick = true changed = portal.UpdateNameDirect(rel.Nickname, true) || changed } else { portal.FriendNick = false changed = portal.UpdateNameDirect(puppet.Name, false) || changed } } if portal.MXID != "" { portal.syncParticipants(source, meta.Recipients) } case discordgo.ChannelTypeGroupDM: changed = portal.UpdateGroupDMAvatar(meta.Icon) || changed if portal.MXID != "" { portal.syncParticipants(source, meta.Recipients) } fallthrough default: changed = portal.UpdateName(meta) || changed if portal.MXID != "" { portal.ensureUserInvited(source, false) } } changed = portal.UpdateTopic(meta.Topic) || changed changed = portal.UpdateParent(meta.ParentID) || changed // Private channels are added to the space in User.handlePrivateChannel if portal.GuildID != "" && portal.MXID != "" && portal.ExpectedSpaceID() != portal.InSpace { changed = portal.updateSpace(source) || changed } if changed { portal.UpdateBridgeInfo() portal.Update() } return meta }