Compare commits
34 Commits
discord-br
...
megabridge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa9c52974 | ||
|
|
04c15d15a7 | ||
|
|
66badc0709 | ||
|
|
d36528400d | ||
|
|
c80fba31d6 | ||
|
|
aba6f5aafc | ||
|
|
6407a3e3e0 | ||
|
|
40ae884e7f | ||
|
|
07ba87f9d6 | ||
|
|
82aab381ab | ||
|
|
c8561de9c4 | ||
|
|
9013e01b49 | ||
|
|
7a6f59ad73 | ||
|
|
2ddba507c2 | ||
|
|
abcc0dca47 | ||
|
|
2310d2c036 | ||
|
|
1fcc910184 | ||
|
|
808993c174 | ||
|
|
a1d4c4cb28 | ||
|
|
ce6404ac78 | ||
|
|
7cfa17023b | ||
|
|
d8ca44ecd9 | ||
|
|
c611e8f116 | ||
|
|
a7ae544999 | ||
|
|
4f420c4662 | ||
|
|
4bdb0de559 | ||
|
|
869d8c5412 | ||
|
|
094bc9bd77 | ||
|
|
36c23bef87 | ||
|
|
6adf319cfb | ||
|
|
9dfc91ff14 | ||
|
|
47095f1993 | ||
|
|
1900993acd | ||
|
|
2682175508 |
2
go.mod
2
go.mod
@@ -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-20251117165013-20c39e9899ec
|
||||
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -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-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I=
|
||||
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
|
||||
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/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=
|
||||
|
||||
@@ -37,11 +37,13 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
|
||||
return nil, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
channelID := discordid.ParsePortalID(fetchParams.Portal.ID)
|
||||
channelID := discordid.ParseChannelPortalID(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
|
||||
@@ -64,6 +66,32 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
|
||||
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)
|
||||
|
||||
@@ -65,6 +65,7 @@ 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,
|
||||
@@ -136,10 +137,7 @@ 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 {
|
||||
|
||||
@@ -18,11 +18,171 @@ 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"
|
||||
)
|
||||
|
||||
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -29,32 +30,31 @@ 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
|
||||
usersFromReady map[string]*discordgo.User
|
||||
UserLogin *bridgev2.UserLogin
|
||||
Session *discordgo.Session
|
||||
connector *DiscordConnector
|
||||
UserLogin *bridgev2.UserLogin
|
||||
Session *discordgo.Session
|
||||
client *http.Client
|
||||
|
||||
hasBegunSyncing bool
|
||||
|
||||
markedOpened map[string]time.Time
|
||||
markedOpenedLock sync.Mutex
|
||||
|
||||
userCache *UserCache
|
||||
}
|
||||
|
||||
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,8 +64,9 @@ 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
|
||||
|
||||
@@ -74,46 +75,31 @@ 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)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) Connect(ctx context.Context) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
if d.Session == nil {
|
||||
log.Error().Msg("No session present")
|
||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||
StateEvent: status.StateBadCredentials,
|
||||
Error: "discord-not-logged-in",
|
||||
})
|
||||
return
|
||||
}
|
||||
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
|
||||
|
||||
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{
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,18 +131,11 @@ func (cl *DiscordClient) connect(ctx context.Context) error {
|
||||
user := cl.Session.State.User
|
||||
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
|
||||
// keyed by user ID.
|
||||
cl.usersFromReady = make(map[string]*discordgo.User)
|
||||
for _, user := range cl.Session.State.Ready.Users {
|
||||
cl.usersFromReady[user.ID] = user
|
||||
}
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
cl.BeginSyncing(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -175,11 +154,7 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) {
|
||||
d.Disconnect()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
func (cl *DiscordClient) BeginSyncing(ctx context.Context) {
|
||||
if cl.hasBegunSyncing {
|
||||
cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
|
||||
return
|
||||
@@ -278,7 +253,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.MakeGuildPortalID(guildID),
|
||||
ID: discordid.MakeGuildPortalIDWithID(guildID),
|
||||
Receiver: d.UserLogin.ID,
|
||||
}
|
||||
}
|
||||
@@ -288,48 +263,44 @@ 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 simpleDownload(ctx, url, "guild icon")
|
||||
return d.simpleDownload(ctx, url, "guild icon")
|
||||
},
|
||||
Remove: guild.Icon == "",
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
selfEvtSender := d.selfEventSender()
|
||||
info := &bridgev2.ChatInfo{
|
||||
Name: &guild.Name,
|
||||
Topic: nil,
|
||||
Members: &bridgev2.ChatMemberList{
|
||||
MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}},
|
||||
// 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{}{}
|
||||
|
||||
// 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)
|
||||
// guilds that were bridged via the provisioning api
|
||||
for guildID, bridged := range meta.BridgedGuildIDs {
|
||||
if bridged {
|
||||
bridgingGuildIDs[guildID] = struct{}{}
|
||||
}
|
||||
} 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) {
|
||||
guildIDs := d.connector.Config.Guilds.BridgingGuildIDs
|
||||
guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
|
||||
|
||||
for _, guildID := range guildIDs {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
@@ -337,31 +308,48 @@ func (d *DiscordClient) syncGuilds(ctx context.Context) {
|
||||
Str("action", "sync guild").
|
||||
Logger()
|
||||
|
||||
err := d.bridgeGuild(log.WithContext(ctx), guildID)
|
||||
err := d.syncGuild(log.WithContext(ctx), guildID)
|
||||
if err != nil {
|
||||
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.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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
d.syncGuildSpace(ctx, guild)
|
||||
|
||||
for _, guildCh := range guild.Channels {
|
||||
if guildCh.Type != discordgo.ChannelTypeGuildText {
|
||||
// TODO implement categories (spaces) and news channels
|
||||
if guildCh.Type != discordgo.ChannelTypeGuildText && guildCh.Type != discordgo.ChannelTypeGuildCategory {
|
||||
// TODO also bridge news channels
|
||||
log.Trace().
|
||||
Str("channel_id", guildCh.ID).
|
||||
Int("channel_type", int(guildCh.Type)).
|
||||
@@ -381,27 +369,33 @@ func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
|
||||
d.syncChannel(ctx, guildCh)
|
||||
}
|
||||
|
||||
log.Debug().Msg("Subscribing to guild after bridging")
|
||||
err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
|
||||
GuildID: guild.ID,
|
||||
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,
|
||||
Typing: true,
|
||||
Activities: true,
|
||||
Threads: true,
|
||||
})
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
if user == nil {
|
||||
panic("DiscordClient makeEventSender was passed a nil user")
|
||||
}
|
||||
|
||||
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{
|
||||
Client: d,
|
||||
channel: ch,
|
||||
portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true),
|
||||
portalKey: discordid.MakeChannelPortalKey(ch, d.UserLogin.ID, true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,8 +19,13 @@ 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"
|
||||
)
|
||||
|
||||
@@ -31,14 +36,14 @@ type DiscordConnector struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
|
||||
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
|
||||
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
|
||||
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
|
||||
_ bridgev2.TransactionIDGeneratingNetwork = (*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) {
|
||||
@@ -46,6 +51,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -59,3 +73,7 @@ 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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
75
pkg/connector/events_chat_resync.go
Normal file
75
pkg/connector/events_chat_resync.go
Normal 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
|
||||
}
|
||||
61
pkg/connector/events_guild_resync.go
Normal file
61
pkg/connector/events_guild_resync.go
Normal 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)
|
||||
}
|
||||
@@ -20,11 +20,16 @@ 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"
|
||||
@@ -58,12 +63,69 @@ 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.RemoteEdit = (*DiscordMessage)(nil)
|
||||
// _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteMessageWithTransactionID = (*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
|
||||
}
|
||||
@@ -73,19 +135,24 @@ 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(evt *discordgo.MessageCreate) DiscordMessage {
|
||||
func (d *DiscordClient) wrapDiscordMessage(msg *discordgo.Message, typ bridgev2.RemoteEventType) DiscordMessage {
|
||||
return DiscordMessage{
|
||||
DiscordEventMeta: &DiscordEventMeta{
|
||||
Type: bridgev2.RemoteEventMessage,
|
||||
Type: typ,
|
||||
PortalKey: networkid.PortalKey{
|
||||
ID: discordid.MakePortalID(evt.ChannelID),
|
||||
ID: discordid.MakeChannelPortalIDWithID(msg.ChannelID),
|
||||
Receiver: d.UserLogin.ID,
|
||||
},
|
||||
},
|
||||
Data: evt.Message,
|
||||
Data: msg,
|
||||
Client: d,
|
||||
}
|
||||
}
|
||||
@@ -154,7 +221,7 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
|
||||
DiscordEventMeta: &DiscordEventMeta{
|
||||
Type: evtType,
|
||||
PortalKey: networkid.PortalKey{
|
||||
ID: discordid.MakePortalID(reaction.ChannelID),
|
||||
ID: discordid.MakeChannelPortalIDWithID(reaction.ChannelID),
|
||||
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) {
|
||||
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 {
|
||||
@@ -194,13 +278,21 @@ 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.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)
|
||||
case *discordgo.Resumed:
|
||||
log.Info().Msg("Received RESUMED dispatch from discordgo")
|
||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||
@@ -215,8 +307,18 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
|
||||
Msg("Dropping message that lacks an author")
|
||||
return
|
||||
}
|
||||
wrappedEvt := d.wrapDiscordMessage(evt)
|
||||
d.userCache.UpdateWithMessage(evt.Message)
|
||||
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessage)
|
||||
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)
|
||||
@@ -227,12 +329,12 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
|
||||
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
|
||||
case *discordgo.PresenceUpdate:
|
||||
return
|
||||
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" {
|
||||
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.
|
||||
return
|
||||
}
|
||||
log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event")
|
||||
d.deleteGuildPortalSpace(ctx, evt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
|
||||
"go.mau.fi/util/variationselector"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
@@ -43,7 +46,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
|
||||
|
||||
portal := msg.Portal
|
||||
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)
|
||||
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.
|
||||
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 {
|
||||
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 {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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) {
|
||||
relatesToKey := reaction.Content.RelatesTo.Key
|
||||
portal := reaction.Portal
|
||||
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
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
|
||||
removing := removal.TargetReaction
|
||||
emojiID := removing.EmojiID
|
||||
channelID := discordid.ParsePortalID(removing.Room.ID)
|
||||
channelID := discordid.ParseChannelPortalID(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))
|
||||
@@ -104,7 +131,7 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal
|
||||
}
|
||||
|
||||
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)
|
||||
return d.Session.ChannelMessageDelete(channelID, messageID)
|
||||
}
|
||||
@@ -150,7 +177,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge
|
||||
|
||||
// TODO: Support threads.
|
||||
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))
|
||||
if err != nil {
|
||||
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()
|
||||
defer d.markedOpenedLock.Unlock()
|
||||
|
||||
channelID := discordid.ParsePortalID(portal.ID)
|
||||
channelID := discordid.ParseChannelPortalID(portal.ID)
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("channel_id", channelID).Logger()
|
||||
|
||||
@@ -206,7 +233,7 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma
|
||||
_ = d.viewingChannel(ctx, msg.Portal)
|
||||
|
||||
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.
|
||||
err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID))
|
||||
|
||||
|
||||
@@ -47,55 +47,40 @@ 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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{
|
||||
ID: discordid.MakeUserLoginID(user.ID),
|
||||
ID: discordid.MakeUserLoginID(self.ID),
|
||||
Metadata: &discordid.UserLoginMetadata{
|
||||
Token: token,
|
||||
HeartbeatSession: session.HeartbeatSession,
|
||||
},
|
||||
}, &bridgev2.NewLoginParams{
|
||||
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,
|
||||
DeleteOnConflict: true,
|
||||
})
|
||||
if err != nil {
|
||||
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().
|
||||
Str("user_id", user.ID).
|
||||
Str("user_username", user.Username).
|
||||
Msg("Logged in to Discord")
|
||||
(ul.Client.(*DiscordClient)).Connect(ctx)
|
||||
|
||||
return ul, nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -81,16 +82,17 @@ 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"`
|
||||
|
||||
// v1-compatible fields:
|
||||
// new in v2:
|
||||
Bridged bool `json:"bridged"`
|
||||
Available bool `json:"available"`
|
||||
|
||||
// legacy fields from v1:
|
||||
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"`
|
||||
@@ -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) {
|
||||
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{
|
||||
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,
|
||||
AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
|
||||
|
||||
BridgingMode: "everything",
|
||||
|
||||
Bridged: beingBridged,
|
||||
Available: !guild.Unavailable,
|
||||
|
||||
// v1 (legacy) backwards compat:
|
||||
MXID: mxid,
|
||||
AutoBridge: beingBridged,
|
||||
BridgingMode: "everything",
|
||||
})
|
||||
}
|
||||
|
||||
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 := r.PathValue("guildID")
|
||||
guildID := normalizeGuildID(r.PathValue("guildID"))
|
||||
if guildID == "" {
|
||||
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
|
||||
return
|
||||
@@ -148,14 +201,31 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo
|
||||
Str("guild_id", guildID).
|
||||
Msg("requested to bridge guild via provisioning api")
|
||||
|
||||
// TODO detect guild already bridged
|
||||
go client.bridgeGuild(context.TODO(), guildID)
|
||||
meta := login.Metadata.(*discordid.UserLoginMetadata)
|
||||
|
||||
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) {
|
||||
guildID := r.PathValue("guildID")
|
||||
guildID := normalizeGuildID(r.PathValue("guildID"))
|
||||
if guildID == "" {
|
||||
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
|
||||
return
|
||||
@@ -166,7 +236,22 @@ func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request,
|
||||
Str("guild_id", guildID).
|
||||
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)
|
||||
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
172
pkg/connector/usercache.go
Normal file
172
pkg/connector/usercache.go
Normal 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
|
||||
}
|
||||
@@ -35,13 +35,13 @@ func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID)
|
||||
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")
|
||||
|
||||
return &bridgev2.Avatar{
|
||||
ID: discordid.MakeAvatarID(url),
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
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")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &bridgev2.UserInfo{
|
||||
Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)},
|
||||
Name: ptr.Ptr(user.DisplayName()),
|
||||
Avatar: makeUserAvatar(user),
|
||||
IsBot: &user.Bot,
|
||||
// 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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
package discordid
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
import (
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
)
|
||||
|
||||
type PortalMetadata struct {
|
||||
// The ID of the Discord guild that the channel corresponding to this portal
|
||||
@@ -30,4 +33,21 @@ 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.
|
||||
}
|
||||
|
||||
@@ -17,10 +17,39 @@
|
||||
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)
|
||||
}
|
||||
@@ -43,11 +72,24 @@ func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
|
||||
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)
|
||||
}
|
||||
|
||||
func ParsePortalID(portalID networkid.PortalID) string {
|
||||
func ParseChannelPortalID(portalID networkid.PortalID) string {
|
||||
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
|
||||
// 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
|
||||
// as a space otherwise.
|
||||
// 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.
|
||||
//
|
||||
// "*" was chosen as the asterisk character is used to filter by guilds in
|
||||
// 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.
|
||||
const GuildPortalKeySigil = "*"
|
||||
|
||||
func MakeGuildPortalID(guildID string) networkid.PortalID {
|
||||
func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
|
||||
return networkid.PortalID(GuildPortalKeySigil + guildID)
|
||||
}
|
||||
|
||||
func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
|
||||
key.ID = MakePortalID(ch.ID)
|
||||
if wantReceiver {
|
||||
key.Receiver = userLoginID
|
||||
// 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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
|
||||
key.ID = MakePortalID(channelID)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
|
||||
return
|
||||
// }
|
||||
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),
|
||||
)); portal != nil {
|
||||
if portal.MXID != "" {
|
||||
|
||||
@@ -44,6 +44,9 @@ 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,
|
||||
@@ -128,9 +131,21 @@ 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}
|
||||
@@ -179,9 +194,9 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
|
||||
|
||||
// The portal containing the message that was replied to.
|
||||
targetPortal := portal
|
||||
if ref.ChannelID != discordid.ParsePortalID(portal.ID) {
|
||||
if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) {
|
||||
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 {
|
||||
log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
|
||||
return
|
||||
@@ -301,7 +316,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.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 {
|
||||
// We've bridged the message that was forwarded, so we can link to it directly.
|
||||
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 {
|
||||
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 (
|
||||
|
||||
@@ -22,8 +22,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -35,14 +33,6 @@ 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
|
||||
@@ -102,23 +92,27 @@ func (mc *MessageConverter) ToDiscord(
|
||||
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
|
||||
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
|
||||
var req discordgo.MessageSend
|
||||
req.Nonce = generateMessageNonce()
|
||||
if msg.InputTransactionID != "" {
|
||||
req.Nonce = string(msg.InputTransactionID)
|
||||
} else {
|
||||
req.Nonce = discordid.GenerateNonce()
|
||||
}
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
if msg.ReplyTo != nil {
|
||||
req.Reference = &discordgo.MessageReference{
|
||||
ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID),
|
||||
ChannelID: discordid.ParseChannelPortalID(msg.ReplyTo.Room.ID),
|
||||
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
|
||||
}
|
||||
}
|
||||
|
||||
portal := msg.Portal
|
||||
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
channelID := discordid.ParsePortalID(portal.ID)
|
||||
channelID := discordid.ParseChannelPortalID(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)
|
||||
}
|
||||
@@ -182,7 +176,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{},
|
||||
|
||||
Reference in New Issue
Block a user