2 Commits

Author SHA1 Message Date
batuhan içöz
89ac3632ec Preserve Discord bridge state codes and retry logic 2026-02-02 19:44:21 +01:00
batuhan içöz
30752fa48b Improve Discord bridge state handling 2026-02-02 19:34:57 +01:00
23 changed files with 644 additions and 1111 deletions

2
go.mod
View File

@@ -41,4 +41,4 @@ require (
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec

4
go.sum
View File

@@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0 h1:cKnAGjgYtCO4DLQePwsx1bBbX2imPSggm8da4t2AzBQ=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=

View File

@@ -37,13 +37,11 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
return nil, bridgev2.ErrNotLoggedIn
}
channelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
channelID := discordid.ParsePortalID(fetchParams.Portal.ID)
log := zerolog.Ctx(ctx).With().
Str("action", "fetch messages").
Str("channel_id", channelID).
Int("desired_count", fetchParams.Count).
Bool("forward", fetchParams.Forward).Logger()
ctx = log.WithContext(ctx)
var beforeID string
var afterID string
@@ -63,35 +61,10 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
log.Debug().Msg("Fetching channel history for backfill")
msgs, err := dc.Session.ChannelMessages(channelID, count, beforeID, afterID, "")
if err != nil {
dc.handlePossible40002(err)
return nil, err
}
// Update our user cache with all of the users present in the response. This
// indirectly makes `GetUserInfo` on `DiscordClient` return the information
// we've fetched above.
cachedDiscordUserIDs := dc.userCache.UpdateWithMessages(msgs)
{
log := zerolog.Ctx(ctx).With().
Str("action", "update ghosts via fetched messages").
Logger()
ctx := log.WithContext(ctx)
// Update/create all of the ghosts for the users involved. This lets us
// set a correct per-message profile on each message, even for users
// that we've never seen until now.
for _, discordUserID := range cachedDiscordUserIDs {
ghost, err := dc.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID))
if err != nil {
log.Err(err).Str("ghost_id", discordUserID).
Msg("Failed to get ghost associated with message")
continue
}
ghost.UpdateInfoIfNecessary(ctx, dc.UserLogin, bridgev2.RemoteEventMessage)
}
}
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)

View File

@@ -0,0 +1,153 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// 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 (
"errors"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/status"
)
const (
DiscordNotLoggedIn status.BridgeStateErrorCode = "dc-not-logged-in"
DiscordTransientDisconnect status.BridgeStateErrorCode = "dc-transient-disconnect"
DiscordInvalidAuth status.BridgeStateErrorCode = "dc-websocket-disconnect-4004"
DiscordHTTP40002 status.BridgeStateErrorCode = "dc-http-40002"
DiscordUnknownWebsocketErr status.BridgeStateErrorCode = "dc-unknown-websocket-error"
)
const discordDisconnectDebounce = 7 * time.Second
func init() {
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
DiscordNotLoggedIn: "You're not logged into Discord. Relogin to continue using the bridge.",
DiscordTransientDisconnect: "Temporarily disconnected from Discord, trying to reconnect.",
DiscordInvalidAuth: "Discord access token is no longer valid, please log in again.",
DiscordHTTP40002: "Discord requires a verified account, please verify and log in again.",
DiscordUnknownWebsocketErr: "Unknown Discord websocket error.",
})
}
func (d *DiscordClient) resetBridgeStateTracking() {
d.bridgeStateLock.Lock()
if d.disconnectTimer != nil {
d.disconnectTimer.Stop()
d.disconnectTimer = nil
}
d.invalidAuthDetected = false
d.bridgeStateLock.Unlock()
}
func (d *DiscordClient) markConnected() {
if d.UserLogin == nil {
return
}
d.bridgeStateLock.Lock()
if d.disconnectTimer != nil {
d.disconnectTimer.Stop()
d.disconnectTimer = nil
}
d.invalidAuthDetected = false
d.bridgeStateLock.Unlock()
d.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
func (d *DiscordClient) markInvalidAuth(message string) {
if d.UserLogin == nil {
return
}
d.bridgeStateLock.Lock()
d.invalidAuthDetected = true
if d.disconnectTimer != nil {
d.disconnectTimer.Stop()
d.disconnectTimer = nil
}
d.bridgeStateLock.Unlock()
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: DiscordInvalidAuth,
Message: message,
UserAction: status.UserActionRelogin,
})
}
func (d *DiscordClient) scheduleTransientDisconnect(message string) {
if d.UserLogin == nil {
return
}
d.bridgeStateLock.Lock()
if d.invalidAuthDetected {
d.bridgeStateLock.Unlock()
return
}
if d.disconnectTimer != nil {
d.disconnectTimer.Stop()
}
login := d.UserLogin
d.disconnectTimer = time.AfterFunc(discordDisconnectDebounce, func() {
d.bridgeStateLock.Lock()
d.disconnectTimer = nil
invalidAuth := d.invalidAuthDetected
d.bridgeStateLock.Unlock()
if invalidAuth {
return
}
login.BridgeState.Send(status.BridgeState{
StateEvent: status.StateTransientDisconnect,
Error: DiscordTransientDisconnect,
Message: message,
})
})
d.bridgeStateLock.Unlock()
}
func (d *DiscordClient) sendConnectFailure(err error, final bool) {
if d.UserLogin == nil || err == nil {
return
}
stateEvent := status.StateTransientDisconnect
if final {
stateEvent = status.StateUnknownError
}
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: stateEvent,
Error: DiscordUnknownWebsocketErr,
Message: err.Error(),
Info: map[string]any{
"go_error": err.Error(),
},
})
}
func (d *DiscordClient) handlePossible40002(err error) bool {
var restErr *discordgo.RESTError
if !errors.As(err, &restErr) || restErr.Message == nil || restErr.Message.Code != discordgo.ErrCodeActionRequiredVerifiedAccount {
return false
}
if d.UserLogin == nil {
return true
}
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: DiscordHTTP40002,
Message: restErr.Message.Message,
UserAction: status.UserActionRelogin,
})
return true
}

View File

@@ -65,7 +65,6 @@ var discordCaps = &event.RoomFeatures{
ID: capID(),
Reply: event.CapLevelFullySupported,
Reaction: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
Delete: event.CapLevelFullySupported,
Formatting: event.FormattingFeatureMap{
event.FmtBold: event.CapLevelFullySupported,
@@ -137,7 +136,10 @@ var discordCaps = &event.RoomFeatures{
},
LocationMessage: event.CapLevelUnsupported,
MaxTextLength: MaxTextLength,
// TODO: Support reactions.
// TODO: Support threads.
// TODO: Support editing.
// TODO: Support message deletion.
}
func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {

View File

@@ -18,171 +18,11 @@ package connector
import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space.
func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) {
selfEvtSender := d.selfEventSender()
return &bridgev2.ChatInfo{
Name: &guild.Name,
Topic: nil,
Members: &bridgev2.ChatMemberList{
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfEvtSender.Sender: {EventSender: selfEvtSender},
},
// As recommended by the spec, prohibit normal events by setting
// events_default to a suitably high number.
PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
},
Avatar: d.makeAvatarForGuild(guild),
Type: ptr.Ptr(database.RoomTypeSpace),
}, nil
}
func channelIsPrivate(ch *discordgo.Channel) bool {
return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
}
func (d *DiscordClient) makeAvatarForChannel(ctx context.Context, ch *discordgo.Channel) *bridgev2.Avatar {
// TODO make this configurable (ala workspace_avatar_in_rooms)
if !channelIsPrivate(ch) {
guild, err := d.Session.State.Guild(ch.GuildID)
if err != nil || guild == nil {
zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
return nil
}
return d.makeAvatarForGuild(guild)
}
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(ch.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
return d.simpleDownload(ctx, url, "channel/gdm icon")
},
Remove: ch.Icon == "",
}
}
func (d *DiscordClient) getPrivateChannelMemberList(ch *discordgo.Channel) bridgev2.ChatMemberList {
var members bridgev2.ChatMemberList
members.IsFull = true
members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
if len(ch.Recipients) > 0 {
selfEventSender := d.selfEventSender()
// Private channels' array of participants doesn't include ourselves,
// so inject ourselves as a member.
members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
for _, recipient := range ch.Recipients {
sender := d.makeEventSender(recipient)
members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
}
members.TotalMemberCount = len(ch.Recipients)
}
return members
}
// getChannelChatInfo computes [bridgev2.ChatInfo] for a guild channel or private (DM or group DM) channel.
func (d *DiscordClient) getChannelChatInfo(ctx context.Context, ch *discordgo.Channel) (*bridgev2.ChatInfo, error) {
var roomType database.RoomType
switch ch.Type {
case discordgo.ChannelTypeGuildCategory:
roomType = database.RoomTypeSpace
case discordgo.ChannelTypeDM:
roomType = database.RoomTypeDM
case discordgo.ChannelTypeGroupDM:
roomType = database.RoomTypeGroupDM
default:
roomType = database.RoomTypeDefault
}
var parentPortalID *networkid.PortalID
if ch.Type == discordgo.ChannelTypeGuildCategory || (ch.ParentID == "" && ch.GuildID != "") {
// Categories and uncategorized guild channels always have the guild as their parent.
parentPortalID = ptr.Ptr(discordid.MakeGuildPortalIDWithID(ch.GuildID))
} else if ch.ParentID != "" {
// Categorized guild channels.
parentPortalID = ptr.Ptr(discordid.MakeChannelPortalIDWithID(ch.ParentID))
}
var memberList bridgev2.ChatMemberList
if channelIsPrivate(ch) {
memberList = d.getPrivateChannelMemberList(ch)
} else {
// TODO we're _always_ sending partial member lists for guilds; we can probably
// do better than that
selfEventSender := d.selfEventSender()
memberList = bridgev2.ChatMemberList{
IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfEventSender.Sender: {EventSender: selfEventSender},
},
}
}
return &bridgev2.ChatInfo{
Name: &ch.Name,
Topic: &ch.Topic,
Avatar: d.makeAvatarForChannel(ctx, ch),
Members: &memberList,
Type: &roomType,
ParentID: parentPortalID,
CanBackfill: true,
ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
meta := portal.Metadata.(*discordid.PortalMetadata)
if meta.GuildID != ch.GuildID {
meta.GuildID = ch.GuildID
changed = true
}
return
},
}, nil
}
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
guildID := discordid.ParseGuildPortalID(portal.ID)
if guildID != "" {
// Portal is a space representing a Discord guild.
guild, err := d.Session.State.Guild(guildID)
if err != nil {
return nil, fmt.Errorf("couldn't get guild: %w", err)
}
return d.getGuildSpaceInfo(ctx, guild)
} else {
// Portal is to a channel of some kind (private or guild).
channelID := discordid.ParseChannelPortalID(portal.ID)
ch, err := d.Session.State.Channel(channelID)
if err != nil {
return nil, fmt.Errorf("couldn't get channel: %w", err)
}
return d.getChannelChatInfo(ctx, ch)
}
//TODO implement me
panic("implement me")
}

View File

@@ -21,7 +21,6 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"slices"
"sync"
@@ -30,31 +29,36 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/util/ptr"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordClient struct {
connector *DiscordConnector
UserLogin *bridgev2.UserLogin
Session *discordgo.Session
client *http.Client
connector *DiscordConnector
usersFromReady map[string]*discordgo.User
UserLogin *bridgev2.UserLogin
Session *discordgo.Session
hasBegunSyncing bool
markedOpened map[string]time.Time
markedOpenedLock sync.Mutex
userCache *UserCache
bridgeStateLock sync.Mutex
disconnectTimer *time.Timer
invalidAuthDetected bool
}
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
meta := login.Metadata.(*discordid.UserLoginMetadata)
session, err := NewDiscordSession(ctx, meta.Token)
login.Save(ctx)
if err != nil {
return err
@@ -64,9 +68,8 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
connector: d,
UserLogin: login,
Session: session,
client: d.Bridge.GetHTTPClientSettings().Compile(),
userCache: NewUserCache(session),
}
cl.SetUp(ctx, meta)
login.Client = &cl
@@ -75,32 +78,47 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
// SetUp performs basic bookkeeping and initialization that should be done
// immediately after a DiscordClient has been created.
//
// nil may be passed for meta, especially during provisioning where we need to
// connect to the Discord gateway, but don't have a UserLogin yet.
func (d *DiscordClient) SetUp(ctx context.Context, meta *discordid.UserLoginMetadata) {
// TODO: Turn this into a factory function like `NewDiscordClient`.
log := zerolog.Ctx(ctx)
// We'll have UserLogin metadata if this UserLogin is being loaded from the
// database, i.e. it hasn't just been provisioned.
if meta != nil {
if meta.HeartbeatSession.IsExpired() {
log.Info().Msg("Heartbeat session expired, creating a new one")
meta.HeartbeatSession = discordgo.NewHeartbeatSession()
}
meta.HeartbeatSession.BumpLastUsed()
d.Session.HeartbeatSession = meta.HeartbeatSession
}
d.markedOpened = make(map[string]time.Time)
d.resetBridgeStateTracking()
}
func (d *DiscordClient) Connect(ctx context.Context) {
log := zerolog.Ctx(ctx)
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
if meta.HeartbeatSession.IsExpired() {
log.Info().Msg("Heartbeat session expired, creating a new one")
meta.HeartbeatSession = discordgo.NewHeartbeatSession()
if d.Session == nil {
log.Error().Msg("No session present")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: DiscordNotLoggedIn,
UserAction: status.UserActionRelogin,
})
return
}
meta.HeartbeatSession.BumpLastUsed()
d.Session.HeartbeatSession = meta.HeartbeatSession
d.markedOpened = make(map[string]time.Time)
log.Debug().Msg("Connecting to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnecting,
})
if err := d.connect(ctx); err != nil {
log.Err(err).Msg("Couldn't connect to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: "discord-connect-error",
Message: err.Error(),
})
}
d.connectWithRetry(ctx, 0)
}
func (cl *DiscordClient) handleDiscordEventSync(event any) {
@@ -126,20 +144,50 @@ func (cl *DiscordClient) connect(ctx context.Context) error {
// Ensure that we actually have a user.
if !cl.IsLoggedIn() {
return fmt.Errorf("unknown identity even after connecting to Discord")
err := fmt.Errorf("unknown identity even after connecting to Discord")
log.Err(err).Msg("No Discord user available after connecting")
return err
}
user := cl.Session.State.User
log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
// Populate the user cache with the users from the READY payload.
log.Debug().Int("n_users", len(cl.Session.State.Ready.Users)).Msg("Inserting users from READY into cache")
cl.userCache.UpdateWithReady(&cl.Session.State.Ready)
// Stash all of the users we received in READY so we can perform quick lookups
// keyed by user ID.
cl.usersFromReady = make(map[string]*discordgo.User)
for _, user := range cl.Session.State.Ready.Users {
cl.usersFromReady[user.ID] = user
}
cl.BeginSyncing(ctx)
// NOTE: We won't have a UserLogin during provisioning, because the UserLogin
// can only be properly constructed once we know what the Discord user ID is
// (i.e. we have returned from this function). We'll rely on the login
// process calling this method manually instead.
cl.BeginSyncingIfUserLoginPresent(ctx)
return nil
}
func (d *DiscordClient) connectWithRetry(ctx context.Context, retryCount int) {
err := d.connect(ctx)
if err == nil || ctx.Err() != nil {
return
}
if retryCount < 6 {
d.sendConnectFailure(err, false)
retryInSeconds := 2 << retryCount
zerolog.Ctx(ctx).Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
select {
case <-time.After(time.Duration(retryInSeconds) * time.Second):
case <-ctx.Done():
zerolog.Ctx(ctx).Info().Msg("Context canceled, exiting connect retry loop")
return
}
d.connectWithRetry(ctx, retryCount+1)
} else {
d.sendConnectFailure(err, true)
}
}
func (d *DiscordClient) Disconnect() {
d.UserLogin.Log.Info().Msg("Disconnecting session")
d.Session.Close()
@@ -154,7 +202,11 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) {
d.Disconnect()
}
func (cl *DiscordClient) BeginSyncing(ctx context.Context) {
func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) {
if cl.UserLogin == nil {
cl.connector.Bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin")
return
}
if cl.hasBegunSyncing {
cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
return
@@ -253,7 +305,7 @@ func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Ch
func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey {
// TODO: Support configuring `split_portals`.
return networkid.PortalKey{
ID: discordid.MakeGuildPortalIDWithID(guildID),
ID: discordid.MakeGuildPortalID(guildID),
Receiver: d.UserLogin.ID,
}
}
@@ -263,44 +315,48 @@ func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Ava
ID: discordid.MakeAvatarID(guild.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
return d.simpleDownload(ctx, url, "guild icon")
return simpleDownload(ctx, url, "guild icon")
},
Remove: guild.Icon == "",
}
}
func (d *DiscordClient) syncGuildSpace(_ context.Context, guild *discordgo.Guild) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordGuildResync{
Client: d,
guild: guild,
portalKey: d.guildPortalKeyFromID(guild.ID),
})
}
func (d *DiscordClient) syncGuildSpace(ctx context.Context, guild *discordgo.Guild) error {
prt, err := d.connector.Bridge.GetPortalByKey(ctx, d.guildPortalKeyFromID(guild.ID))
if err != nil {
return fmt.Errorf("couldn't get/create portal corresponding to guild: %w", err)
}
// bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
// presence in the returned set does not imply anything about the corresponding
// portals and rooms.
func (d *DiscordClient) bridgedGuildIDs() map[string]struct{} {
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
bridgingGuildIDs := map[string]struct{}{}
selfEvtSender := d.selfEventSender()
info := &bridgev2.ChatInfo{
Name: &guild.Name,
Topic: nil,
Members: &bridgev2.ChatMemberList{
MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}},
// guilds that were bridged via the provisioning api
for guildID, bridged := range meta.BridgedGuildIDs {
if bridged {
bridgingGuildIDs[guildID] = struct{}{}
// As recommended by the spec, prohibit normal events by setting
// `events_default` to a suitably high number.
PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
},
Avatar: d.makeAvatarForGuild(guild),
Type: ptr.Ptr(database.RoomTypeSpace),
}
if prt.MXID == "" {
err := prt.CreateMatrixRoom(ctx, d.UserLogin, info)
if err != nil {
return fmt.Errorf("couldn't create room in order to materialize guild portal: %w", err)
}
} else {
prt.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{})
}
// guilds that were declared in the configuration file
for _, guildID := range d.connector.Config.Guilds.BridgingGuildIDs {
bridgingGuildIDs[guildID] = struct{}{}
}
return bridgingGuildIDs
return nil
}
func (d *DiscordClient) syncGuilds(ctx context.Context) {
guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
guildIDs := d.connector.Config.Guilds.BridgingGuildIDs
for _, guildID := range guildIDs {
log := zerolog.Ctx(ctx).With().
@@ -308,48 +364,31 @@ func (d *DiscordClient) syncGuilds(ctx context.Context) {
Str("action", "sync guild").
Logger()
err := d.syncGuild(log.WithContext(ctx), guildID)
err := d.bridgeGuild(log.WithContext(ctx), guildID)
if err != nil {
log.Err(err).Msg("Couldn't bridge guild during sync")
}
}
}
// deleteGuildPortalSpace queues a remote event that deletes a guild space
// (including children).
func (d *DiscordClient) deleteGuildPortalSpace(ctx context.Context, guildID string) {
func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
log := zerolog.Ctx(ctx)
log.Info().Msg("Unbridging guild by deleting the entire space")
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete,
PortalKey: d.guildPortalKeyFromID(guildID),
},
OnlyForMe: true,
Children: true,
})
}
func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error {
log := zerolog.Ctx(ctx).With().
Str("guild_id", guildID).
Str("action", "bridge guild").
Logger()
ctx = log.WithContext(ctx)
guild, err := d.Session.State.Guild(guildID)
if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil {
log.Err(err).Msg("Couldn't find guild, user isn't a member?")
// TODO likely left/kicked/banned from guild; nuke the portals
return errors.New("couldn't find guild in state")
}
d.syncGuildSpace(ctx, guild)
err = d.syncGuildSpace(ctx, guild)
if err != nil {
log.Err(err).Msg("Couldn't sync guild space portal")
return fmt.Errorf("couldn't sync guild space portal: %w", err)
}
for _, guildCh := range guild.Channels {
if guildCh.Type != discordgo.ChannelTypeGuildText && guildCh.Type != discordgo.ChannelTypeGuildCategory {
// TODO also bridge news channels
if guildCh.Type != discordgo.ChannelTypeGuildText {
// TODO implement categories (spaces) and news channels
log.Trace().
Str("channel_id", guildCh.ID).
Int("channel_type", int(guildCh.Type)).
@@ -369,33 +408,28 @@ func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error {
d.syncChannel(ctx, guildCh)
}
d.subscribeGuild(ctx, guildID)
return nil
}
func (d *DiscordClient) subscribeGuild(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Subscribing to guild")
err := d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guildID,
log.Debug().Msg("Subscribing to guild after bridging")
err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild, proceeding")
d.handlePossible40002(err)
log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding")
}
return nil
}
func (d *DiscordClient) simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to prepare request: %w", err)
}
resp, err := d.client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", thing, err)
}
@@ -421,10 +455,6 @@ func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
}
func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
if user == nil {
panic("DiscordClient makeEventSender was passed a nil user")
}
return d.makeEventSenderWithID(user.ID)
}
@@ -432,6 +462,6 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
Client: d,
channel: ch,
portalKey: discordid.MakeChannelPortalKey(ch, d.UserLogin.ID, true),
portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true),
})
}

View File

@@ -19,13 +19,8 @@ package connector
import (
"context"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/pkg/discordid"
"go.mau.fi/mautrix-discord/pkg/msgconv"
)
@@ -36,14 +31,14 @@ type DiscordConnector struct {
}
var (
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
_ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
)
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
d.Bridge = bridge
d.MsgConv = msgconv.NewMessageConverter(bridge)
d.setUpProvisioningAPIs()
}
func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
@@ -51,15 +46,6 @@ func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
}
func (d *DiscordConnector) Start(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Setting up provisioning API")
err := d.setUpProvisioningAPIs()
if err != nil {
log.Err(err).Msg("Failed to set up provisioning API, proceeding")
// Don't treat this error as fatal.
}
return nil
}
@@ -73,7 +59,3 @@ func (d *DiscordConnector) GetName() bridgev2.BridgeName {
DefaultPort: 29334,
}
}
func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(discordid.GenerateNonce())
}

179
pkg/connector/events.go Normal file
View File

@@ -0,0 +1,179 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// 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"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordChatResync struct {
Client *DiscordClient
channel *discordgo.Channel
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
)
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
return c
}
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordChatResync) avatar(ctx context.Context) *bridgev2.Avatar {
ch := d.channel
// TODO make this configurable (ala workspace_avatar_in_rooms)
if !d.isPrivate() {
guild, err := d.Client.Session.State.Guild(ch.GuildID)
if err != nil || guild == nil {
zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
return nil
}
return d.Client.makeAvatarForGuild(guild)
}
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(ch.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
return simpleDownload(ctx, url, "group dm icon")
},
Remove: ch.Icon == "",
}
}
func (d *DiscordChatResync) privateChannelMemberList() bridgev2.ChatMemberList {
ch := d.channel
var members bridgev2.ChatMemberList
members.IsFull = true
members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
if len(ch.Recipients) > 0 {
selfEventSender := d.Client.selfEventSender()
// Private channels' array of participants doesn't include ourselves,
// so inject ourselves as a member.
members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
for _, recipient := range ch.Recipients {
sender := d.Client.makeEventSender(recipient)
members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
}
members.TotalMemberCount = len(ch.Recipients)
}
return members
}
func (d *DiscordChatResync) memberList() bridgev2.ChatMemberList {
if d.isPrivate() {
return d.privateChannelMemberList()
}
// TODO we're _always_ sending partial member lists for guilds; we can probably
// do better
selfEventSender := d.Client.selfEventSender()
return bridgev2.ChatMemberList{
IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfEventSender.Sender: {EventSender: selfEventSender},
},
}
}
func (d *DiscordChatResync) isPrivate() bool {
ch := d.channel
return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
}
func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
ch := d.channel
var roomType database.RoomType
switch ch.Type {
case discordgo.ChannelTypeDM:
roomType = database.RoomTypeDM
case discordgo.ChannelTypeGroupDM:
roomType = database.RoomTypeGroupDM
}
info := &bridgev2.ChatInfo{
Name: &ch.Name,
Members: ptr.Ptr(d.memberList()),
Avatar: d.avatar(ctx),
Type: &roomType,
CanBackfill: true,
ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
meta := portal.Metadata.(*discordid.PortalMetadata)
if meta.GuildID != ch.GuildID {
meta.GuildID = ch.GuildID
changed = true
}
return
},
}
if !d.isPrivate() {
// Channel belongs to a guild; associate it with the respective space.
info.ParentID = ptr.Ptr(d.Client.guildPortalKeyFromID(ch.GuildID).ID)
}
return info, nil
}
func (d *DiscordChatResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
if latestBridged == nil {
zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
return false, nil
}
return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
}

View File

@@ -1,75 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// 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"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordChatResync struct {
Client *DiscordClient
channel *discordgo.Channel
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
)
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
return c
}
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}
func (d *DiscordChatResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
if latestBridged == nil {
zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
return false, nil
}
return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
}

View File

@@ -1,61 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// 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"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type DiscordGuildResync struct {
Client *DiscordClient
guild *discordgo.Guild
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordGuildResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordGuildResync)(nil)
)
func (d *DiscordGuildResync) AddLogContext(c zerolog.Context) zerolog.Context {
return c.Str("guild_id", d.guild.ID).Str("guild_name", d.guild.Name)
}
func (d *DiscordGuildResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordGuildResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordGuildResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordGuildResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}

View File

@@ -20,17 +20,11 @@ import (
"context"
"fmt"
"runtime/debug"
"slices"
"strconv"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
@@ -63,69 +57,12 @@ type DiscordMessage struct {
Client *DiscordClient
}
func (m *DiscordMessage) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
log := zerolog.Ctx(ctx).With().
Str("action", "convert discord edit").Logger()
ctx = log.WithContext(ctx)
// FIXME don't redundantly reupload attachments
convertedEdit := m.Client.connector.MsgConv.ToMatrix(
ctx,
portal,
intent,
m.Client.UserLogin,
m.Client.Session,
m.Data,
)
// TODO this is really gross and relies on how we assign incrementing numeric
// part ids. to return a semantically correct `ConvertedEdit` we should ditch
// this system
slices.SortStableFunc(existing, func(a *database.Message, b *database.Message) int {
ai, _ := strconv.Atoi(string(a.PartID))
bi, _ := strconv.Atoi(string(b.PartID))
return ai - bi
})
if len(convertedEdit.Parts) != len(existing) {
// FIXME support # of parts changing; triggerable by removing individual
// attachments, etc.
//
// at the very least we can make this better by handling attachments,
// which are always(?) at the end
log.Warn().Int("n_parts_existing", len(existing)).Int("n_parts_after_edit", len(convertedEdit.Parts)).
Msg("Ignoring message edit that changed number of parts")
return nil, bridgev2.ErrIgnoringRemoteEvent
}
parts := make([]*bridgev2.ConvertedEditPart, 0, len(existing))
for pi, part := range convertedEdit.Parts {
parts = append(parts, part.ToEditPart(existing[pi]))
}
return &bridgev2.ConvertedEdit{
ModifiedParts: parts,
}, nil
}
var (
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
_ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
// _ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
// _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
)
func (m *DiscordMessage) GetTargetMessage() networkid.MessageID {
return discordid.MakeMessageID(m.Data.ID)
}
func (m *DiscordMessage) GetTransactionID() networkid.TransactionID {
if m.Data.Nonce == "" {
return ""
}
return networkid.TransactionID(m.Data.Nonce)
}
func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil
}
@@ -135,24 +72,19 @@ func (m *DiscordMessage) GetID() networkid.MessageID {
}
func (m *DiscordMessage) GetSender() bridgev2.EventSender {
if m.Data.Author == nil {
// Message deletions don't have a sender associated with them.
return bridgev2.EventSender{}
}
return m.Client.makeEventSender(m.Data.Author)
}
func (d *DiscordClient) wrapDiscordMessage(msg *discordgo.Message, typ bridgev2.RemoteEventType) DiscordMessage {
func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) DiscordMessage {
return DiscordMessage{
DiscordEventMeta: &DiscordEventMeta{
Type: typ,
Type: bridgev2.RemoteEventMessage,
PortalKey: networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(msg.ChannelID),
ID: discordid.MakePortalID(evt.ChannelID),
Receiver: d.UserLogin.ID,
},
},
Data: msg,
Data: evt.Message,
Client: d,
}
}
@@ -221,7 +153,7 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
DiscordEventMeta: &DiscordEventMeta{
Type: evtType,
PortalKey: networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(reaction.ChannelID),
ID: discordid.MakePortalID(reaction.ChannelID),
Receiver: d.UserLogin.ID,
},
},
@@ -230,41 +162,24 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
}
}
func (d *DiscordClient) handleDiscordTyping(ctx context.Context, typing *discordgo.TypingStart) {
if typing.UserID == d.Session.State.User.ID {
return
}
log := zerolog.Ctx(ctx)
portalKey := networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(typing.ChannelID),
Receiver: d.UserLogin.ID,
}
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
log.Err(err).Msg("Failed to query for existing portal")
return
}
if portal == nil || portal.MXID == "" {
return
}
// Make sure we have this user's info in case we haven't seen them at all yet.
_ = d.userCache.Resolve(ctx, typing.UserID)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Typing{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventTyping,
PortalKey: portalKey,
Sender: d.makeEventSenderWithID(typing.UserID),
},
Timeout: 12 * time.Second,
Type: bridgev2.TypingTypeText,
})
}
func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
if d.UserLogin == nil {
// Our event handlers are able to assume that a UserLogin is available.
// We respond to special events like READY outside of this function,
// by virtue of methods like Session.Open only returning control flow
// after RESUME or READY.
log := zerolog.Ctx(context.TODO())
log.Trace().Msg("Dropping Discord event received before UserLogin creation")
return
}
if d.Session == nil || d.Session.State == nil || d.Session.State.User == nil {
// Our event handlers are able to assume that we've fully connected to the
// gateway.
d.UserLogin.Log.Debug().Msg("Dropping Discord event received before READY or RESUMED")
return
}
defer func() {
err := recover()
if err != nil {
@@ -278,26 +193,23 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
log := d.UserLogin.Log.With().Str("action", "handle discord event").
Type("event_type", rawEvt).
Logger()
ctx := log.WithContext(d.UserLogin.Bridge.BackgroundCtx)
// NOTE: discordgo seemingly dispatches both the proper unmarshalled type
// (e.g. `*discordgo.TypingStart`) _as well as_ a "raw" *discordgo.Event
// (e.g. `*discordgo.Event` with `Type` of `TYPING_START`) for every gateway
// event
switch evt := rawEvt.(type) {
case *discordgo.Connect:
log.Info().Msg("Discord gateway connected")
d.markConnected()
case *discordgo.Disconnect:
log.Info().Msg("Discord gateway disconnected")
d.scheduleTransientDisconnect("")
case *discordgo.InvalidAuth:
log.Warn().Msg("Discord gateway reported invalid auth")
d.markInvalidAuth("You have been logged out of Discord, please reconnect")
case *discordgo.Ready:
log.Info().Msg("Received READY dispatch from discordgo")
d.userCache.UpdateWithReady(evt)
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected,
})
case *discordgo.TypingStart:
d.handleDiscordTyping(ctx, evt)
d.markConnected()
case *discordgo.Resumed:
log.Info().Msg("Received RESUMED dispatch from discordgo")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected,
})
d.markConnected()
case *discordgo.MessageCreate:
if evt.Author == nil {
log.Trace().Int("message_type", int(evt.Message.Type)).
@@ -307,18 +219,8 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
Msg("Dropping message that lacks an author")
return
}
d.userCache.UpdateWithMessage(evt.Message)
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessage)
wrappedEvt := d.wrapDiscordMessage(evt)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.MessageUpdate:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventEdit)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.UserUpdate:
d.userCache.UpdateWithUserUpdate(evt)
case *discordgo.MessageDelete:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessageRemove)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
// TODO *discordgo.MessageDeleteBulk
case *discordgo.MessageReactionAdd:
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
@@ -329,12 +231,12 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
case *discordgo.PresenceUpdate:
return
case *discordgo.GuildDelete:
if evt.Unavailable {
log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
// For now, leave the portals alone if the guild only went away due to an outage.
case *discordgo.Event:
// For presently unknown reasons sometimes discordgo won't unmarshal
// events into their proper corresponding structs.
if evt.Type == "PRESENCE_UPDATE" || evt.Type == "PASSIVE_UPDATE_V2" || evt.Type == "CONVERSATION_SUMMARY_UPDATE" {
return
}
d.deleteGuildPortalSpace(ctx, evt.ID)
log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event")
}
}

View File

@@ -18,7 +18,6 @@ package connector
import (
"context"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
@@ -26,8 +25,6 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/util/variationselector"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
@@ -46,7 +43,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
portal := msg.Portal
guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(portal.ID)
channelID := discordid.ParsePortalID(portal.ID)
sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg)
if err != nil {
@@ -57,8 +54,9 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
// TODO: When supporting threads (and not a bot user), send a thread referer.
options = append(options, discordgo.WithChannelReferer(guildID, channelID))
sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParseChannelPortalID(msg.Portal.ID), sendReq, options...)
sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParsePortalID(msg.Portal.ID), sendReq, options...)
if err != nil {
d.handlePossible40002(err)
return nil, err
}
sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID)
@@ -73,32 +71,12 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
}
func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
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{},
)
_, err := d.Session.ChannelMessageEdit(
discordid.ParseChannelPortalID(msg.Portal.ID),
discordid.ParseMessageID(msg.EditTarget.ID),
content,
)
if err != nil {
return fmt.Errorf("failed to send message edit to discord: %w", err)
}
return nil
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
key := variationselector.FullyQualify(reaction.Content.RelatesTo.Key)
key := reaction.Content.RelatesTo.Key
// TODO: Handle custom emoji.
return bridgev2.MatrixReactionPreResponse{
@@ -108,32 +86,38 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b
}
func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
relatesToKey := reaction.Content.RelatesTo.Key
portal := reaction.Portal
meta := portal.Metadata.(*discordid.PortalMetadata)
err := d.Session.MessageReactionAddUser(
meta.GuildID,
discordid.ParseChannelPortalID(portal.ID),
discordid.ParseMessageID(reaction.TargetMessage.ID),
discordid.ParseEmojiID(reaction.PreHandleResp.EmojiID),
)
err := d.Session.MessageReactionAddUser(meta.GuildID, discordid.ParsePortalID(portal.ID), discordid.ParseMessageID(reaction.TargetMessage.ID), relatesToKey)
if err != nil {
d.handlePossible40002(err)
}
return nil, err
}
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
removing := removal.TargetReaction
emojiID := removing.EmojiID
channelID := discordid.ParseChannelPortalID(removing.Room.ID)
channelID := discordid.ParsePortalID(removing.Room.ID)
guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID))
if err != nil {
d.handlePossible40002(err)
}
return err
}
func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
channelID := discordid.ParseChannelPortalID(removal.Portal.ID)
channelID := discordid.ParsePortalID(removal.Portal.ID)
messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
return d.Session.ChannelMessageDelete(channelID, messageID)
err := d.Session.ChannelMessageDelete(channelID, messageID)
if err != nil {
d.handlePossible40002(err)
}
return err
}
func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
@@ -177,9 +161,10 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge
// TODO: Support threads.
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
channelID := discordid.ParsePortalID(msg.Portal.ID)
resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID))
if err != nil {
d.handlePossible40002(err)
log.Err(err).Msg("Failed to send read receipt to Discord")
return err
} else if resp.Token != nil {
@@ -202,7 +187,7 @@ func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Por
d.markedOpenedLock.Lock()
defer d.markedOpenedLock.Unlock()
channelID := discordid.ParseChannelPortalID(portal.ID)
channelID := discordid.ParsePortalID(portal.ID)
log := zerolog.Ctx(ctx).With().
Str("channel_id", channelID).Logger()
@@ -213,6 +198,7 @@ func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Por
err := d.Session.MarkViewing(channelID)
if err != nil {
d.handlePossible40002(err)
log.Error().Err(err).Msg("Failed to mark user as viewing channel")
return err
}
@@ -233,11 +219,12 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma
_ = d.viewingChannel(ctx, msg.Portal)
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
channelID := discordid.ParsePortalID(msg.Portal.ID)
// TODO: Support threads properly when sending the referer.
err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID))
if err != nil {
d.handlePossible40002(err)
log.Warn().Err(err).Msg("Failed to mark user as typing")
return err
}

View File

@@ -47,40 +47,60 @@ type DiscordGenericLogin struct {
}
func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
log := zerolog.Ctx(ctx).With().Str("action", "finalize login").Logger()
// TODO we don't need an entire discordgo session for this as we're just
// interested in /users/@me
log.Info().Msg("Creating initial session with provided token")
session, err := NewDiscordSession(ctx, token)
if err != nil {
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
dl.Session = session
log.Info().Msg("Requesting @me with provided token")
self, err := session.User("@me")
if err != nil {
return nil, fmt.Errorf("couldn't request self user (bad credentials?): %w", err)
client := DiscordClient{
connector: dl.connector,
Session: session,
}
dl.DiscordUser = self
client.SetUp(ctx, nil)
log.Info().Msg("Fetched @me")
err = client.connect(ctx)
if err != nil {
dl.Cancel()
return nil, err
}
// At this point we've opened a WebSocket connection to the gateway, received
// a READY packet, and know who we are.
user := session.State.User
dl.DiscordUser = user
dl.Session = session
ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
ID: discordid.MakeUserLoginID(self.ID),
ID: discordid.MakeUserLoginID(user.ID),
Metadata: &discordid.UserLoginMetadata{
Token: token,
HeartbeatSession: session.HeartbeatSession,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error {
login.Client = &client
client.UserLogin = login
// Only now that we have a UserLogin can we begin syncing.
client.BeginSyncingIfUserLoginPresent(ctx)
return nil
},
DeleteOnConflict: true,
DontReuseExisting: false,
})
if err != nil {
dl.Cancel()
return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
return nil, fmt.Errorf("couldn't create login: %w", err)
}
(ul.Client.(*DiscordClient)).Connect(ctx)
zerolog.Ctx(ctx).Info().
Str("user_id", user.ID).
Str("user_username", user.Username).
Msg("Logged in to Discord")
// We already opened the gateway session before creating the UserLogin,
// which means the initial READY/CONNECT event was dropped. Send Connected
// here so infra gets login status for new logins.
client.markConnected()
return ul, nil
}

View File

@@ -20,7 +20,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
@@ -82,17 +81,16 @@ func (d *DiscordConnector) setUpProvisioningAPIs() error {
type guildEntry struct {
ID string `json:"id"`
Name string `json:"name"`
// TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
AvatarURL string `json:"avatar_url"`
// new in v2:
Bridged bool `json:"bridged"`
Available bool `json:"available"`
// legacy fields from v1:
// v1-compatible fields:
MXID string `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
Available bool `json:"available"`
}
type respGuildsList struct {
Guilds []guildEntry `json:"guilds"`
@@ -119,78 +117,27 @@ func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Re
}
func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
ctx := r.Context()
p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
bridgedGuildIDs := client.bridgedGuildIDs()
var resp respGuildsList
resp.Guilds = []guildEntry{}
for _, guild := range client.Session.State.Guilds {
portalKey := client.guildPortalKeyFromID(guild.ID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
p.log.Err(err).
Str("guild_id", guild.ID).
Msg("Failed to get guild portal for provisioning list")
}
_, beingBridged := bridgedGuildIDs[guild.ID]
mxid := ""
if portal != nil && portal.MXID != "" {
mxid = portal.MXID.String()
} else if beingBridged {
// Beeper Desktop expects the space to exist by the time it receives
// our HTTP response. If it doesn't, then the space won't appear
// until the app is reloaded, and the toggle in the user interface
// won't respond to the user's click.
//
// Pre-bridgev2, we synchronously bridged guilds. However, this
// might take a while for guilds with many channels.
//
// To solve this, generate a deterministic room ID to use as the
// MXID so that it recognizes the guild as bridged, even if the
// portals haven't been created just yet. This lets us
// asynchronously bridge guilds while keeping the UI responsive.
mxid = p.connector.Bridge.Matrix.GenerateDeterministicRoomID(portalKey).String()
}
resp.Guilds = append(resp.Guilds, guildEntry{
// For now, have the ID exactly correspond to the portal ID. This
// practically means that the ID will begin with an asterisk (the
// guild portal ID sigil).
//
// Otherwise, Beeper Desktop will show a duplicate space for every
// guild, as it recognizes the guild returned from this HTTP
// endpoint and the actual space itself as separate "entities".
// (Despite this, they point to identical rooms.)
ID: string(discordid.MakeGuildPortalIDWithID(guild.ID)),
ID: guild.ID,
Name: guild.Name,
AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
Bridged: beingBridged,
Available: !guild.Unavailable,
// v1 (legacy) backwards compat:
MXID: mxid,
AutoBridge: beingBridged,
BridgingMode: "everything",
Available: !guild.Unavailable,
})
}
exhttp.WriteJSONResponse(w, 200, resp)
}
// normalizeGuildID removes the guild portal sigil from a guild ID if it's
// there.
//
// This helps facilitate code that would like to accept portal keys
// corresponding to guilds as well as plain Discord guild IDs.
func normalizeGuildID(guildID string) string {
return strings.TrimPrefix(guildID, discordid.GuildPortalKeySigil)
}
func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := normalizeGuildID(r.PathValue("guildID"))
guildID := r.PathValue("guildID")
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
@@ -201,31 +148,14 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo
Str("guild_id", guildID).
Msg("requested to bridge guild via provisioning api")
meta := login.Metadata.(*discordid.UserLoginMetadata)
// TODO detect guild already bridged
go client.bridgeGuild(context.TODO(), guildID)
if meta.BridgedGuildIDs == nil {
meta.BridgedGuildIDs = map[string]bool{}
}
_, alreadyBridged := meta.BridgedGuildIDs[guildID]
meta.BridgedGuildIDs[guildID] = true
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild bridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
go client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID)
responseStatus := 201
if alreadyBridged {
responseStatus = 200
}
exhttp.WriteJSONResponse(w, responseStatus, nil)
exhttp.WriteJSONResponse(w, 201, nil)
}
func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := normalizeGuildID(r.PathValue("guildID"))
guildID := r.PathValue("guildID")
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
@@ -236,22 +166,7 @@ func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request,
Str("guild_id", guildID).
Msg("requested to unbridge guild via provisioning api")
meta := login.Metadata.(*discordid.UserLoginMetadata)
if meta.BridgedGuildIDs != nil {
delete(meta.BridgedGuildIDs, guildID)
}
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild unbridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
ctx := login.Log.With().
Str("component", "provisioning").
Str("action", "unbridge guild").
Str("guild_id", guildID).
Logger().
WithContext(context.Background())
ctx := context.TODO()
portalKey := client.guildPortalKeyFromID(guildID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)

View File

@@ -33,11 +33,6 @@ func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, e
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
// Don't bother tracking things we don't care/support right now. Presences
// are especially expensive to track as they occur extremely frequently.
session.State.TrackPresences = false
session.State.TrackVoice = false
// Set up logging.
session.LogLevel = discordgo.LogInformational
session.Logger = func(msgL, caller int, format string, a ...any) {

View File

@@ -1,172 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// 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"
"errors"
"maps"
"net/http"
"slices"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// NOTE: Not simply using `exsync.Map` because we want the lock to be held
// during HTTP requests.
type UserCache struct {
session *discordgo.Session
cache map[string]*discordgo.User
lock sync.Mutex
}
func NewUserCache(session *discordgo.Session) *UserCache {
return &UserCache{
session: session,
cache: make(map[string]*discordgo.User),
}
}
func (uc *UserCache) UpdateWithReady(ready *discordgo.Ready) {
if ready == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range ready.Users {
uc.cache[user.ID] = user
}
}
// UpdateWithMessage updates the user cache with the users involved in a single
// message (author, mentioned, mentioned author, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessage(msg *discordgo.Message) []string {
if msg == nil {
return []string{}
}
// For now just forward to HandleMessages until a need for a specialized
// path makes itself known.
return uc.UpdateWithMessages([]*discordgo.Message{msg})
}
// UpdateWithMessages updates the user cache with the total set of users involved
// with multiple messages (authors, mentioned users, mentioned authors, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessages(msgs []*discordgo.Message) []string {
if len(msgs) == 0 {
return []string{}
}
collectedUsers := map[string]*discordgo.User{}
for _, msg := range msgs {
collectedUsers[msg.Author.ID] = msg.Author
referenced := msg.ReferencedMessage
if referenced != nil && referenced.Author != nil {
collectedUsers[referenced.Author.ID] = referenced.Author
}
for _, mentioned := range msg.Mentions {
collectedUsers[mentioned.ID] = mentioned
}
// Message snapshots lack `author` entirely and seemingly have an empty
// `mentions` array, even when the original message actually mentions
// someone.
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range collectedUsers {
uc.cache[user.ID] = user
}
return slices.Collect(maps.Keys(collectedUsers))
}
func (uc *UserCache) UpdateWithUserUpdate(update *discordgo.UserUpdate) {
if update == nil || update.User == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
uc.cache[update.ID] = update.User
}
// Resolve looks up a user in the cache, requesting the user from the Discord
// HTTP API if not present.
//
// If the user cannot be found, then its nonexistence is cached. This is to
// avoid excessive requests when e.g. backfilling messages from a user that has
// since been deleted since connecting. If some other error occurs, the cache
// isn't touched and nil is returned.
//
// Otherwise, the cache is updated as you'd expect.
func (uc *UserCache) Resolve(ctx context.Context, userID string) *discordgo.User {
if userID == discordid.DeletedGuildUserID {
return &discordid.DeletedGuildUser
}
// Hopefully this isn't too contentious?
uc.lock.Lock()
defer uc.lock.Unlock()
cachedUser, present := uc.cache[userID]
if cachedUser != nil {
return cachedUser
} else if present {
// If a `nil` is present in the map, then we already know that the user
// doesn't exist.
return nil
}
log := zerolog.Ctx(ctx).With().
Str("action", "resolve user").
Str("user_id", userID).Logger()
log.Trace().Msg("Fetching user")
user, err := uc.session.User(userID)
var restError *discordgo.RESTError
if errors.As(err, &restError) && restError.Response.StatusCode == http.StatusNotFound {
log.Info().Msg("Tried to resolve a user that doesn't exist, caching nonexistence")
uc.cache[userID] = nil
return nil
} else if err != nil {
log.Err(err).Msg("Failed to resolve user")
return nil
}
uc.cache[userID] = user
return user
}

View File

@@ -35,13 +35,13 @@ func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID)
return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID)
}
func (d *DiscordClient) makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
func makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
url := u.AvatarURL("256")
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(url),
Get: func(ctx context.Context) ([]byte, error) {
return d.simpleDownload(ctx, url, "user avatar")
return simpleDownload(ctx, url, "user avatar")
},
}
}
@@ -54,19 +54,18 @@ func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost)
return nil, nil
}
discordUserID := discordid.ParseUserID(ghost.ID)
discordUser := d.userCache.Resolve(ctx, discordUserID)
if discordUser == nil {
log.Error().Str("discord_user_id", discordUserID).
Msg("Failed to resolve user")
// FIXME(skip): This won't work for users in guilds.
user, ok := d.usersFromReady[discordid.ParseUserID(ghost.ID)]
if !ok {
log.Error().Str("ghost_id", discordid.ParseUserID(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost")
return nil, nil
}
return &bridgev2.UserInfo{
// FIXME clear this for webhooks (stash in ghost metadata)
Identifiers: []string{fmt.Sprintf("discord:%s", discordUser.String())},
Name: ptr.Ptr(discordUser.DisplayName()),
Avatar: d.makeUserAvatar(discordUser),
IsBot: &discordUser.Bot,
Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)},
Name: ptr.Ptr(user.DisplayName()),
Avatar: makeUserAvatar(user),
IsBot: &user.Bot,
}, nil
}

View File

@@ -16,10 +16,7 @@
package discordid
import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/database"
)
import "github.com/bwmarrin/discordgo"
type PortalMetadata struct {
// The ID of the Discord guild that the channel corresponding to this portal
@@ -33,21 +30,4 @@ type PortalMetadata struct {
type UserLoginMetadata struct {
Token string `json:"token"`
HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"`
BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"`
}
var _ database.MetaMerger = (*UserLoginMetadata)(nil)
func (ulm *UserLoginMetadata) CopyFrom(incoming any) {
incomingMeta, ok := incoming.(*UserLoginMetadata)
if !ok || incomingMeta == nil {
return
}
if incomingMeta.Token != "" {
ulm.Token = incomingMeta.Token
}
ulm.HeartbeatSession = discordgo.NewHeartbeatSession()
// Retain the BridgedGuildIDs from the existing login.
}

View File

@@ -17,39 +17,10 @@
package discordid
import (
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/networkid"
)
// DeletedGuildUserID is a magic user ID that is used in place of an actual user
// ID once they have deleted their account. This only applies in non-private
// (i.e. guild) contexts, such as guild channel message authors and mentions.
//
// Note that this user ID can also appear in message content as part of user
// mention markup ("<@456226577798135808>").
const DeletedGuildUserID = "456226577798135808"
// DeletedGuildUser is the user returned from the Discord API as a stand-in for
// users who have since deleted their account. As the name suggests, this only
// applies to fetched entities within guilds.
var DeletedGuildUser = discordgo.User{
ID: DeletedGuildUserID,
Username: "Deleted User",
Discriminator: "0000",
}
const DiscordEpochMillis = 1420070400000
// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
func GenerateNonce() string {
snowflake := (time.Now().UnixMilli() - DiscordEpochMillis) << 22
return strconv.FormatInt(snowflake, 10)
}
func MakeUserID(userID string) networkid.UserID {
return networkid.UserID(userID)
}
@@ -72,24 +43,11 @@ func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
return networkid.UserID(id)
}
func MakeChannelPortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(ch.ID)
if wantReceiver {
key.Receiver = userLoginID
}
return
}
func MakeChannelPortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(channelID)
return
}
func MakeChannelPortalIDWithID(channelID string) networkid.PortalID {
func MakePortalID(channelID string) networkid.PortalID {
return networkid.PortalID(channelID)
}
func ParseChannelPortalID(portalID networkid.PortalID) string {
func ParsePortalID(portalID networkid.PortalID) string {
return string(portalID)
}
@@ -127,8 +85,8 @@ func MakeAvatarID(avatar string) networkid.AvatarID {
//
// To accommodate Discord guilds created before this API change that have also
// never deleted the default channel, we need a way to distinguish between the
// guild and the default channel. Otherwise, we wouldn't be able to bridge both
// the channel portal as well as the guild space; their keys would conflict.
// guild and the default channel, as we wouldn't be able to bridge the guild
// as a space otherwise.
//
// "*" was chosen as the asterisk character is used to filter by guilds in
// the quick switcher (in Discord's first-party clients).
@@ -136,20 +94,19 @@ func MakeAvatarID(avatar string) networkid.AvatarID {
// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
const GuildPortalKeySigil = "*"
func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
func MakeGuildPortalID(guildID string) networkid.PortalID {
return networkid.PortalID(GuildPortalKeySigil + guildID)
}
// ParseGuildPortalID converts a [network.PortalID] pointing to a guild space
// back into the guild's ID on Discord.
//
// If the portal ID does not point to a guild, then an empty string is returned.
func ParseGuildPortalID(portalID networkid.PortalID) string {
opaque := string(portalID)
if strings.HasPrefix(opaque, GuildPortalKeySigil) {
guildID := opaque[1:]
return guildID
func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
key.ID = MakePortalID(ch.ID)
if wantReceiver {
key.Receiver = userLoginID
}
return ""
return
}
func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = MakePortalID(channelID)
return
}

View File

@@ -285,7 +285,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return
// }
case *astDiscordChannelMention:
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(
strconv.FormatInt(node.id, 10),
)); portal != nil {
if portal.MXID != "" {

View File

@@ -44,9 +44,6 @@ const (
contextKeyDiscordClient
)
// ToMatrix bridges a Discord message to Matrix.
//
// This method expects ghost information to be up-to-date.
func (mc *MessageConverter) ToMatrix(
ctx context.Context,
portal *bridgev2.Portal,
@@ -131,21 +128,9 @@ func (mc *MessageConverter) ToMatrix(
// puppet.addMemberMeta(part, msg)
// }
sender := discordid.MakeUserID(msg.Author.ID)
pmp, err := portal.PerMessageProfileForSender(ctx, sender)
if err != nil {
log.Err(err).Msg("Failed to make per-message profile")
}
// Assign incrementing part IDs.
for i, part := range parts {
part.ID = networkid.PartID(strconv.Itoa(i))
// Beeper clients support backfilling backwards (scrolling up to load
// more messages). Adding per-message profiles to every part helps them
// present the right message authorship information even when a
// membership event isn't present.
part.Content.BeeperPerMessageProfile = &pmp
}
converted := &bridgev2.ConvertedMessage{Parts: parts}
@@ -194,9 +179,9 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
// The portal containing the message that was replied to.
targetPortal := portal
if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) {
if ref.ChannelID != discordid.ParsePortalID(portal.ID) {
var err error
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(ref.ChannelID))
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID))
if err != nil {
log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
return
@@ -316,7 +301,7 @@ func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal
forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true)
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakeChannelPortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil {
// We've bridged the message that was forwarded, so we can link to it directly.
origLink = fmt.Sprintf(
@@ -419,71 +404,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *
}
func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart {
var mime string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
default:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
Str("sticker_id", sticker.ID).
Msg("Unknown sticker format")
}
// TODO(skip): Support direct media.
reupload, err := mc.ReuploadMedia(ctx, sticker.URL(), mime, "", -1, true)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy sticker to Matrix")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: mediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: sticker.Name, // TODO(skip): Find description from somewhere?
Info: &event.FileInfo{
MimeType: reupload.MimeType,
Size: reupload.Size,
},
}
content.URL, content.File = reupload.MXC, reupload.File
cleanupConvertedStickerInfo(content)
return &bridgev2.ConvertedMessagePart{
Type: event.EventSticker,
Content: content,
}
}
const DiscordStickerSize = 160
func cleanupConvertedStickerInfo(content *event.MessageEventContent) {
if content.Info == nil {
return
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
} else if content.Info.Width < content.Info.Height {
content.Info.Width /= content.Info.Height / DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
}
panic("unimplemented")
}
const (

View File

@@ -22,6 +22,8 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
@@ -33,6 +35,14 @@ import (
"go.mau.fi/mautrix-discord/pkg/discordid"
)
const discordEpochMillis = 1420070400000
func generateMessageNonce() string {
snowflake := (time.Now().UnixMilli() - discordEpochMillis) << 22
// Nonce snowflakes don't have internal IDs or increments
return strconv.FormatInt(snowflake, 10)
}
func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil {
return nil
@@ -92,27 +102,23 @@ func (mc *MessageConverter) ToDiscord(
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
var req discordgo.MessageSend
if msg.InputTransactionID != "" {
req.Nonce = string(msg.InputTransactionID)
} else {
req.Nonce = discordid.GenerateNonce()
}
req.Nonce = generateMessageNonce()
log := zerolog.Ctx(ctx)
if msg.ReplyTo != nil {
req.Reference = &discordgo.MessageReference{
ChannelID: discordid.ParseChannelPortalID(msg.ReplyTo.Room.ID),
ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID),
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
}
}
portal := msg.Portal
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(portal.ID)
channelID := discordid.ParsePortalID(portal.ID)
content := msg.Content
convertMatrix := func() {
req.Content, req.AllowedMentions = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
if content.MsgType == event.MsgEmote {
req.Content = fmt.Sprintf("_%s_", req.Content)
}
@@ -176,7 +182,7 @@ func (mc *MessageConverter) ToDiscord(
return &req, nil
}
func (mc *MessageConverter) ConvertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},