From 2182c0d38fe714efcd173895aab08b900829229c Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Tue, 28 Oct 2025 08:39:43 -0700 Subject: [PATCH 01/16] Only send `CONNECTED` bridge state on `READY` or `RESUMED` (#199) --- user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user.go b/user.go index f209b33..2dd727a 100644 --- a/user.go +++ b/user.go @@ -807,6 +807,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 { @@ -1007,7 +1008,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}) } } From c8c00a42bba772b72c5055687641fa7fb182446e Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Thu, 30 Oct 2025 08:06:21 -0700 Subject: [PATCH 02/16] Bump discordgo (#200) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3cfd6aa..5cb664e 100644 --- a/go.mod +++ b/go.mod @@ -42,4 +42,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-20251029151721-c53d6229e2fd diff --git a/go.sum b/go.sum index 51356d2..a16ce05 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-20251029151721-c53d6229e2fd h1:RXB0a8lTNN9vB838lZXm11inXwvILpOzXi3j978P8RE= +github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= 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= From 9eaf213091efddcfd68e7dad2952db08317c416c Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Wed, 19 Nov 2025 11:10:08 -0800 Subject: [PATCH 03/16] user: send `errUserNotLoggedIn` if we can't bridge event from logged-out user (#204) --- portal.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/portal.go b/portal.go index 3a5db83..0d860d6 100644 --- a/portal.go +++ b/portal.go @@ -1522,9 +1522,15 @@ 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 + 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 + } } content, ok := evt.Content.Parsed.(*event.MessageEventContent) @@ -1918,12 +1924,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 +2108,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 From d7292a0706f787a84537ee5a281c3652543efbf4 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Wed, 19 Nov 2025 14:33:26 -0800 Subject: [PATCH 04/16] bump discordgo and add support for heartbeat sessions (#203) --- database/json.go | 20 +++++++++++++ database/upgrades/00-latest-revision.sql | 5 ++-- .../upgrades/24-user-heartbeat-session.sql | 2 ++ database/user.go | 30 ++++++++++--------- go.mod | 3 +- go.sum | 6 ++-- user.go | 16 +++++++++- 7 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 database/json.go create mode 100644 database/upgrades/24-user-heartbeat-session.sql 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/go.mod b/go.mod index 5cb664e..255ce83 100644 --- a/go.mod +++ b/go.mod @@ -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-20251029151721-c53d6229e2fd +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec diff --git a/go.sum b/go.sum index a16ce05..aeac7db 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-20251029151721-c53d6229e2fd h1:RXB0a8lTNN9vB838lZXm11inXwvILpOzXi3j978P8RE= -github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +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/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/user.go b/user.go index 2dd727a..b5ff572 100644 --- a/user.go +++ b/user.go @@ -550,6 +550,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 +580,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 } From 11b1ea5aa6f7fd38fb31437ce218153fad184d11 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Tue, 25 Nov 2025 11:16:51 -0800 Subject: [PATCH 05/16] bump discordgo (#206) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 255ce83..5dd9c96 100644 --- a/go.mod +++ b/go.mod @@ -43,4 +43,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-20251125191000-08af68849c99 diff --git a/go.sum b/go.sum index aeac7db..8bd33f8 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-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= -github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= +github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 h1:uLpNLE/Avs+XMOzbjh49MfWuqm2lo+Z8Kv07CjOdRWQ= +github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99/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= From 5c4527f1b2cbd8ea63bd3f58f325450e2231b522 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 21 Jan 2026 18:47:43 +0200 Subject: [PATCH 06/16] Disable restricted rooms by default --- example-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example-config.yaml b/example-config.yaml index ea392bb..bfe30d4 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 From ca9f0322340f9857f43e6a0511061bead066e186 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 12 Feb 2026 16:29:35 +0200 Subject: [PATCH 07/16] docker: fix working directory and update to Alpine 3.23 --- Dockerfile | 5 +++-- Dockerfile.ci | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) 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"] From 17c1938b4caf9d67687e16e12783f6c3c8793db2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 14:46:35 +0200 Subject: [PATCH 08/16] Bump minimum Go version to 1.25 --- .github/workflows/go.yml | 4 ++-- go.mod | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/go.mod b/go.mod index 5dd9c96..69e6091 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 From fab784bfd817df7526594259e3201627ffc9869c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 14:48:09 +0200 Subject: [PATCH 09/16] Add new fields to uploads --- go.mod | 2 +- go.sum | 4 ++-- portal.go | 11 ++++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 69e6091..59f16f0 100644 --- a/go.mod +++ b/go.mod @@ -43,4 +43,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f diff --git a/go.sum b/go.sum index 8bd33f8..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-20251125191000-08af68849c99 h1:uLpNLE/Avs+XMOzbjh49MfWuqm2lo+Z8Kv07CjOdRWQ= -github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= +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= diff --git a/portal.go b/portal.go index 0d860d6..b8132ec 100644 --- a/portal.go +++ b/portal.go @@ -1653,16 +1653,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 { From daf6b9420ca07a03b72c67a4fe47935155e16476 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 21:48:10 +0200 Subject: [PATCH 10/16] Add support for federation thumbnail endpoint --- directmedia.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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{ From 19e26674e6624a02bced982aafe845cb20e43827 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Feb 2026 15:49:46 +0200 Subject: [PATCH 11/16] Bump version to v0.7.6 --- CHANGELOG.md | 12 ++++++++++++ main.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) 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/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", From b69576cdc45b0f19a19878de9b5eb44f675712c0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 11 Mar 2026 22:47:48 +0200 Subject: [PATCH 12/16] Don't panic if editing a message fails [skip cd] --- portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal.go b/portal.go index b8132ec..6571c1f 100644 --- a/portal.go +++ b/portal.go @@ -1565,7 +1565,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 { From ceadbf3093d8670ab9f4b58700bdd3c394a803bf Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Fri, 20 Mar 2026 19:37:50 -0700 Subject: [PATCH 13/16] Add option to block user DMs to strangers using a synchronized relationship cache (#221) Co-authored-by: Tulir Asokan --- config/bridge.go | 1 + config/upgrade.go | 1 + example-config.yaml | 4 ++++ portal.go | 24 ++++++++++++++++++++++++ user.go | 41 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 68 insertions(+), 3 deletions(-) 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/example-config.yaml b/example-config.yaml index bfe30d4..0c1ab13 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -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/portal.go b/portal.go index 6571c1f..baaaa4d 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): @@ -1531,6 +1537,22 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } + + if portal.bridge.Config.Bridge.ForbidDMingStrangers { + 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 + } + } } content, ok := evt.Content.Parsed.(*event.MessageEventContent) @@ -2541,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 @@ -2548,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 b5ff572..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 == "" { @@ -700,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 } @@ -758,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) @@ -843,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, "") From bccdea7d24c9060d41c8b5c5f06c89747072ee45 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Mon, 23 Mar 2026 18:12:10 -0700 Subject: [PATCH 14/16] Permit user DMs to bots even under `ForbidDMingStrangers` (#222) [skip cd] --- portal.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/portal.go b/portal.go index baaaa4d..f73d206 100644 --- a/portal.go +++ b/portal.go @@ -1539,18 +1539,22 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { } if portal.bridge.Config.Bridge.ForbidDMingStrangers { - sender.relationshipLock.RLock() - if !sender.relationshipsReady { - go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "") - sender.relationshipLock.RUnlock() - return - } - relationship, hasRelationship := sender.relationships[portal.OtherUserID] - sender.relationshipLock.RUnlock() + recipient := portal.bridge.GetPuppetByID(portal.OtherUserID) - if !hasRelationship || relationship.Type != discordgo.RelationshipFriend { - go portal.sendMessageMetrics(evt, errDMingStranger, "") - return + 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 + } } } } From d3d1c338f44dd3977fa9bea9dc708b904cd41a68 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 12 May 2026 00:16:06 +0300 Subject: [PATCH 15/16] Don't check friend status when sending as bot [skip cd] --- portal.go | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/portal.go b/portal.go index f73d206..db26a0e 100644 --- a/portal.go +++ b/portal.go @@ -1528,17 +1528,27 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok { + go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") + return + } + + channelID := portal.Key.ChannelID + sess := sender.Session + if sess == nil && portal.RelayWebhookID == "" { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + isWebhookSend := sess == nil + 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 } - if portal.bridge.Config.Bridge.ForbidDMingStrangers { + if portal.bridge.Config.Bridge.ForbidDMingStrangers && sess.IsUser { recipient := portal.bridge.GetPuppetByID(portal.OtherUserID) if !recipient.IsBot { @@ -1558,20 +1568,6 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { } } } - - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") - return - } - - channelID := portal.Key.ChannelID - sess := sender.Session - if sess == nil && portal.RelayWebhookID == "" { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") - return - } - isWebhookSend := sess == nil var threadID string if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil { From da5e548b7f84d99ce8a838d8098eba269ca5c3f6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 23 May 2026 17:42:29 +0300 Subject: [PATCH 16/16] Bump lock-threads action version [skip ci] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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