diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 173e36a..b238c9c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,8 +8,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24", "1.25"] - name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }} + go-version: ["1.25", "1.26"] + name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0f49322..0d40ec7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: lock-stale: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 id: lock with: issue-inactive-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f53a95..c88e03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v0.7.6 (2026-02-16) + +* Bumped minimum Go version to 1.25. +* Updated Docker image to Alpine 3.23. +* Added support for following tombstones. +* Added support for disabling link previews in messages sent to Discord using + [MSC4095]. +* Added support for federation thumbnail endpoint when using direct media. +* Disabled using `restricted` join rules by default. + +[MSC4095]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + # v0.7.5 (2025-07-16) * Fixed federation key response when using direct media. diff --git a/Dockerfile b/Dockerfile index 4664399..58b1f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1-alpine3.22 AS builder +FROM golang:1-alpine3.23 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev @@ -6,7 +6,7 @@ COPY . /build WORKDIR /build RUN go build -o /usr/bin/mautrix-discord -FROM alpine:3.22 +FROM alpine:3.23 ENV UID=1337 \ GID=1337 @@ -17,5 +17,6 @@ COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/Dockerfile.ci b/Dockerfile.ci index 32b9c35..abb50bd 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,4 +1,4 @@ -FROM alpine:3.22 +FROM alpine:3.23 ENV UID=1337 \ GID=1337 @@ -10,5 +10,6 @@ COPY $EXECUTABLE /usr/bin/mautrix-discord COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/config/bridge.go b/config/bridge.go index 2f78ed7..c546aa8 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -56,6 +56,7 @@ type BridgeConfig struct { PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"` EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"` UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"` + ForbidDMingStrangers bool `yaml:"forbid_dming_strangers"` Proxy string `yaml:"proxy"` diff --git a/config/upgrade.go b/config/upgrade.go index 1c9fe56..3d7a9fa 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -63,6 +63,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Bool, "bridge", "prefix_webhook_messages") helper.Copy(up.Bool, "bridge", "enable_webhook_avatars") helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload") + helper.Copy(up.Bool, "bridge", "forbid_dming_strangers") helper.Copy(up.Str|up.Null, "bridge", "proxy") helper.Copy(up.Str, "bridge", "cache_media") helper.Copy(up.Bool, "bridge", "direct_media", "enabled") diff --git a/database/json.go b/database/json.go new file mode 100644 index 0000000..566a6c4 --- /dev/null +++ b/database/json.go @@ -0,0 +1,20 @@ +package database + +import ( + "go.mau.fi/util/dbutil" +) + +// Backported from mautrix/go-util@e5cb5e96d15cb87ffe6e5970c2f90ee47980e715. + +// JSONPtr is a convenience function for wrapping a pointer to a value in the JSON utility, but removing typed nils +// (i.e. preventing nils from turning into the string "null" in the database). +func JSONPtr[T any](val *T) dbutil.JSON { + return dbutil.JSON{Data: UntypedNil(val)} +} + +func UntypedNil[T any](val *T) any { + if val == nil { + return nil + } + return val +} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql index 46fbb73..d794530 100644 --- a/database/upgrades/00-latest-revision.sql +++ b/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v23 (compatible with v19+): Latest revision +-- v0 -> v24 (compatible with v19+): Latest revision CREATE TABLE guild ( dcid TEXT PRIMARY KEY, @@ -92,7 +92,8 @@ CREATE TABLE "user" ( space_room TEXT, dm_space_room TEXT, - read_state_version INTEGER NOT NULL DEFAULT 0 + read_state_version INTEGER NOT NULL DEFAULT 0, + heartbeat_session jsonb ); CREATE TABLE user_portal ( diff --git a/database/upgrades/24-user-heartbeat-session.sql b/database/upgrades/24-user-heartbeat-session.sql new file mode 100644 index 0000000..ccb44f9 --- /dev/null +++ b/database/upgrades/24-user-heartbeat-session.sql @@ -0,0 +1,2 @@ +-- v24 (compatible with v19+): Add persisted heartbeat sessions +ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb; diff --git a/database/user.go b/database/user.go index 763625d..eff661b 100644 --- a/database/user.go +++ b/database/user.go @@ -3,6 +3,7 @@ package database import ( "database/sql" + "github.com/bwmarrin/discordgo" "go.mau.fi/util/dbutil" log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/id" @@ -21,18 +22,18 @@ func (uq *UserQuery) New() *User { } func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE mxid=$1` return uq.New().Scan(uq.db.QueryRow(query, userID)) } func (uq *UserQuery) GetByID(id string) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE dcid=$1` return uq.New().Scan(uq.db.QueryRow(query, id)) } func (uq *UserQuery) GetAllWithToken() []*User { query := ` - SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version + SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE discord_token IS NOT NULL ` rows, err := uq.db.Query(query) @@ -54,19 +55,20 @@ type User struct { db *Database log log.Logger - MXID id.UserID - DiscordID string - DiscordToken string - ManagementRoom id.RoomID - SpaceRoom id.RoomID - DMSpaceRoom id.RoomID + MXID id.UserID + DiscordID string + DiscordToken string + ManagementRoom id.RoomID + SpaceRoom id.RoomID + DMSpaceRoom id.RoomID + HeartbeatSession *discordgo.HeartbeatSession ReadStateVersion int } func (u *User) Scan(row dbutil.Scannable) *User { var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString - err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion) + err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession}) if err != nil { if err != sql.ErrNoRows { u.log.Errorln("Database scan failed:", err) @@ -83,8 +85,8 @@ func (u *User) Scan(row dbutil.Scannable) *User { } func (u *User) Insert() { - query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion) + query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession)) if err != nil { u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) panic(err) @@ -92,8 +94,8 @@ func (u *User) Insert() { } func (u *User) Update() { - query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7` - _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID) + query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6, heartbeat_session=$7 WHERE mxid=$8` + _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession), u.MXID) if err != nil { u.log.Warnfln("Failed to update %q: %v", u.MXID, err) panic(err) diff --git a/directmedia.go b/directmedia.go index 4499c1a..6b683aa 100644 --- a/directmedia.go +++ b/directmedia.go @@ -154,6 +154,7 @@ func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI { addRoutes("r0") addRoutes("v1") federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) + federationRouter.HandleFunc("/v1/media/thumbnail/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet) mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) @@ -556,7 +557,7 @@ func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWri func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerolog.Ctx(ctx) - isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/") + isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/") vars := mux.Vars(r) if !isNewFederation && vars["serverName"] != dma.cfg.ServerName { jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ diff --git a/example-config.yaml b/example-config.yaml index ea392bb..0c1ab13 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -130,7 +130,7 @@ bridge: message_error_notices: true # Should the bridge use space-restricted join rules instead of invite-only for guild rooms? # This can avoid unnecessary invite events in guild rooms when members are synced in. - restricted_rooms: true + restricted_rooms: false # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). autojoin_thread_on_open: true @@ -171,6 +171,10 @@ bridge: # like the official client does? The other option is sending the media in the message send request as a form part # (which is always used by bots and webhooks). use_discord_cdn_upload: true + # Should the bridge forbid direct messages from users to other users who they aren't friends with? Discord generally + # considers this to be a "risky" action. Note that the bridge will conservatively reject all outgoing DMs from users + # until it has synced that user's relationships from Discord. + forbid_dming_strangers: true # Proxy for Discord connections proxy: # Should mxc uris copied from Discord be cached? diff --git a/go.mod b/go.mod index 3cfd6aa..59f16f0 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module go.mau.fi/mautrix-discord -go 1.24.0 +go 1.25.0 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/bwmarrin/discordgo v0.27.0 @@ -26,6 +26,7 @@ require ( require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -42,4 +43,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f diff --git a/go.sum b/go.sum index 51356d2..dc97b3a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f h1:A+SRmETpSnFixbP1x6u7sQdoi8cOuYfL5bkDJy9F/Pg= +github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -11,6 +11,8 @@ github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFA github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= diff --git a/main.go b/main.go index 5b6f635..a9beca7 100644 --- a/main.go +++ b/main.go @@ -187,7 +187,7 @@ func main() { Name: "mautrix-discord", URL: "https://github.com/mautrix/discord", Description: "A Matrix-Discord puppeting bridge.", - Version: "0.7.5", + Version: "0.7.6", ProtocolName: "Discord", BeeperServiceName: "discordgo", BeeperNetworkName: "discord", diff --git a/portal.go b/portal.go index 3a5db83..db26a0e 100644 --- a/portal.go +++ b/portal.go @@ -1226,6 +1226,8 @@ var ( errUnknownRelationType = errors.New("unknown relation type") errTargetNotFound = errors.New("target event not found") errUnknownEmoji = errors.New("unknown emoji") + errRelationshipsNotReady = errors.New("can't direct message before receiving relationships") + errDMingStranger = errors.New("can't direct message a stranger") errCantStartThread = errors.New("can't create thread without being logged into Discord") ) @@ -1241,6 +1243,10 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev errors.Is(err, attachment.UnsupportedAlgorithm), errors.Is(err, errCantStartThread): return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "", nil + case errors.Is(err, errDMingStranger): + return event.MessageStatusGenericError, event.MessageStatusFail, true, true, "You can't message users who aren't on your friends list. Use the Discord app to chat or add them as a friend to continue.", nil + case errors.Is(err, errRelationshipsNotReady): + return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Still syncing your Discord friends list, please try again in a moment.", nil case errors.Is(err, attachment.HashMismatch), errors.Is(err, attachment.InvalidKey), errors.Is(err, attachment.InvalidInitVector): @@ -1522,11 +1528,6 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return - } - content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") @@ -1540,6 +1541,33 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { return } isWebhookSend := sess == nil + + if portal.IsPrivateChat() { + if sender.DiscordID != portal.Key.Receiver { + go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") + return + } + + if portal.bridge.Config.Bridge.ForbidDMingStrangers && sess.IsUser { + recipient := portal.bridge.GetPuppetByID(portal.OtherUserID) + + if !recipient.IsBot { + sender.relationshipLock.RLock() + if !sender.relationshipsReady { + go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "") + sender.relationshipLock.RUnlock() + return + } + relationship, hasRelationship := sender.relationships[portal.OtherUserID] + sender.relationshipLock.RUnlock() + + if !hasRelationship || relationship.Type != discordgo.RelationshipFriend { + go portal.sendMessageMetrics(evt, errDMingStranger, "") + return + } + } + } + } var threadID string if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil { @@ -1559,7 +1587,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { }) } go portal.sendMessageMetrics(evt, err, "Failed to edit") - if msg.EditedTimestamp != nil { + if msg != nil && msg.EditedTimestamp != nil { edits.UpdateEditTimestamp(*msg.EditedTimestamp) } } else { @@ -1647,16 +1675,21 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser { att := &discordgo.MessageAttachment{ - ID: "0", - Filename: filename, - Description: description, + ID: "0", + Filename: filename, + Description: description, + OriginalContentType: content.Info.MimeType, } sendReq.Attachments = []*discordgo.MessageAttachment{att} + isClip := false prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ Files: []*discordgo.FilePrepare{{ Size: len(data), Name: att.Filename, ID: sender.NextDiscordUploadID(), + + IsClip: &isClip, + OriginalContentType: att.OriginalContentType, }}, }, portal.RefererOpt(threadID)) if err != nil { @@ -1918,12 +1951,13 @@ func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { } func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return - } else if !sender.IsLoggedIn() { - //go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring") - return } reaction := evt.Content.AsReaction() @@ -2101,9 +2135,15 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess } func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return + if portal.IsPrivateChat() { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + if sender.DiscordID != portal.Key.Receiver { + go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") + return + } } sess := sender.Session @@ -2523,6 +2563,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord if portal.OtherUserID != "" { puppet := portal.bridge.GetPuppetByID(portal.OtherUserID) changed = portal.UpdateAvatarFromPuppet(puppet) || changed + source.relationshipLock.RLock() if rel, ok := source.relationships[portal.OtherUserID]; ok && rel.Nickname != "" { portal.FriendNick = true changed = portal.UpdateNameDirect(rel.Nickname, true) || changed @@ -2530,6 +2571,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord portal.FriendNick = false changed = portal.UpdateNameDirect(puppet.Name, false) || changed } + source.relationshipLock.RUnlock() } if portal.MXID != "" { portal.syncParticipants(source, meta.Recipients) diff --git a/user.go b/user.go index f209b33..01a7203 100644 --- a/user.go +++ b/user.go @@ -68,6 +68,12 @@ type User struct { nextDiscordUploadID atomic.Int32 relationships map[string]*discordgo.Relationship + // relationshipsReady should be protected by relationshipLock and is merely + // used to cover the brief moment in time where the readyHandler goroutine + // is being scheduled; during that time, the relationships map is unlocked + // and "available" but not logically "ready" just yet. + relationshipsReady bool + relationshipLock sync.RWMutex } func (user *User) GetRemoteID() string { @@ -497,6 +503,7 @@ func (user *User) Logout(isOverwriting bool) { } user.Session = nil + user.reconstructRelationships(nil) user.DiscordToken = "" user.ReadStateVersion = 0 if !isOverwriting { @@ -511,6 +518,26 @@ func (user *User) Logout(isOverwriting bool) { user.log.Info().Msg("User logged out") } +func (user *User) reconstructRelationships(relationships []*discordgo.Relationship) { + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() + + clear(user.relationships) + + if relationships == nil { + // Relationships are just being cleared out; we don't actually have + // them yet. + user.relationshipsReady = false + } else { + // We've received the authoritative list of relationships from the + // gateway. + for _, relationship := range relationships { + user.relationships[relationship.ID] = relationship + } + user.relationshipsReady = true + } +} + func (user *User) Connected() bool { user.Lock() defer user.Unlock() @@ -538,6 +565,9 @@ const BotIntents = discordgo.IntentGuilds | func (user *User) Connect() error { user.Lock() + // Clear our in-memory relationship cache as it might've changed while + // offline; READY will repopulate it. + user.reconstructRelationships(nil) defer user.Unlock() if user.DiscordToken == "" { @@ -550,6 +580,17 @@ func (user *User) Connect() error { if err != nil { return err } + + if user.HeartbeatSession == nil || user.HeartbeatSession.IsExpired() { + user.log.Debug().Msg("Creating new heartbeat session") + sess := discordgo.NewHeartbeatSession() + user.HeartbeatSession = &sess + } + user.HeartbeatSession.BumpLastUsed() + user.Update() + // make discordgo use our session instead of the one it creates automatically + session.HeartbeatSession = *user.HeartbeatSession + if user.bridge.Config.Bridge.Proxy != "" { u, _ := url.Parse(user.bridge.Config.Bridge.Proxy) tlsConf := &tls.Config{ @@ -569,7 +610,10 @@ func (user *User) Connect() error { } else { session.LogLevel = discordgo.LogInformational } - userDiscordLog := user.log.With().Str("component", "discordgo").Logger() + userDiscordLog := user.log.With(). + Str("component", "discordgo"). + Str("heartbeat_session", session.HeartbeatSession.ID.String()). + Logger() session.Logger = func(msgL, caller int, format string, a ...interface{}) { userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf } @@ -686,6 +730,7 @@ func (user *User) Disconnect() error { } user.log.Info().Msg("Disconnecting session manually") + user.reconstructRelationships(nil) if err := user.Session.Close(); err != nil { return err } @@ -744,9 +789,7 @@ func (user *User) readyHandler(r *discordgo.Ready) { user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling}) user.tryAutomaticDoublePuppeting() - for _, relationship := range r.Relationships { - user.relationships[relationship.ID] = relationship - } + user.reconstructRelationships(r.Relationships) updateTS := time.Now() portalsInSpace := make(map[string]bool) @@ -807,6 +850,7 @@ func (user *User) subscribeGuilds(delay time.Duration) { func (user *User) resumeHandler(_ *discordgo.Resumed) { user.log.Debug().Msg("Discord connection resumed") user.subscribeGuilds(0 * time.Second) + user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } func (user *User) addPrivateChannelToSpace(portal *Portal) bool { @@ -828,17 +872,23 @@ func (user *User) addPrivateChannelToSpace(portal *Portal) bool { func (user *User) relationshipAddHandler(r *discordgo.RelationshipAdd) { user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship added") + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() user.relationships[r.ID] = r.Relationship user.handleRelationshipChange(r.ID, r.Nickname) } func (user *User) relationshipUpdateHandler(r *discordgo.RelationshipUpdate) { + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship update") user.relationships[r.ID] = r.Relationship user.handleRelationshipChange(r.ID, r.Nickname) } func (user *User) relationshipRemoveHandler(r *discordgo.RelationshipRemove) { + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() user.log.Debug().Str("other_user_id", r.ID).Msg("Relationship removed") delete(user.relationships, r.ID) user.handleRelationshipChange(r.ID, "") @@ -1007,7 +1057,6 @@ func (user *User) connectedHandler(_ *discordgo.Connect) { user.log.Debug().Msg("Connected to Discord") if user.wasDisconnected { user.wasDisconnected = false - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } }