Skip to content

Instantly share code, notes, and snippets.

@binwiederhier
Created August 8, 2026 08:32
Show Gist options
  • Select an option

  • Save binwiederhier/a252892ccc05af7412119643aac39e31 to your computer and use it in GitHub Desktop.

Select an option

Save binwiederhier/a252892ccc05af7412119643aac39e31 to your computer and use it in GitHub Desktop.
Fix for mautrix-discord bot reconnect loop / Discord token resets since 2026-08-07 (websocket close 4002: Discord rejects beeper/discordgo QoS heartbeat on bot connections)

Fix: mautrix-discord bot reconnect loop / token resets since 2026-08-07 (websocket close 4002)

Since 2026-08-07 ~17:00 UTC, mautrix-discord bridges using a bot token fail to stay connected to Discord. The gateway connection dies about one second after READY, the bridge reconnect-loops every few seconds, and after ~1000 connection attempts Discord force-resets the bot token and emails you:

It appears your bot has connected to Discord more than 1000 times within a short time period. Since this kind of behavior is usually a result of a bug we have gone ahead and reset your bot's token.

Symptoms in the logs (journalctl -u matrix-mautrix-discord):

WRN wsapi.go:272:listen() > error reading from gateway wss://gateway.discord.gg/... websocket,
    websocket: close 4002: Error while decoding payload.
WRN wsapi.go:224:Open() > Expected READY/RESUMED, instead got:
&discordgo.Event{Operation:9, Sequence:0, ...}

Eventually followed by close 4004: Authentication failed. once the token has been reset.

Notably, Matrix -> Discord relay keeps working (it uses Discord webhooks over REST); only Discord -> Matrix breaks, because that direction needs the gateway websocket. Getting a new token does NOT help; the new session dies the same way.

Root cause

mautrix-discord does not use stock discordgo. Its go.mod replaces it with Beeper's fork:

replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f

In November 2025, the fork replaced the standard Op 1 gateway heartbeat with Discord's client-internal Op 40 "QoS heartbeat" for ALL sessions, including bots (beeper/discordgo PR #5, commit 20c39e9, "Update various headers and capability flags", motivation: "more closely align the bridge's behavior with that of the first-party Discord client"). The payload looks like:

{"op":40,"d":{"seq":123,"qos":{"active":true,"ver":26,"reasons":["foregrounded"]}}}

On 2026-08-07, Discord's gateway started rejecting this payload on bot connections with close 4002: Error while decoding payload, killing the session at the first heartbeat (~1s after READY). discordgo then reconnects aggressively, which is what burns through Discord's 1000-connection limit and triggers the token reset.

Verified by A/B testing a minimal bot against the gateway (same token, same intents):

Library version Heartbeat sent Result
beeper/discordgo @ ccf8cbaa (v0.7.6 pin) Op 40 QoS, ver 26 4002 at first heartbeat
same, with ver bumped 26 -> 28 Op 40 QoS, ver 28 4002 at first heartbeat
beeper/discordgo @ HEAD (2026-08-03) Op 40 QoS, ver 28 4002 at first heartbeat
ccf8cbaa + this patch standard Op 1 stable

So as of 2026-08-08 this is not fixed anywhere upstream: not in mautrix-discord v0.7.6 (latest release), not on its main branch, and not at beeper/discordgo HEAD.

Independent confirmation: the DiscordBotClient / VencordDBCPlugin project (which also sends client opcodes on bot tokens) hit the identical breakage on Aug 7. Their finding matches: the bot gateway now rejects a non-null qos object (they fixed it by sending qos: null; sending the standard Op 1 is the cleaner fix for a pure bot). See aiko-chan-ai/VencordDBCPlugin#3

The patch

mautrix-discord-op1-heartbeat.patch (in this gist) applies to beeper/discordgo at commit ccf8cbaa0a9f (the exact commit mautrix-discord v0.7.6 pins). It makes bot sessions send the standard Op 1 heartbeat and leaves user-token sessions (Beeper's puppeting use case) on the QoS heartbeat:

func (s *Session) newHeartbeatOp(seq int64) interface{} {
    if s.IsUser {
        return newForegroundedQosHeartbeatOp(seq)
    }
    return heartbeatOp{Op: 1, Data: seq}
}

Fix A: patched binary bind-mounted into the official docker image

This keeps the official dock.mau.dev/mautrix/discord:v0.7.6 image and overlays a patched binary. Paths below assume the spantaleev/matrix-docker-ansible-deploy layout (/matrix/mautrix-discord/...); adjust to taste.

1. Get sources and apply the patch

mkdir -p /matrix/mautrix-discord/patched-build
cd /matrix/mautrix-discord/patched-build
git clone --depth 1 --branch v0.7.6 https://github.com/mautrix/discord.git src
git clone https://github.com/beeper/discordgo.git discordgo
cd discordgo
git checkout ccf8cbaa0a9f
git apply /path/to/mautrix-discord-op1-heartbeat.patch

2. Build (inside the matching Alpine toolchain)

The official binaries are statically linked against libolm; Alpine 3.23 no longer ships a static olm, so build dynamically and ship libolm.so.3 alongside (step 3):

cd /matrix/mautrix-discord/patched-build
docker run --rm -v "$PWD":/build -w /build/src golang:1-alpine3.23 sh -c '
  apk add --no-cache git ca-certificates build-base olm-dev &&
  go mod edit -replace github.com/bwmarrin/discordgo=/build/discordgo &&
  go build -o /build/mautrix-discord .'

3. Extract libolm.so.3 (the official image does not contain it)

docker run --rm -v "$PWD":/out alpine:3.23 sh -c \
  'apk add --no-cache olm && cp -L /usr/lib/libolm.so.3 /out/libolm.so.3'

4. Mount both into the container

Edit the docker create line (for matrix-docker-ansible-deploy: /etc/systemd/system/matrix-mautrix-discord.service, back it up first) and add:

--mount type=bind,src=/matrix/mautrix-discord/patched-build/mautrix-discord,dst=/usr/bin/mautrix-discord,ro
--mount type=bind,src=/matrix/mautrix-discord/patched-build/libolm.so.3,dst=/usr/lib/libolm.so.3,ro

Then:

systemctl daemon-reload
systemctl restart matrix-mautrix-discord

5. Verify

journalctl -u matrix-mautrix-discord -f
# no more "close 4002" every ~45s; send a message in a bridged Discord channel
# and confirm it arrives in Matrix

Caveat: if you deploy via ansible, the next playbook run rewrites the unit file and silently removes the mounts. Treat this as a stopgap until it is fixed upstream.

Fix B (zero build): downgrade the image to v0.7.5

mautrix-discord v0.7.5 pins an older fork commit (f23a8518, June 2025) that still sends the standard Op 1 heartbeat, verified working against the current gateway.

Safe schema-wise: v0.7.6 migrates the bridge DB to version 24, but that migration is marked compatible with v19+, and v0.7.5 understands compat level 19. Check yours:

SELECT version, compat FROM version;  -- in the mautrix-discord database; expect 24 | 19

Then point the service at dock.mau.dev/mautrix/discord:v0.7.5 (in matrix-docker-ansible-deploy: matrix_mautrix_discord_docker_image override, or edit the unit file) and restart. You lose the few v0.7.6 additions (federation thumbnail endpoint, new upload fields).

Aftermath checklist

  • If your token was already reset, get a new one from the Discord developer portal and re-login: as the bridge's Matrix user, DM the bridge bot login-token bot <TOKEN>.
  • Do not leave an unpatched bridge running: it will burn ~1000 connects in a few hours and get the fresh token reset again. systemctl stop matrix-mautrix-discord until you have a fix in place.
diff --git a/wsapi.go b/wsapi.go
index 5dede9d..cfc5a0c 100644
--- a/wsapi.go
+++ b/wsapi.go
@@ -310,6 +310,24 @@ func (s *Session) listen(wsConn *websocket.Conn, listening <-chan interface{}) {
}
}
+// heartbeatOp is the standard gateway Op 1 heartbeat payload.
+type heartbeatOp struct {
+ Op int `json:"op"`
+ Data int64 `json:"d"`
+}
+
+// newHeartbeatOp returns the heartbeat payload for this session type. Bot
+// sessions must send the standard Op 1: since 2026-08-07 Discord rejects the
+// client-internal Op 40 QoS heartbeat on bot connections with close 4002,
+// which puts the session in a reconnect loop. User sessions keep the QoS
+// heartbeat that the official client sends.
+func (s *Session) newHeartbeatOp(seq int64) interface{} {
+ if s.IsUser {
+ return newForegroundedQosHeartbeatOp(seq)
+ }
+ return heartbeatOp{Op: 1, Data: seq}
+}
+
func newForegroundedQosHeartbeatOp(seq int64) qosHeartbeatOp {
return qosHeartbeatOp{
Op: 40,
@@ -388,7 +406,7 @@ func (s *Session) heartbeat(wsConn *websocket.Conn, listening <-chan interface{}
s.log(LogDebug, "sending gateway websocket heartbeat seq %d", sequence)
s.wsMutex.Lock()
s.LastHeartbeatSent = time.Now().UTC()
- err = wsConn.WriteJSON(newForegroundedQosHeartbeatOp(sequence))
+ err = wsConn.WriteJSON(s.newHeartbeatOp(sequence))
s.wsMutex.Unlock()
if err != nil || time.Now().UTC().Sub(last) > (heartbeatIntervalMsec*FailedHeartbeatAcks) {
if err != nil {
@@ -754,7 +772,7 @@ func (s *Session) onEvent(messageType int, message []byte, isOnConnect bool) (*E
if e.Operation == 1 {
s.log(LogInformational, "sending heartbeat in response to Op1")
s.wsMutex.Lock()
- err = s.wsConn.WriteJSON(newForegroundedQosHeartbeatOp(atomic.LoadInt64(s.sequence)))
+ err = s.wsConn.WriteJSON(s.newHeartbeatOp(atomic.LoadInt64(s.sequence)))
s.wsMutex.Unlock()
if err != nil {
s.log(LogError, "error sending heartbeat in response to Op1")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment