34 Commits

Author SHA1 Message Date
Skip R
dfa9c52974 discordid: make function names more explicit
These are fairly wordy but help maintain correctness. A channel portal
ID should not be confused with a guild portal ID.
2026-02-13 21:19:07 -08:00
Skip R
04c15d15a7 handlediscord: bridge categories with proper parenting 2026-02-13 21:05:35 -08:00
Skip R
66badc0709 handlediscord: bridge channel topics 2026-02-13 18:16:09 -08:00
Skip R
d36528400d handlediscord: don't log upon "unknown" events
Due to how discordgo dispatches events, this would be extraneously
logged for every event received.
2026-02-11 19:36:30 -08:00
Skip R
c80fba31d6 handlediscord: bridge typing 2026-02-11 19:36:22 -08:00
Skip R
aba6f5aafc dbmeta: merge incoming metadata
By not implementing MetaMerger, UserLogin metadata such as
BridgedGuildIDs would get clobbered upon reauthing because the metadata
was replaced entirely. Implement CopyFrom so we can gain deeper control
over what is preserved upon reauth. Notably, preserve BridgedGuildIDs so
we can't get into a weird state where a guild is bridged but
simultaneously absent from BridgedGuildIDs, which would cause us to not
subscribe (OP 14) to it properly.
2026-02-11 19:33:46 -08:00
Skip R
6407a3e3e0 connector: set up provisioning in Start instead of Init
Doing it in Init happens to work for local bridges, but not when
connecting to a remote Matrix homeserver.
2026-02-11 19:23:33 -08:00
Skip R
40ae884e7f connector/client: refactor guild subscription, push log onto context 2026-02-11 19:23:19 -08:00
Skip R
07ba87f9d6 handlediscord: bridge guild delete 2026-02-11 17:42:25 -08:00
Skip R
82aab381ab handlediscord: bridge message edits 2026-02-09 18:04:23 -08:00
Skip R
c8561de9c4 connector: panic with a more useful message when creating nil sender 2026-02-09 15:05:07 -08:00
Skip R
9013e01b49 handlediscord: bridge message deletes 2026-02-09 15:04:47 -08:00
Skip R
7a6f59ad73 handlematrix: bridge message edits 2026-02-09 15:04:33 -08:00
Skip R
2ddba507c2 connector/capabilities: clean up stale comments 2026-02-09 14:34:11 -08:00
Skip R
abcc0dca47 msgconv/from-discord: port sticker conversion 2026-02-09 14:13:54 -08:00
Skip R
2310d2c036 usercache: rename methods
"Update" better expresses what is being done to the cache.
2026-02-06 17:55:46 -08:00
Skip R
1fcc910184 msgconv/from-discord: add per-message profiles 2026-02-06 17:42:51 -08:00
Skip R
808993c174 backfill: update ghosts as we backfill 2026-02-06 17:42:09 -08:00
Skip R
a1d4c4cb28 usercache: return user ids that were updated 2026-02-06 17:41:54 -08:00
Skip R
ce6404ac78 backfill: attach sublogger to context 2026-02-06 17:36:26 -08:00
Skip R
7cfa17023b userinfo: use username as ghost identifier instead of user id
This is more correct.
2026-02-06 15:43:51 -08:00
Skip R
d8ca44ecd9 connector: implement user cache
* Fixes the totally broken UserInfo resolution in guilds.
* Adds support for USER_UPDATE from the gateway.

Design considerations behind the user cache:

* Explicitly handle deleted user IDs by short circuiting the lookup
  logic and returning a singleton.
* The cache map is protected during HTTP requests to the Discord API.
* The nonexistence of a user is cached. This is to prevent excessive
  requests (a user can't suddenly begin existing at a given ID).

The user cache is upserted on READY, incoming messages, backfill, etc.
2026-02-06 15:43:51 -08:00
Skip R
c611e8f116 connector: tell discordgo to not track presences/voice state 2026-02-06 13:37:45 -08:00
Skip R
a7ae544999 provisioning: improve compat with beeper desktop 2026-02-05 22:48:36 -08:00
Skip R
4f420c4662 provisioning: preserve logger context 2026-02-05 22:22:13 -08:00
Skip R
4bdb0de559 discordid,connector: remember which guilds were bridged 2026-02-05 22:21:16 -08:00
Skip R
869d8c5412 handlematrix: actually use the qualified emoji when reacting
By accessing reaction.Content.RelatesTo.Key we bypass the work done in
PreHandleMatrixReaction.
2026-02-04 12:44:33 -08:00
Skip R
094bc9bd77 connector: support transaction IDs 2026-02-03 22:03:29 -08:00
Skip R
36c23bef87 dependencies: update discordgo 2026-02-03 22:02:53 -08:00
Skip R
6adf319cfb connector: sync guild spaces via event instead of manually 2026-02-03 21:36:08 -08:00
Skip R
9dfc91ff14 handlematrix: fully qualify reaction emojis 2026-02-03 21:07:51 -08:00
Skip R
47095f1993 connector: instantiate http.Client from bridge settings 2026-02-03 21:00:35 -08:00
Skip R
1900993acd connector/login: remove custom LoadUserLogin
Consolidate how we construct `DiscordClient` by always going through the
connector's `LoadUserLogin` method.
2026-02-03 20:44:38 -08:00
Skip R
2682175508 connector: fetch @me to create login before creating client
Creating the client before the actual UserLogin is bad form.
2026-02-02 22:52:14 -08:00
22 changed files with 1104 additions and 432 deletions

2
go.mod
View File

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

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= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0 h1:cKnAGjgYtCO4DLQePwsx1bBbX2imPSggm8da4t2AzBQ=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 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= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=

View File

@@ -37,11 +37,13 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
return nil, bridgev2.ErrNotLoggedIn return nil, bridgev2.ErrNotLoggedIn
} }
channelID := discordid.ParsePortalID(fetchParams.Portal.ID) channelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
log := zerolog.Ctx(ctx).With(). log := zerolog.Ctx(ctx).With().
Str("action", "fetch messages").
Str("channel_id", channelID). Str("channel_id", channelID).
Int("desired_count", fetchParams.Count). Int("desired_count", fetchParams.Count).
Bool("forward", fetchParams.Forward).Logger() Bool("forward", fetchParams.Forward).Logger()
ctx = log.WithContext(ctx)
var beforeID string var beforeID string
var afterID string var afterID string
@@ -64,6 +66,32 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
return nil, 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)) converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
for _, msg := range msgs { for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)

View File

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

View File

@@ -18,11 +18,171 @@ package connector
import ( import (
"context" "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"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
) )
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { // getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space.
//TODO implement me func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) {
panic("implement me") 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)
}
} }

View File

@@ -21,6 +21,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"slices" "slices"
"sync" "sync"
@@ -29,32 +30,31 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/util/ptr"
"go.mau.fi/mautrix-discord/pkg/discordid" "go.mau.fi/mautrix-discord/pkg/discordid"
) )
type DiscordClient struct { type DiscordClient struct {
connector *DiscordConnector connector *DiscordConnector
usersFromReady map[string]*discordgo.User UserLogin *bridgev2.UserLogin
UserLogin *bridgev2.UserLogin Session *discordgo.Session
Session *discordgo.Session client *http.Client
hasBegunSyncing bool hasBegunSyncing bool
markedOpened map[string]time.Time markedOpened map[string]time.Time
markedOpenedLock sync.Mutex markedOpenedLock sync.Mutex
userCache *UserCache
} }
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
meta := login.Metadata.(*discordid.UserLoginMetadata) meta := login.Metadata.(*discordid.UserLoginMetadata)
session, err := NewDiscordSession(ctx, meta.Token) session, err := NewDiscordSession(ctx, meta.Token)
login.Save(ctx)
if err != nil { if err != nil {
return err return err
@@ -64,8 +64,9 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
connector: d, connector: d,
UserLogin: login, UserLogin: login,
Session: session, Session: session,
client: d.Bridge.GetHTTPClientSettings().Compile(),
userCache: NewUserCache(session),
} }
cl.SetUp(ctx, meta)
login.Client = &cl login.Client = &cl
@@ -74,46 +75,31 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) 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)
}
func (d *DiscordClient) Connect(ctx context.Context) { func (d *DiscordClient) Connect(ctx context.Context) {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
if d.Session == nil { meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
log.Error().Msg("No session present")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: "discord-not-logged-in",
})
return
}
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)
log.Debug().Msg("Connecting to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{ d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnecting, StateEvent: status.StateConnecting,
}) })
if err := d.connect(ctx); err != nil { if err := d.connect(ctx); err != nil {
log.Err(err).Msg("Couldn't connect to Discord") 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(),
})
} }
} }
@@ -145,18 +131,11 @@ func (cl *DiscordClient) connect(ctx context.Context) error {
user := cl.Session.State.User user := cl.Session.State.User
log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord") log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
// Stash all of the users we received in READY so we can perform quick lookups // Populate the user cache with the users from the READY payload.
// keyed by user ID. log.Debug().Int("n_users", len(cl.Session.State.Ready.Users)).Msg("Inserting users from READY into cache")
cl.usersFromReady = make(map[string]*discordgo.User) cl.userCache.UpdateWithReady(&cl.Session.State.Ready)
for _, user := range cl.Session.State.Ready.Users {
cl.usersFromReady[user.ID] = user
}
// NOTE: We won't have a UserLogin during provisioning, because the UserLogin cl.BeginSyncing(ctx)
// 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 return nil
} }
@@ -175,11 +154,7 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) {
d.Disconnect() d.Disconnect()
} }
func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { func (cl *DiscordClient) BeginSyncing(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 { if cl.hasBegunSyncing {
cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once") cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
return return
@@ -278,7 +253,7 @@ func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Ch
func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey { func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey {
// TODO: Support configuring `split_portals`. // TODO: Support configuring `split_portals`.
return networkid.PortalKey{ return networkid.PortalKey{
ID: discordid.MakeGuildPortalID(guildID), ID: discordid.MakeGuildPortalIDWithID(guildID),
Receiver: d.UserLogin.ID, Receiver: d.UserLogin.ID,
} }
} }
@@ -288,48 +263,44 @@ func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Ava
ID: discordid.MakeAvatarID(guild.Icon), ID: discordid.MakeAvatarID(guild.Icon),
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
return simpleDownload(ctx, url, "guild icon") return d.simpleDownload(ctx, url, "guild icon")
}, },
Remove: guild.Icon == "", Remove: guild.Icon == "",
} }
} }
func (d *DiscordClient) syncGuildSpace(ctx context.Context, guild *discordgo.Guild) error { func (d *DiscordClient) syncGuildSpace(_ context.Context, guild *discordgo.Guild) {
prt, err := d.connector.Bridge.GetPortalByKey(ctx, d.guildPortalKeyFromID(guild.ID)) d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordGuildResync{
if err != nil { Client: d,
return fmt.Errorf("couldn't get/create portal corresponding to guild: %w", err) guild: guild,
} portalKey: d.guildPortalKeyFromID(guild.ID),
})
}
selfEvtSender := d.selfEventSender() // bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
info := &bridgev2.ChatInfo{ // presence in the returned set does not imply anything about the corresponding
Name: &guild.Name, // portals and rooms.
Topic: nil, func (d *DiscordClient) bridgedGuildIDs() map[string]struct{} {
Members: &bridgev2.ChatMemberList{ meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}}, bridgingGuildIDs := map[string]struct{}{}
// As recommended by the spec, prohibit normal events by setting // guilds that were bridged via the provisioning api
// `events_default` to a suitably high number. for guildID, bridged := range meta.BridgedGuildIDs {
PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)}, if bridged {
}, bridgingGuildIDs[guildID] = struct{}{}
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{})
} }
return nil // guilds that were declared in the configuration file
for _, guildID := range d.connector.Config.Guilds.BridgingGuildIDs {
bridgingGuildIDs[guildID] = struct{}{}
}
return bridgingGuildIDs
} }
func (d *DiscordClient) syncGuilds(ctx context.Context) { func (d *DiscordClient) syncGuilds(ctx context.Context) {
guildIDs := d.connector.Config.Guilds.BridgingGuildIDs guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
for _, guildID := range guildIDs { for _, guildID := range guildIDs {
log := zerolog.Ctx(ctx).With(). log := zerolog.Ctx(ctx).With().
@@ -337,31 +308,48 @@ func (d *DiscordClient) syncGuilds(ctx context.Context) {
Str("action", "sync guild"). Str("action", "sync guild").
Logger() Logger()
err := d.bridgeGuild(log.WithContext(ctx), guildID) err := d.syncGuild(log.WithContext(ctx), guildID)
if err != nil { if err != nil {
log.Err(err).Msg("Couldn't bridge guild during sync") log.Err(err).Msg("Couldn't bridge guild during sync")
} }
} }
} }
func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error { // deleteGuildPortalSpace queues a remote event that deletes a guild space
// (including children).
func (d *DiscordClient) deleteGuildPortalSpace(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx) 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) guild, err := d.Session.State.Guild(guildID)
if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil { if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil {
log.Err(err).Msg("Couldn't find guild, user isn't a member?") 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") return errors.New("couldn't find guild in state")
} }
err = d.syncGuildSpace(ctx, guild) 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 { for _, guildCh := range guild.Channels {
if guildCh.Type != discordgo.ChannelTypeGuildText { if guildCh.Type != discordgo.ChannelTypeGuildText && guildCh.Type != discordgo.ChannelTypeGuildCategory {
// TODO implement categories (spaces) and news channels // TODO also bridge news channels
log.Trace(). log.Trace().
Str("channel_id", guildCh.ID). Str("channel_id", guildCh.ID).
Int("channel_type", int(guildCh.Type)). Int("channel_type", int(guildCh.Type)).
@@ -381,27 +369,33 @@ func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
d.syncChannel(ctx, guildCh) d.syncChannel(ctx, guildCh)
} }
log.Debug().Msg("Subscribing to guild after bridging") d.subscribeGuild(ctx, guildID)
err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID, 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,
Typing: true, Typing: true,
Activities: true, Activities: true,
Threads: true, Threads: true,
}) })
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding") log.Warn().Err(err).Msg("Failed to subscribe to guild, proceeding")
} }
return nil
} }
func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { func (d *DiscordClient) simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to prepare request: %w", err) return nil, fmt.Errorf("failed to prepare request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) resp, err := d.client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", thing, err) return nil, fmt.Errorf("failed to download %s: %w", thing, err)
} }
@@ -427,6 +421,10 @@ func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
} }
func (d *DiscordClient) makeEventSender(user *discordgo.User) 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) return d.makeEventSenderWithID(user.ID)
} }
@@ -434,6 +432,6 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
Client: d, Client: d,
channel: ch, channel: ch,
portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true), portalKey: discordid.MakeChannelPortalKey(ch, d.UserLogin.ID, true),
}) })
} }

View File

@@ -19,8 +19,13 @@ package connector
import ( import (
"context" "context"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2" "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" "go.mau.fi/mautrix-discord/pkg/msgconv"
) )
@@ -31,14 +36,14 @@ type DiscordConnector struct {
} }
var ( var (
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil) _ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil) _ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
_ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
) )
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
d.Bridge = bridge d.Bridge = bridge
d.MsgConv = msgconv.NewMessageConverter(bridge) d.MsgConv = msgconv.NewMessageConverter(bridge)
d.setUpProvisioningAPIs()
} }
func (d *DiscordConnector) SetMaxFileSize(maxSize int64) { func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
@@ -46,6 +51,15 @@ func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
} }
func (d *DiscordConnector) Start(ctx context.Context) error { 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 return nil
} }
@@ -59,3 +73,7 @@ func (d *DiscordConnector) GetName() bridgev2.BridgeName {
DefaultPort: 29334, DefaultPort: 29334,
} }
} }
func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(discordid.GenerateNonce())
}

View File

@@ -1,179 +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"
"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

@@ -0,0 +1,75 @@
// 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

@@ -0,0 +1,61 @@
// 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,11 +20,16 @@ import (
"context" "context"
"fmt" "fmt"
"runtime/debug" "runtime/debug"
"slices"
"strconv"
"time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-discord/pkg/discordid" "go.mau.fi/mautrix-discord/pkg/discordid"
@@ -58,12 +63,69 @@ type DiscordMessage struct {
Client *DiscordClient 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 ( var (
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil) _ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
// _ bridgev2.RemoteEdit = (*DiscordMessage)(nil) _ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
// _ bridgev2.RemoteMessageRemove = (*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) { 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 return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil
} }
@@ -73,19 +135,24 @@ func (m *DiscordMessage) GetID() networkid.MessageID {
} }
func (m *DiscordMessage) GetSender() bridgev2.EventSender { 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) return m.Client.makeEventSender(m.Data.Author)
} }
func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) DiscordMessage { func (d *DiscordClient) wrapDiscordMessage(msg *discordgo.Message, typ bridgev2.RemoteEventType) DiscordMessage {
return DiscordMessage{ return DiscordMessage{
DiscordEventMeta: &DiscordEventMeta{ DiscordEventMeta: &DiscordEventMeta{
Type: bridgev2.RemoteEventMessage, Type: typ,
PortalKey: networkid.PortalKey{ PortalKey: networkid.PortalKey{
ID: discordid.MakePortalID(evt.ChannelID), ID: discordid.MakeChannelPortalIDWithID(msg.ChannelID),
Receiver: d.UserLogin.ID, Receiver: d.UserLogin.ID,
}, },
}, },
Data: evt.Message, Data: msg,
Client: d, Client: d,
} }
} }
@@ -154,7 +221,7 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
DiscordEventMeta: &DiscordEventMeta{ DiscordEventMeta: &DiscordEventMeta{
Type: evtType, Type: evtType,
PortalKey: networkid.PortalKey{ PortalKey: networkid.PortalKey{
ID: discordid.MakePortalID(reaction.ChannelID), ID: discordid.MakeChannelPortalIDWithID(reaction.ChannelID),
Receiver: d.UserLogin.ID, Receiver: d.UserLogin.ID,
}, },
}, },
@@ -163,24 +230,41 @@ 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) { 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() { defer func() {
err := recover() err := recover()
if err != nil { if err != nil {
@@ -194,13 +278,21 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
log := d.UserLogin.Log.With().Str("action", "handle discord event"). log := d.UserLogin.Log.With().Str("action", "handle discord event").
Type("event_type", rawEvt). Type("event_type", rawEvt).
Logger() 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) { switch evt := rawEvt.(type) {
case *discordgo.Ready: case *discordgo.Ready:
log.Info().Msg("Received READY dispatch from discordgo") log.Info().Msg("Received READY dispatch from discordgo")
d.userCache.UpdateWithReady(evt)
d.UserLogin.BridgeState.Send(status.BridgeState{ d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected, StateEvent: status.StateConnected,
}) })
case *discordgo.TypingStart:
d.handleDiscordTyping(ctx, evt)
case *discordgo.Resumed: case *discordgo.Resumed:
log.Info().Msg("Received RESUMED dispatch from discordgo") log.Info().Msg("Received RESUMED dispatch from discordgo")
d.UserLogin.BridgeState.Send(status.BridgeState{ d.UserLogin.BridgeState.Send(status.BridgeState{
@@ -215,8 +307,18 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
Msg("Dropping message that lacks an author") Msg("Dropping message that lacks an author")
return return
} }
wrappedEvt := d.wrapDiscordMessage(evt) d.userCache.UpdateWithMessage(evt.Message)
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessage)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) 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: case *discordgo.MessageReactionAdd:
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true) wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
@@ -227,12 +329,12 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo) // TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
case *discordgo.PresenceUpdate: case *discordgo.PresenceUpdate:
return return
case *discordgo.Event: case *discordgo.GuildDelete:
// For presently unknown reasons sometimes discordgo won't unmarshal if evt.Unavailable {
// events into their proper corresponding structs. log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
if evt.Type == "PRESENCE_UPDATE" || evt.Type == "PASSIVE_UPDATE_V2" || evt.Type == "CONVERSATION_SUMMARY_UPDATE" { // For now, leave the portals alone if the guild only went away due to an outage.
return return
} }
log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event") d.deleteGuildPortalSpace(ctx, evt.ID)
} }
} }

View File

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

View File

@@ -47,55 +47,40 @@ type DiscordGenericLogin struct {
} }
func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) { 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) session, err := NewDiscordSession(ctx, token)
if err != nil { if err != nil {
return nil, fmt.Errorf("couldn't create discord session: %w", err) return nil, fmt.Errorf("couldn't create discord session: %w", err)
} }
client := DiscordClient{
connector: dl.connector,
Session: session,
}
client.SetUp(ctx, nil)
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 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)
}
dl.DiscordUser = self
log.Info().Msg("Fetched @me")
ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
ID: discordid.MakeUserLoginID(user.ID), ID: discordid.MakeUserLoginID(self.ID),
Metadata: &discordid.UserLoginMetadata{ Metadata: &discordid.UserLoginMetadata{
Token: token, Token: token,
HeartbeatSession: session.HeartbeatSession, HeartbeatSession: session.HeartbeatSession,
}, },
}, &bridgev2.NewLoginParams{ }, &bridgev2.NewLoginParams{
LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { DeleteOnConflict: true,
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 { if err != nil {
dl.Cancel() dl.Cancel()
return nil, fmt.Errorf("couldn't create login: %w", err) return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
} }
zerolog.Ctx(ctx).Info(). (ul.Client.(*DiscordClient)).Connect(ctx)
Str("user_id", user.ID).
Str("user_username", user.Username).
Msg("Logged in to Discord")
return ul, nil return ul, nil
} }

View File

@@ -20,6 +20,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strings"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -81,16 +82,17 @@ func (d *DiscordConnector) setUpProvisioningAPIs() error {
type guildEntry struct { type guildEntry struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
// TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here // TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
// v1-compatible fields: // new in v2:
Bridged bool `json:"bridged"`
Available bool `json:"available"`
// legacy fields from v1:
MXID string `json:"mxid"` MXID string `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"` AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"` BridgingMode string `json:"bridging_mode"`
Available bool `json:"available"`
} }
type respGuildsList struct { type respGuildsList struct {
Guilds []guildEntry `json:"guilds"` Guilds []guildEntry `json:"guilds"`
@@ -117,27 +119,78 @@ 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) { 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") p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
bridgedGuildIDs := client.bridgedGuildIDs()
var resp respGuildsList var resp respGuildsList
resp.Guilds = []guildEntry{} resp.Guilds = []guildEntry{}
for _, guild := range client.Session.State.Guilds { 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{ resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID, // 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)),
Name: guild.Name, Name: guild.Name,
AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon), AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
Bridged: beingBridged,
BridgingMode: "everything",
Available: !guild.Unavailable, Available: !guild.Unavailable,
// v1 (legacy) backwards compat:
MXID: mxid,
AutoBridge: beingBridged,
BridgingMode: "everything",
}) })
} }
exhttp.WriteJSONResponse(w, 200, resp) 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) { func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := r.PathValue("guildID") guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" { if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w) mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return return
@@ -148,14 +201,31 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo
Str("guild_id", guildID). Str("guild_id", guildID).
Msg("requested to bridge guild via provisioning api") Msg("requested to bridge guild via provisioning api")
// TODO detect guild already bridged meta := login.Metadata.(*discordid.UserLoginMetadata)
go client.bridgeGuild(context.TODO(), guildID)
exhttp.WriteJSONResponse(w, 201, nil) 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)
} }
func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := r.PathValue("guildID") guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" { if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w) mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return return
@@ -166,7 +236,22 @@ func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request,
Str("guild_id", guildID). Str("guild_id", guildID).
Msg("requested to unbridge guild via provisioning api") Msg("requested to unbridge guild via provisioning api")
ctx := context.TODO() 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())
portalKey := client.guildPortalKeyFromID(guildID) portalKey := client.guildPortalKeyFromID(guildID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey) portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)

View File

@@ -33,6 +33,11 @@ func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, e
return nil, fmt.Errorf("couldn't create discord session: %w", err) 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. // Set up logging.
session.LogLevel = discordgo.LogInformational session.LogLevel = discordgo.LogInformational
session.Logger = func(msgL, caller int, format string, a ...any) { session.Logger = func(msgL, caller int, format string, a ...any) {

172
pkg/connector/usercache.go Normal file
View File

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

View File

@@ -16,7 +16,10 @@
package discordid package discordid
import "github.com/bwmarrin/discordgo" import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/database"
)
type PortalMetadata struct { type PortalMetadata struct {
// The ID of the Discord guild that the channel corresponding to this portal // The ID of the Discord guild that the channel corresponding to this portal
@@ -30,4 +33,21 @@ type PortalMetadata struct {
type UserLoginMetadata struct { type UserLoginMetadata struct {
Token string `json:"token"` Token string `json:"token"`
HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` 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,10 +17,39 @@
package discordid package discordid
import ( import (
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/networkid" "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 { func MakeUserID(userID string) networkid.UserID {
return networkid.UserID(userID) return networkid.UserID(userID)
} }
@@ -43,11 +72,24 @@ func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
return networkid.UserID(id) return networkid.UserID(id)
} }
func MakePortalID(channelID string) networkid.PortalID { 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 {
return networkid.PortalID(channelID) return networkid.PortalID(channelID)
} }
func ParsePortalID(portalID networkid.PortalID) string { func ParseChannelPortalID(portalID networkid.PortalID) string {
return string(portalID) return string(portalID)
} }
@@ -85,8 +127,8 @@ func MakeAvatarID(avatar string) networkid.AvatarID {
// //
// To accommodate Discord guilds created before this API change that have also // 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 // never deleted the default channel, we need a way to distinguish between the
// guild and the default channel, as we wouldn't be able to bridge the guild // guild and the default channel. Otherwise, we wouldn't be able to bridge both
// as a space otherwise. // the channel portal as well as the guild space; their keys would conflict.
// //
// "*" was chosen as the asterisk character is used to filter by guilds in // "*" was chosen as the asterisk character is used to filter by guilds in
// the quick switcher (in Discord's first-party clients). // the quick switcher (in Discord's first-party clients).
@@ -94,19 +136,20 @@ 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. // For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
const GuildPortalKeySigil = "*" const GuildPortalKeySigil = "*"
func MakeGuildPortalID(guildID string) networkid.PortalID { func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
return networkid.PortalID(GuildPortalKeySigil + guildID) return networkid.PortalID(GuildPortalKeySigil + guildID)
} }
func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { // ParseGuildPortalID converts a [network.PortalID] pointing to a guild space
key.ID = MakePortalID(ch.ID) // back into the guild's ID on Discord.
if wantReceiver { //
key.Receiver = userLoginID // 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
} }
return
}
func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) { return ""
key.ID = MakePortalID(channelID)
return
} }

View File

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

View File

@@ -44,6 +44,9 @@ const (
contextKeyDiscordClient contextKeyDiscordClient
) )
// ToMatrix bridges a Discord message to Matrix.
//
// This method expects ghost information to be up-to-date.
func (mc *MessageConverter) ToMatrix( func (mc *MessageConverter) ToMatrix(
ctx context.Context, ctx context.Context,
portal *bridgev2.Portal, portal *bridgev2.Portal,
@@ -128,9 +131,21 @@ func (mc *MessageConverter) ToMatrix(
// puppet.addMemberMeta(part, msg) // 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. // Assign incrementing part IDs.
for i, part := range parts { for i, part := range parts {
part.ID = networkid.PartID(strconv.Itoa(i)) 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} converted := &bridgev2.ConvertedMessage{Parts: parts}
@@ -179,9 +194,9 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
// The portal containing the message that was replied to. // The portal containing the message that was replied to.
targetPortal := portal targetPortal := portal
if ref.ChannelID != discordid.ParsePortalID(portal.ID) { if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) {
var err error var err error
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID)) targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(ref.ChannelID))
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get cross-room reply portal; proceeding") log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
return return
@@ -301,7 +316,7 @@ func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal
forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true)
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
origLink := fmt.Sprintf("unknown channel • %s", msgTSText) origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakeChannelPortalKeyWithID(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 { 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. // We've bridged the message that was forwarded, so we can link to it directly.
origLink = fmt.Sprintf( origLink = fmt.Sprintf(
@@ -404,7 +419,71 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *
} }
func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart {
panic("unimplemented") 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
}
}
} }
const ( const (

View File

@@ -22,8 +22,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -35,14 +33,6 @@ import (
"go.mau.fi/mautrix-discord/pkg/discordid" "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 { func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil { if raw == nil {
return nil return nil
@@ -102,23 +92,27 @@ func (mc *MessageConverter) ToDiscord(
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal) ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session) ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
var req discordgo.MessageSend var req discordgo.MessageSend
req.Nonce = generateMessageNonce() if msg.InputTransactionID != "" {
req.Nonce = string(msg.InputTransactionID)
} else {
req.Nonce = discordid.GenerateNonce()
}
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
if msg.ReplyTo != nil { if msg.ReplyTo != nil {
req.Reference = &discordgo.MessageReference{ req.Reference = &discordgo.MessageReference{
ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID), ChannelID: discordid.ParseChannelPortalID(msg.ReplyTo.Room.ID),
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID), MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
} }
} }
portal := msg.Portal portal := msg.Portal
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(portal.ID) channelID := discordid.ParseChannelPortalID(portal.ID)
content := msg.Content content := msg.Content
convertMatrix := func() { 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 { if content.MsgType == event.MsgEmote {
req.Content = fmt.Sprintf("_%s_", req.Content) req.Content = fmt.Sprintf("_%s_", req.Content)
} }
@@ -182,7 +176,7 @@ func (mc *MessageConverter) ToDiscord(
return &req, nil 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{ allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{}, Parse: []discordgo.AllowedMentionType{},
Users: []string{}, Users: []string{},