Watch
1
0
Fork
You've already forked mautrix-discord
0
mirror of https://github.com/mautrix/discord.git synced 2026-11-09 15:49:18 -05:00

Compare commits

..
Author SHA1 Message Date
Gerardo Rodriguez
b1c7017e70 msgconv: don't log dead external embed images as errors 2026-11-09 02:12:35 +00:00
Gerardo Rodriguez
4cd87ec1b5
provisioning: use login_id from the request when given (#307) 2026-11-05 09:59:14 -05:00
Gerardo Rodriguez
52cff5853f
dependencies: update discordgo to retry non-JSON 429s (#302) 2026-11-05 14:23:11 +00:00
indent[bot]
13636de20f
handlediscord: look up deleted channel portals from the event's channel (#305)
Co-authored-by: Indent <noreply@indent.com>
2026-11-05 13:29:20 +00:00
indent[bot]
28e559c5bd
msgconv/from-discord: log messages that convert to no parts (#304)
Co-authored-by: Indent <noreply@indent.com>
2026-11-05 13:28:49 +00:00
Nick Mills-Barrett
d17912102c
pre-commit: install bridgelint with the CI Go toolchain (#306) 2026-11-05 13:08:41 +00:00
Nick Mills-Barrett
a8a0e0a57b
pre-commit: add bridgelint hook (#301) 2026-11-03 21:35:15 +00:00
Nick Mills-Barrett
28310fd806
login, startchat: return proper HTTP errors for login start and DM creation failures (#300) 2026-11-03 20:54:28 +00:00
indent[bot]
d93624c815
provisioning: don't abort guild unbridge when the client disconnects (#293)
Co-authored-by: Indent <noreply@indent.com>
2026-11-02 09:27:02 -05:00
Gerardo Rodriguez
e834d3abdc
msgconv/from-discord: bridge media and embeds in forwarded messages (#288) 2026-11-02 13:11:04 +00:00
Gerardo Rodriguez
2e7fcc6119
msgconv/from-discord: bridge guild member and webhook profiles (#289) 2026-11-02 11:18:16 +00:00
indent[bot]
786d32096b
backfill: stop retrying channels Discord won't let us read (#283)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:16:38 -05:00
indent[bot]
4f82d1a9de
msgconv/from-discord: fall back to the thumbnail when a video embed has no proxy URL (#290)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:12:37 -05:00
Nick Mills-Barrett
cbc53a658a
handlematrix: don't allow stranger DMs before account standing is known (#287) 2026-10-29 14:37:37 +00:00
Tulir Asokan
51fae00001 directmedia: add config option to proxy downloads 2026-10-29 16:07:15 +02:00
Nick Mills-Barrett
2ab5df8259
client: keep retrying the gateway and report drops as transient disconnects (#281) 2026-10-29 14:02:31 +00:00
Gerardo Rodriguez
7f73c93f36
handlematrix: accept message requests before replying (#280) 2026-10-29 09:59:58 -04:00
Tulir Asokan
053bd08826 Revert "directmedia: proxy attachment downloads instead of redirecting (#286)"
This reverts commit e0daf0ed13.
2026-10-29 15:42:45 +02:00
Gerardo Rodriguez
e0daf0ed13
directmedia: proxy attachment downloads instead of redirecting (#286) 2026-10-29 13:40:40 +00:00
Nick Mills-Barrett
7e84766c51
client: add config options to customize stranger DMs blocking (#243) 2026-10-29 13:40:05 +00:00
Nick Mills-Barrett
5b528a6aed
handlematrix: report Discord's definite rejections as permanent failures (#282)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:10:50 +00:00
Gerardo Rodriguez
e0d509263b
backfill: pass the channel read state as ReadUpTo (#279) 2026-10-26 12:21:02 -04:00
Gerardo Rodriguez
bf7bb1190e
login_machine: tell the user when Discord still wants email verification (#278) 2026-10-22 11:07:16 -04:00
Nick Mills-Barrett
e8de753a8d
client: don't leave a session open after Disconnect (#275) 2026-10-21 21:54:48 +02:00
Gerardo Rodriguez
d7da4169c9
startchat: support starting DMs, contact list and user search (#270) 2026-10-20 22:03:12 +02:00
Nick Mills-Barrett
f7e6160909
login_remoteauth: tolerate cancel before the client is created (#271) 2026-10-19 14:10:10 +01:00
Nick Mills-Barrett
2cdbe57a4b
handlematrix: don't send typing when the user stops typing (#272) 2026-10-19 14:09:46 +01:00
Nick Mills-Barrett
28043011de
handlematrix,msgconv: report unsupported actions with proper statuses (#277) 2026-10-19 14:00:49 +01:00
Gerardo Rodriguez
bd79ef8276
handlediscord: attach captions added in edits to existing media instead of sending new parts (#268) 2026-10-16 12:57:54 -04:00
Gerardo Rodriguez
bce44c64dc
msgconv: fetch GIF stickers from media.discordapp.net (#269) 2026-10-16 07:07:39 -04:00
Gerardo Rodriguez
097d3397a3
handlediscord: bridge guild channels that become visible (#260) 2026-10-15 11:15:19 -04:00
Gerardo Rodriguez
540e2d9487
handlematrix: handle echoes of messages whose send timed out (#262) 2026-10-14 06:59:51 -04:00
Gerardo Rodriguez
7d2d590898
handlediscord: replace text part in place when edit turns it into media (#264) 2026-10-12 14:16:54 -04:00
Gerardo Rodriguez
accd7d3e29
chatinfo: inherit category mute for channels (#263) 2026-10-12 14:09:54 -04:00
Tulir Asokan
f70c356b2a capabilities: actually fix DM caps 2026-10-05 18:57:25 +03:00
Tulir Asokan
2116e16923 handlematrix: check current relationship before unblocking 2026-10-05 18:56:06 +03:00
Tulir Asokan
28187f2718 capabilities: fix DM thread capability 2026-10-05 18:53:35 +03:00
Tulir Asokan
d892da17a1 handle*: add support for blocking DM portals 2026-10-05 18:26:10 +03:00
Gerardo Rodriguez
6c79932f94
msgconv: Avoid dropping Discord messages with mentions or incomplete replies (#258) 2026-09-26 10:40:58 -04:00
Tulir Asokan
e83888f174 login,remoteauth: fix unsafe login cancellation 2026-09-02 17:05:40 +03:00
Tulir Asokan
c85f727c0e dependencies: update mautrix-go 2026-08-25 17:26:16 +03:00
Pierre Kisters
872958ba09
chatinfo: expose group DM recipients to channel name template (#245) 2026-08-25 13:25:05 +03:00
Tulir Asokan
3e27bb029b docker: update to Alpine 3.24 2026-08-25 13:17:14 +03:00
Tulir Asokan
281bf9c027 dependencies: bump minimum Go version to 1.26 2026-08-25 13:16:51 +03:00
Nick Mills-Barrett
6501891a2b
login: wrap and propagate errors (#242) 2026-08-12 18:23:39 +01:00
Nick Mills-Barrett
7b36a30cd1
dependencies: upgrade mautrix-go 2026-08-12 12:19:35 +01:00
Tulir Asokan
7e7ad561ca meowcord/wsapi: send standard Op 1 heartbeat for bot sessions
Sync from 8051e14a44
2026-08-08 12:08:17 +03:00
Skip R
1fbf12709a dependencies: update discordgo 2026-08-03 06:18:54 -07:00
Skip R
61d3e904b5 meow: sync discordgo 2026-08-03 06:18:41 -07:00
Skip R
005afd448c auth/machine: log converted errors 2026-08-02 19:43:57 -07:00
Tulir Asokan
7727bdfcc5 dependencies: update mautrix-go 2026-07-20 15:18:11 +03:00
Skip R
04b40a6cc5 meow: lint 2026-07-17 04:07:31 -07:00
Skip R
4721fca436 doc: vitals 2026-07-16 16:39:01 -07:00
Skip R
b12444c197 errorcodes: fix comment 2026-07-16 16:38:55 -07:00
Skip R
295957290f connector: don't explode if Session.User is nil after Open 2026-07-15 20:46:16 -07:00
Skip R
948b6d9321 connector/chatinfo,userinfo: permit when "logged out"
So we can create portals (for e.g. the official system user) if
necessary.
2026-07-15 20:27:09 -07:00
Skip R
e52c19533e vitals: lock state reads 2026-07-15 20:21:54 -07:00
Skip R
5cda45c29f connector/config: correct doc 2026-07-15 18:28:56 -07:00
Skip R
3de7b918ac connector: defer full sync until we have good vitals 2026-07-15 18:28:32 -07:00
Skip R
17ce47e772 vitals: don't litter context sent to downstream work 2026-07-15 17:28:31 -07:00
Skip R
75a27726bd handlediscord: transition bridge state ASAP upon urgent system message
Don't wait for the safety hub fetch to finish.
2026-07-15 16:37:53 -07:00
Skip R
317876a0b6 refine comments 2026-07-15 16:37:44 -07:00
Skip R
bf19aafe6f handlediscord: spawn goroutine for updating safety hub/vitals
This code is in the synchronous update path, we need to avoid blocking
on HTTP requests here.
2026-07-15 16:29:03 -07:00
Skip R
86da1edd6c auth/error: don't consider "compromised account" error to be input error 2026-07-15 16:28:28 -07:00
Skip R
3fc2e39b88 auth/machine: explicitly check for correct answer field 2026-07-15 16:26:40 -07:00
Skip R
ae24c77e02 auth/machine: clear interrupt req if IP auth token is invalid
Otherwise, the interrupt req takes priority (it always does this by
design) and the user doesn't actually get to log in from scratch again,
they just get stuck in a loop forever.
2026-07-15 16:09:11 -07:00
Skip R
0c61a6db4c handlediscord: only set UserFlagHasUnreadUrgentMessages when urgent
Setting this flag incorrectly can wedge the entire bridge, especially if
the user doesn't actually have any unread urgent messages (so the
official client has nothing to clear...)
2026-07-15 16:04:14 -07:00
Skip R
e2d47d7d32 meow: import beeper fork
[skip ci] for now, will fix lints and checks in a later pass.
2026-07-14 11:36:09 -07:00
Skip R
8bcc39ec3b bump to discordgo e194251224ed and correct all flag logic 2026-07-14 08:36:16 -07:00
Skip R
3f37f0cb58 connector/vitals: remove unnecessary cast 2026-07-14 08:25:11 -07:00
Skip R
96a14ce0ff bump discordgo to 7d8ec59d414a 2026-07-14 08:24:48 -07:00
Skip R
c2cd95e7ba lint 2026-07-13 23:43:00 -07:00
Skip R
5abc733383 connector: comments 2026-07-13 23:41:46 -07:00
Skip R
d052403358 report account vital signals as bridge state, preventing usage when bad 2026-07-13 23:25:49 -07:00
Skip R
8520692901 bump discordgo to f8349f61660f 2026-07-13 18:55:43 -07:00
Skip R
6faac571bb connector: consider news channels to be bridgeable 2026-07-08 09:11:21 -07:00
Skip R
51adda3a87 chatinfo: add readable string case for news channels 2026-07-08 09:11:10 -07:00
Skip R
1ca066c15b auth/error: split field summary logic into method 2026-07-07 20:54:36 -07:00
Skip R
a8ecf2e4b7 connector/login: make errors visible to the user 2026-07-07 20:54:04 -07:00
Skip R
7061d2109f auth/error: add ACCOUNT_COMPROMISED_RESET_PASSWORD error code 2026-07-07 20:43:04 -07:00
Skip R
2541cc2f56 auth/error: add AnyFieldHasError utility 2026-07-07 20:42:43 -07:00
Skip R
aab23b50bf auth/error: check all field errors for the code 2026-07-07 20:25:46 -07:00
Skip R
ab65bb659a auth/machine: test IP verification via SMS 2026-07-07 20:16:58 -07:00
Skip R
c88b84dc84 auth/machine: handle expired IP verification tokens 2026-07-07 20:16:58 -07:00
Skip R
a9fab25784 auth/error: consider InvalidVerificationCode to be a user input error 2026-07-07 20:16:58 -07:00
Skip R
b5dad96026 auth/error: add more codes 2026-07-07 20:16:58 -07:00
Skip R
e491ff6e0c connector/login: wire up phone verification 2026-07-07 20:16:58 -07:00
Skip R
740ca9e29d auth/machine: support IP verification via code sent to phone via SMS 2026-07-07 20:16:58 -07:00
Skip R
f2565dda83 auth: widen criteria for user input error detection
This is to make detection of user input errors more reliable, especially
with phone verification support coming up soon.
2026-07-07 19:50:16 -07:00
Skip R
869e9f2519 auth/machine: support one level of re-entrancy & sequencing more ops 2026-07-07 18:58:18 -07:00
Skip R
3f7fd707df auth: remove unused imports 2026-07-07 18:55:09 -07:00
Skip R
fba984b489 auth: fix static checks 2026-07-07 17:25:52 -07:00
Skip R
02982000be auth/machine: don't expose POST helper 2026-07-07 17:12:35 -07:00
Skip R
3921bb1f1b auth/machine: add captcha tests 2026-07-07 17:12:35 -07:00
Skip R
892aede394 auth/machine: turn into a resumable state machine 2026-07-07 17:12:35 -07:00
Skip R
0d779c477c provisioning: log errors if we fail to sync guild 2026-07-02 13:31:35 -07:00
Skip R
bca69922ba handlediscord: bridge incrementally created guild channels 2026-07-02 13:05:34 -07:00
Skip R
3762fb09f6 doc: elucidate utility of ensurePortal 2026-07-02 13:03:23 -07:00
Skip R
1d50d4b16d client: examine guild bridgedness when reporting channel bridgeability 2026-07-02 13:03:17 -07:00
Skip R
025d24eb6c client: refactor shouldBridgeChannel logic into method 2026-07-02 10:58:59 -07:00
Skip R
5f13d00432 handlediscord,msgconv: handle attachment editing properly
Finally begin assigning stable part IDs when creating message parts.
2026-07-02 05:52:35 -07:00
Skip R
012b9293e5 connector: resync all existing private portals upon connect 2026-07-02 05:18:56 -07:00
Skip R
a4faa82ac6 provisioning: read-lock discordgo state while reading 2026-06-19 21:34:57 -07:00
Skip R
97ae7f687f client: wipe guild settings when receiving a full snapshot
Note: introducing this tentatively, not due to observed behavior.
2026-06-19 21:34:57 -07:00
Skip R
ce30aaa726 client: wipe read states when receiving a full snapshot
Note: introducing this tentatively, not due to observed behavior.
2026-06-19 21:34:57 -07:00
Skip R
e928815109 handlediscord: unify post-READY state handling path
Consistently update in-memory state after connecting.
2026-06-19 21:34:57 -07:00
Skip R
55ef8e6adf client: delete spaces corresponding to guilds the user has left 2026-06-19 20:57:51 -07:00
Skip R
b820bdc214 handlediscord: poke portal backfill when reconnecting
Previously, this would only happen once in the entire process lifetime
of the bridge.
2026-06-19 20:54:37 -07:00
Skip R
184ce82bbe handlediscord: update user cache synchronously
Do not rely on goroutine scheduling.
2026-06-19 20:50:36 -07:00
Skip R
5eb78e86e4 client: RLock the state while we clone private channel data 2026-06-19 20:47:33 -07:00
Skip R
064d71c688 deps: bump discordgo 2026-06-19 20:46:55 -07:00
Skip R
b05860dccb disable per-message-profile hack by default 2026-06-18 22:08:38 -07:00
Skip R
3ef21aabaf msgconv: enforce filename extensions on outgoing voice messages 2026-06-17 23:10:43 -07:00
Gerardo Rodriguez
e8a6e93ce0
msgconv: correctly send OriginalContentType on outgoing attachments (#232)
Co-authored-by: Skip R <tinyslices@gmail.com>
2026-06-16 19:44:58 -07:00
Skip R
41ae5735b9 handlediscord: resync ghosts on READY 2026-06-16 19:07:19 -07:00
Skip R
5457c76a4a handlediscord: update ghost info in response to PRESENCE_UPDATE 2026-06-16 18:27:30 -07:00
Skip R
9ddec5ed24 capabilities: enable aggressive ghost info updates 2026-06-16 18:21:49 -07:00
Skip R
e1adfc5179 auth/machine: implement login location verification 2026-06-16 16:52:37 -07:00
Skip R
c7abb32c2f proxy: add settings to conditionalize proxying for login flows 2026-06-15 10:02:54 -07:00
Skip R
99d88e9cbf transport/tls: drop ALPS when only offering http/1.1
Modern Chrome seems to do this. Observed in testing.
2026-06-12 12:18:58 -07:00
Skip R
3bcefc06e8 connector: always TLS cloak, even without a proxy configured 2026-06-12 11:59:36 -07:00
Skip R
ffe5bc86af migrate to coder WebSocket and apply TLS cloak to gateway 2026-06-12 11:44:30 -07:00
Skip R
f9f8355027 pre-commit 2026-06-12 05:06:09 -07:00
Skip R
2b487d3025 transport: apply TLS impersonation more broadly
* all API REST requests, remoteauth (QR), and auth machine now
  impersonate properly
* gateway dial is still left
2026-06-12 05:03:51 -07:00
Skip R.
185a12a48e
[bridgev2] first pass at proxy support (#228) 2026-06-12 03:05:01 -07:00
Skip R
72a384db3d auth: remove redirection hack 2026-06-10 23:10:05 -07:00
Skip R
cac3a11000 auth: retry on bad MFA creds 2026-06-10 22:25:54 -07:00
Skip R
c12aaa46d3 auth/machine: don't clobber fingerprint when retrying 2026-06-10 22:24:08 -07:00
Skip R
89fc26d839 login: tweak default headers 2026-06-02 18:12:33 -07:00
Skip R
62091bfc2a auth: impersonate chrome 2026-06-02 18:12:20 -07:00
Skip R
91ecfd4d8f login: trim TOTP code input 2026-06-02 18:10:58 -07:00
Skip R
f54ce7f05f deps: add beeper req fork 2026-06-02 17:24:32 -07:00
Skip R
1595e76434 discordauth: send installation id 2026-06-02 17:00:28 -07:00
Radon Rosborough
5d3595eee7 Upgrade mautrix/go 2026-05-19 14:14:03 -07:00
Tulir Asokan
12aea6a534 dependencies: update mautrix-go 2026-05-13 15:09:30 +03:00
Skip R
5f1cbbce5f handlematrix: backport stranger dm prevention from v1 2026-05-13 03:13:02 -07:00
Skip R
4fc08f5fc9 move comment 2026-05-13 01:54:52 -07:00
Skip R
e456929f66 handlediscord: log READY statistics 2026-05-13 01:36:32 -07:00
Skip R
37156a3ba2 connector: log amount of dms synced 2026-05-13 01:36:32 -07:00
Skip R
d1996fbf44 handlediscord: log when we drop messages from unbridged channels
This should make debugging issues in production easier.
2026-05-13 01:36:32 -07:00
Tulir Asokan
a92fe234c1 dependencies: update mautrix-go 2026-05-07 17:12:20 +03:00
Tulir Asokan
98f61ad97a dependencies: update mautrix-go 2026-04-29 12:33:06 +03:00
Skip R
3fb27b7ed5 handlediscord: always log when receiving supplemental READY 2026-04-28 20:47:21 -07:00
Skip R
44360fedf0 handlediscord: log when lazy_private_channels present 2026-04-28 20:44:09 -07:00
Skip R
1ab2788861 deps: bump discordgo 2026-04-28 20:42:13 -07:00
Skip R
d01d6f9edd handlediscord: let DMs create portals 2026-04-28 20:12:19 -07:00
Skip R
ba3df6fec2 login/token: relax token regex 2026-04-28 20:09:50 -07:00
Skip R
6ab8144feb deps: bump discordgo 2026-04-21 19:35:25 -07:00
Skip R
04c90e0875 deps: update to mautrix v0.27.0 and go 1.25
Mautrix bumps the minimum Go version to this version, which is why (I
think) it changed.
2026-04-21 16:28:58 -07:00
Skip R
da6a633470 msgconv/from-discord: inline PerMessageProfileForSender usage
This is removed from mautrix 0.27.0.
2026-04-21 15:08:43 -07:00
Skip R
6ea9ef2013 handlediscord: track event handler panics 2026-04-20 20:48:44 -07:00
Skip R
80a46e1fa7 connector: stop using RecipientIDs
This field is undocumented and the gateway doesn't always send it with
good data.
2026-04-20 20:36:09 -07:00
Skip R
ed7fd2b380 login: initial captcha support 2026-04-14 22:53:10 -07:00
Skip R
7ee769714e auth/error: present form errors in String() 2026-04-14 22:38:55 -07:00
Skip R
81a99e9dda auth/error: define INVALID_LOGIN code 2026-04-14 22:33:02 -07:00
Skip R
85072b04bf remove stale fixme comments 2026-04-14 20:31:45 -07:00
Skip R
f179e50b47 login: strip whitespace from sms code 2026-04-14 20:24:16 -07:00
Skip R
c7d16fb9ca login: implement backup code usage 2026-04-14 20:24:03 -07:00
Skip R
d6d91c415c auth: document how backup codes are presented 2026-04-14 20:23:46 -07:00
Skip R
5d3715341d auth: sketch first pass at bridgev2 login
still no captcha/backup code, etc.
2026-04-14 00:32:41 -07:00
Skip R
ae2321805c provisioning: backport v1 api 2026-04-12 23:13:19 -07:00
Skip R
ecd1fabd37 handlediscord: actually bridge MESSAGE_ACKs from gateway 2026-04-09 00:04:16 -07:00
Skip R
d58edf7b65 handlediscord: respect friend nicknames 2026-04-08 23:53:31 -07:00
Skip R
bef6e4b9e8 auth: add sequence diagram 2026-04-07 21:29:29 -07:00
Skip R
032ac4440b auth: support inspection of form field errors 2026-04-07 20:14:53 -07:00
Skip R
0c28feadea auth: add POST request helper 2026-04-06 23:33:15 -07:00
Skip R
af4cf49217 make method receiver names more consistent 2026-04-06 20:45:01 -07:00
Skip R
d138c12bbd connector: handle live relationship updates
discordgo should really be doing this
2026-03-31 01:13:37 -07:00
Skip R
d20451cea8 analytics: make last send attempt relationship type readable 2026-03-31 01:12:48 -07:00
Skip R
e824270244 analytics: send on auth invalidated
also simplify the call sites a bit
2026-03-31 00:18:52 -07:00
Skip R
36d918670a analytics: send for outgoing DMs
as alluded to in the comment, discord's anti-spam measures seem to weigh
DMs heavily
2026-03-31 00:17:40 -07:00
Skip R
6ccb507298 analytics: send when account requires verification 2026-03-31 00:16:51 -07:00
Skip R
1d77d59fd5 analytics: send when getting CAPTCHA challenge from discord
to help us find out which endpoints like to serve challenges
2026-03-31 00:15:49 -07:00
Skip R
a0a049f527 deps: bump discordgo for RESTResponseHook 2026-03-30 22:13:31 -07:00
Skip R
a62136eae2 auth/machine: handle err 2026-03-27 21:11:11 -07:00
Skip R
26546297bb auth: implement MFA for TOTP, SMS, and backup codes
* create ChallengeHandler interface that clients must implement
* support asking discord to send the user's phone an SMS code
2026-03-27 21:07:59 -07:00
Skip R
04fbf2870b auth: move X-Debug-Options to Personality 2026-03-26 22:21:36 -07:00
Skip R
356b66224e auth: move header constants to their respective files 2026-03-26 22:16:28 -07:00
Skip R
99b25c58f9 auth: add filter to log headers 2026-03-26 22:08:20 -07:00
Skip R
2df7bf7886 auth: comment 2026-03-26 22:08:05 -07:00
Skip R
5dbbe625c7 authtester: support hCaptcha 2026-03-26 21:55:18 -07:00
Skip R
9dc717ff45 auth: add authtester cli program 2026-03-26 21:17:15 -07:00
Skip R
9ac70fd1bd auth: simplify nil slice check 2026-03-26 21:17:15 -07:00
Skip R
754159ed81 auth: add some focused tests 2026-03-26 21:17:15 -07:00
Skip R
65478d4424 auth: set outgoing Content-Type 2026-03-26 21:17:15 -07:00
Skip R
bb787717ee auth: actually log fingerprint if filter permits it 2026-03-26 21:17:15 -07:00
Skip R
8a98bd2b25 auth: fix log message 2026-03-26 21:17:15 -07:00
Skip R
6f148b0bbf auth: actually send x-debug-options 2026-03-26 21:17:15 -07:00
Skip R
a58e88cf93 auth/personality: fix marshal infinite recursion 2026-03-26 21:17:14 -07:00
Skip R
a627d2d510 auth/sensitive: impl unmarshaler 2026-03-26 21:02:43 -07:00
Skip R
d8f3ed3e36 auth: return login response 2026-03-26 20:51:29 -07:00
Skip R
289f92660f auth: add API errors 2026-03-26 20:51:19 -07:00
Skip R
654e3fd898 auth: remove channel 2026-03-26 20:19:24 -07:00
Skip R
82b1645e00 auth: add initial sketch 2026-03-26 20:18:16 -07:00
Skip R
c88ffbc9ba directmedia: reimplement in-memory URL cache 2026-03-23 21:58:05 -07:00
Nick Mills-Barrett
d40b1fc68e
Fix legacy migration comments 2026-03-20 15:11:35 +00:00
Skip R
dd2c2ec0c9 directmedia: sketch implementation 2026-03-20 02:39:40 -07:00
Skip R
744ba4190b msgconv/from-discord: improve look of backfilled user joins 2026-03-18 23:59:25 -07:00
Skip R
8270a9462d handlediscord: bridge live user joins as membership events 2026-03-18 23:58:50 -07:00
Skip R
134c5ed446 capabilities: more explicitly advertise support for all file types 2026-03-18 23:54:55 -07:00
Skip R
b71744a287 chatinfo: support configurable channel name template 2026-03-18 22:43:25 -07:00
Skip R
672f4b8c23 handlediscord: make sure all parent spaces exist before their children
Using `QueueRemoteEvent` to blindly fire off the resyncing of spaces as
well as their child rooms effectively races, resulting in the child
rooms lacking a `network` in their `m.bridge` state.

To fix this, synchronously ensure that the spaces exist before bridging
their children.

This patch also makes it so that empty categories (categories that are
literally empty as well as categories exclusively consisting of channels
that can't be viewed by the user) are no longer bridged.
2026-03-18 22:38:06 -07:00
Skip R
c1b90ef84e downgrade log level of "Computed visibility of guild channel" logs
These are really noisy.
2026-03-18 22:19:50 -07:00
Skip R
f041f78c09 msgconv/from-matrix: support voice messages 2026-03-17 10:20:31 -07:00
Skip R
f796e4cef6 msgconv/from-discord: tweak incoming call message 2026-03-12 19:26:58 -07:00
Skip R
961a89beba msgconv/formatter: escape role and channel names 2026-03-12 18:59:59 -07:00
Skip R
a2c65ee47c msgconv/formatter: fix inbound mention handling
* Check for user logins as well as fetching a ghost. This fixes
  mentioning other Matrix users (when split portals are off), or
  yourself.
* Escape display names.
* Gracefully fail when failing to locate any useful information.
2026-03-12 18:56:54 -07:00
Skip R
1dce5fa9ff msgconv/from-matrix: don't send allowed_mentions for now 2026-03-12 18:18:31 -07:00
Skip R
2b3b058750 msgconv/from-matrix: convert user pills 2026-03-12 18:17:18 -07:00
Skip R
cb9bf5b04b handlediscord: sync group dm membership changes 2026-03-11 19:48:14 -07:00
Skip R
7363538bae sync private channel deletions that occur while the bridge is offline 2026-03-11 19:13:51 -07:00
Skip R
3ae8e8d585 router: log when creating an uncertain route 2026-03-11 19:13:07 -07:00
Skip R
1534b2b984 handlediscord: handle CHANNEL_DELETE 2026-03-11 19:11:51 -07:00
Skip R
6959888bbd msgconv: change animated sticker mime type
This matches with what Beeper Desktop expects.
2026-03-11 17:33:02 -07:00
Skip R
03fb7bc83e handlediscord: sync gateway-created dm channels 2026-03-11 11:16:10 -07:00
Skip R
02b35812ab userinfo: make sure we're logged in 2026-03-10 23:12:10 -07:00
Skip R
1c8bb3531d dependencies: bump discordgo 2026-03-10 23:07:00 -07:00
Skip R
4f2dc2bf30 provisioning: check for logged in state 2026-03-10 23:01:48 -07:00
Skip R
edcad997ac client: don't set a up session if we lack a token entirely 2026-03-10 23:01:24 -07:00
Skip R
cd1f9ddd85 connector: check for nil session before disconnecting 2026-03-10 22:59:28 -07:00
Skip R
51473cf210 connector: check if we're logged out much more 2026-03-10 22:58:40 -07:00
Skip R
2e7e39a91c properly handle our credentials becoming invalidated
Further support cancellation (via concurrent Disconnect call) while
we're "sleeping" in connectRetrying. This is sorta half baked because we
don't handle cancellation post-Open, which should really be added so we
can avoid the sync.
2026-03-10 22:08:35 -07:00
Skip R
261d041ebb client: refuse to connect if token is empty
This is in preparation of us intentionally emptying the token if our
credentials become invalid.
2026-03-10 22:05:52 -07:00
Skip R
a06b09f201 handlediscord: add error constants 2026-03-10 22:04:07 -07:00
Skip R
2cd22e0100 properly handle 40002 errors 2026-03-10 21:04:23 -07:00
Skip R
9719c6d1d5 add proper support for remote profiles 2026-03-06 21:54:15 -08:00
Skip R
833085bd95 usercache: add self user to cache 2026-03-06 19:57:57 -08:00
Skip R
3f5993e6e4 provisioning: deeply delete portals when unbridging 2026-03-05 18:46:31 -08:00
Skip R
0c0aa7e212 handlematrix: implement bridgev2.MuteHandlingNetworkAPI
(When used with Beeper, this is only effectively invoked when the bridge
is being used locally.)
2026-03-05 16:30:43 -08:00
Skip R
1bb9b0c244 chatinfo: bridge discord mute state to matrix
Note that mute_only_on_create in the config seems to default to true.
When that is the case, the remote mute state only makes a difference
during initial portal creation.
2026-03-05 16:29:41 -08:00
Skip R
35dcea1f03 client,handlediscord: keep track of user guild settings
These contain information about mutes, notification settings, etc.
2026-03-05 16:28:21 -08:00
Skip R
a9eccd574d dependencies: bump discordgo
for user guild settings stuff
2026-03-05 16:23:34 -08:00
Skip R
25cf272ca1 events: remove unused field that made the linter mad 2026-03-04 20:51:04 -08:00
Skip R
03d733da42 router: cope with database failures, making routing infallible
Even if the DB is dead for some reason, we should still be able to route
from in-memory data.
2026-03-04 20:47:45 -08:00
Skip R
4c14457c52 lint 2026-03-04 20:43:34 -08:00
Skip R
7e01e8aec3 dbmeta: stash discord channel type in portal metadata 2026-03-04 20:42:02 -08:00
Skip R
55501bf50f handlediscord: avoid queueing remote events for unbridged channels
This avoids the implicit creation of portals that have zero-valued
metadata. Other methods like GetPortalByKey also do this.
2026-03-04 20:42:00 -08:00
Skip R
3c53f7ed2d connector: fix forward backfill heuristic 2026-03-04 20:37:45 -08:00
Skip R
a2933d989b msgconv: do not create stub portals when formatting channel mentions 2026-03-04 20:23:23 -08:00
Skip R
cfe1d24d19 connector: handle receivers correctly and consolidate thread routing
* The split_portals config option is finally respected.
* This should prevent future room duplication when migrating from the
  pre-bridgev2 version of the bridge, as we'll finally address guild
  channels properly (that is, without a Receiver set, unless
  split_portals is true).
2026-03-04 19:15:46 -08:00
Skip R
786a6712fd backfill: implement bridgev2.BackfillingNetworkAPIWithLimits
This lets DMs backfill properly when the user scrolls to the top of the
room in a Beeper client.
2026-03-03 22:18:10 -08:00
Skip R
8e51cf25aa msgconv: populate reply information even when target isn't in db
We can't rely on the reply target to be in the database because that
won't be true during backwards backfill. Populate it more eagerly.
2026-03-03 22:12:49 -08:00
Skip R
c1f82b9157 backfill: set MarkRead if all messages in a batch are read 2026-03-03 21:25:29 -08:00
Skip R
1ead5baf2f client: handle read states 2026-03-03 21:23:49 -08:00
Skip R
5d9e2373d9 msgconv/from-discord: propagate reply log to context 2026-03-03 20:13:12 -08:00
Skip R
28f20d8f62 legacymigrate: drop tables in a different order to avoid fkey violation
The transaction that runs legacymigrate.sql will eventually fail with:

    no such table: main.guild_old

if you drop portal_old without dropping guild_old first. This has
something to do with how the foreign keys and cascading works out on
SQLite's end (especially the self-fk on portal_old).
2026-03-02 15:50:16 -08:00
Skip R
a1ad290a29 ci: set GOTOOLCHAIN again 2026-02-27 07:28:29 -08:00
Skip R
09ace9ce8b ci: bump Go version 2026-02-27 07:24:52 -08:00
Skip R
bc50b3c79d migrate: fixup 2026-02-27 06:44:09 -08:00
Skip R
7e20338ea9 migrate: add comments 2026-02-27 06:44:09 -08:00
Skip R
eae5128f91 lint 2026-02-27 06:44:09 -08:00
Skip R
6ff087ef60 migrate: migrate subset of discord_file to custom_emoji 2026-02-27 06:44:09 -08:00
Skip R
661234452f migrate: init 2026-02-27 06:44:09 -08:00
Skip R
343ae04ea6 handlediscord: simplify threading support 2026-02-27 06:44:09 -08:00
Skip R
55c0272903 handlediscord: initial pass at threads support 2026-02-27 06:44:09 -08:00
Skip R
6a2d8d569e db: add thread migration 2026-02-27 06:44:09 -08:00
Skip R
ea3052e567 handlediscord: sync roles from guilds 2026-02-27 06:44:09 -08:00
Skip R
0e1ff96e62 db: add model for roles 2026-02-27 06:44:09 -08:00
Skip R
fa25e2784a db: init roles 2026-02-27 06:44:02 -08:00
Skip R
1bebafcc3d config: add guild_avatars_in_rooms, disabled by default 2026-02-20 17:38:50 -08:00
Skip R
19cfff24d2 handlediscord: bridge realtime channel updates 2026-02-19 13:24:22 -08:00
Skip R
91f8d2b269 support custom emojis 2026-02-17 19:45:12 -08:00
Skip R
1014576dc0 capabilities: declare support for voice messages 2026-02-16 21:49:38 -08:00
Skip R
dfa9c52974 discordid: make function names more explicit
These are fairly wordy but help maintain correctness. A channel portal
ID should not be confused with a guild portal ID.
2026-02-13 21:19:07 -08:00
Skip R
04c15d15a7 handlediscord: bridge categories with proper parenting 2026-02-13 21:05:35 -08:00
Skip R
66badc0709 handlediscord: bridge channel topics 2026-02-13 18:16:09 -08:00
Skip R
d36528400d handlediscord: don't log upon "unknown" events
Due to how discordgo dispatches events, this would be extraneously
logged for every event received.
2026-02-11 19:36:30 -08:00
Skip R
c80fba31d6 handlediscord: bridge typing 2026-02-11 19:36:22 -08:00
Skip R
aba6f5aafc dbmeta: merge incoming metadata
By not implementing MetaMerger, UserLogin metadata such as
BridgedGuildIDs would get clobbered upon reauthing because the metadata
was replaced entirely. Implement CopyFrom so we can gain deeper control
over what is preserved upon reauth. Notably, preserve BridgedGuildIDs so
we can't get into a weird state where a guild is bridged but
simultaneously absent from BridgedGuildIDs, which would cause us to not
subscribe (OP 14) to it properly.
2026-02-11 19:33:46 -08:00
Skip R
6407a3e3e0 connector: set up provisioning in Start instead of Init
Doing it in Init happens to work for local bridges, but not when
connecting to a remote Matrix homeserver.
2026-02-11 19:23:33 -08:00
Skip R
40ae884e7f connector/client: refactor guild subscription, push log onto context 2026-02-11 19:23:19 -08:00
Skip R
07ba87f9d6 handlediscord: bridge guild delete 2026-02-11 17:42:25 -08:00
Skip R
82aab381ab handlediscord: bridge message edits 2026-02-09 18:04:23 -08:00
Skip R
c8561de9c4 connector: panic with a more useful message when creating nil sender 2026-02-09 15:05:07 -08:00
Skip R
9013e01b49 handlediscord: bridge message deletes 2026-02-09 15:04:47 -08:00
Skip R
7a6f59ad73 handlematrix: bridge message edits 2026-02-09 15:04:33 -08:00
Skip R
2ddba507c2 connector/capabilities: clean up stale comments 2026-02-09 14:34:11 -08:00
Skip R
abcc0dca47 msgconv/from-discord: port sticker conversion 2026-02-09 14:13:54 -08:00
Skip R
2310d2c036 usercache: rename methods
"Update" better expresses what is being done to the cache.
2026-02-06 17:55:46 -08:00
Skip R
1fcc910184 msgconv/from-discord: add per-message profiles 2026-02-06 17:42:51 -08:00
Skip R
808993c174 backfill: update ghosts as we backfill 2026-02-06 17:42:09 -08:00
Skip R
a1d4c4cb28 usercache: return user ids that were updated 2026-02-06 17:41:54 -08:00
Skip R
ce6404ac78 backfill: attach sublogger to context 2026-02-06 17:36:26 -08:00
Skip R
7cfa17023b userinfo: use username as ghost identifier instead of user id
This is more correct.
2026-02-06 15:43:51 -08:00
Skip R
d8ca44ecd9 connector: implement user cache
* Fixes the totally broken UserInfo resolution in guilds.
* Adds support for USER_UPDATE from the gateway.

Design considerations behind the user cache:

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

The user cache is upserted on READY, incoming messages, backfill, etc.
2026-02-06 15:43:51 -08:00
Skip R
c611e8f116 connector: tell discordgo to not track presences/voice state 2026-02-06 13:37:45 -08:00
Skip R
a7ae544999 provisioning: improve compat with beeper desktop 2026-02-05 22:48:36 -08:00
Skip R
4f420c4662 provisioning: preserve logger context 2026-02-05 22:22:13 -08:00
Skip R
4bdb0de559 discordid,connector: remember which guilds were bridged 2026-02-05 22:21:16 -08:00
Skip R
869d8c5412 handlematrix: actually use the qualified emoji when reacting
By accessing reaction.Content.RelatesTo.Key we bypass the work done in
PreHandleMatrixReaction.
2026-02-04 12:44:33 -08:00
Skip R
094bc9bd77 connector: support transaction IDs 2026-02-03 22:03:29 -08:00
Skip R
36c23bef87 dependencies: update discordgo 2026-02-03 22:02:53 -08:00
Skip R
6adf319cfb connector: sync guild spaces via event instead of manually 2026-02-03 21:36:08 -08:00
Skip R
9dfc91ff14 handlematrix: fully qualify reaction emojis 2026-02-03 21:07:51 -08:00
Skip R
47095f1993 connector: instantiate http.Client from bridge settings 2026-02-03 21:00:35 -08:00
Skip R
1900993acd connector/login: remove custom LoadUserLogin
Consolidate how we construct `DiscordClient` by always going through the
connector's `LoadUserLogin` method.
2026-02-03 20:44:38 -08:00
Skip R
2682175508 connector: fetch @me to create login before creating client
Creating the client before the actual UserLogin is bad form.
2026-02-02 22:52:14 -08:00
Skip R
8c02a80f85 connector/login: return browser login method as the first one
clients will prefer it
2026-02-01 21:10:33 -08:00
Skip R
92352ce603 discordid: remove all ID-related casts
In the same vein as mautrix-whatsapp, -slack and others, do not make
assumptions about how the ID is represented in the connector code. Let
the discordid package be entirely responsible.
2026-02-01 21:09:17 -08:00
Tulir Asokan
e7554b212f msgconv/attachments: don't fail if mimeless file has less than 512 bytes 2026-01-28 17:37:34 +02:00
Tulir Asokan
7d26eae8e5 login: fix flow IDs 2026-01-28 17:21:19 +02:00
Tulir Asokan
f3a797d5e5 main: update version number 2026-01-28 17:21:19 +02:00
Tulir Asokan
d89746d099 msgconv: clean up reuploading attachments to Matrix 2026-01-28 17:21:19 +02:00
Skip R
1a3144d2d0 msgconv/from-discord: bridge replies 2026-01-27 22:29:55 -08:00
Skip R
b8a01bf9d4 msgconv/from-discord: use a tagged switch instead of if 2026-01-27 19:12:26 -08:00
Skip R
578030a9dd msgconv/from-discord: only complain about portal when we can't find it 2026-01-27 19:11:57 -08:00
Skip R
2f8de6635a msgconv/from-discord: refactor forwarded message conversion 2026-01-27 19:10:58 -08:00
Skip R
9b3ead7186 doc: add WIP caution to readme 2026-01-26 11:33:13 -08:00
Skip R
138c77c34e provisioning: sketch out implementation
for now, this is completely unauthenticated
2026-01-26 11:33:13 -08:00
Skip R
5e0f9b909a connector: break out guild bridging logic into method 2026-01-26 11:33:13 -08:00
Skip R
b4fdd8b9ed connector: don't crash upon less than 10 private channels 2026-01-26 11:33:13 -08:00
Skip R
689f8b9998 mautrix-go@v0.26.2 2026-01-26 11:33:13 -08:00
Skip R
7849c09443 connector: send bridge state updates on gateway events
This also makes the account properly appear in client settings after
provisioning.
2026-01-20 16:08:16 -08:00
Skip R
5b7a7a430c connector: fix log messages when downloading guild avatars 2026-01-15 15:53:17 -08:00
Skip R
ac338ee722 msgconv: correctly bridge attachments and embeds
`URL` needs to be set if the room/attachment is unencrypted; otherwise,
`File` needs to be set.
2026-01-14 18:07:15 -08:00
Skip R
bdbfd661a2 msgconv: don't set EncryptedFile if there isn't one
FIXME: This probably isn't proper handling, but this is enough to
prevent panics.
2026-01-13 14:50:35 -08:00
Skip R
3d59a0eb3f login/remoteauth: tolerate multiple attempts to cancel
This will otherwise panic.
2026-01-13 14:50:12 -08:00
Skip R
e38998e68b connector: don't store config as pointer 2026-01-13 14:49:15 -08:00
Skip R
f5292e6a7d connector: first pass at bridging guilds
For each guild specified in the config, create a space and bridge all
contained text channels that the user has permissions to view.

* Finally add a custom config struct where we accept a list of guild IDs
  to bridge. This is intended to be temporary as we flesh out the proper
  interfaces for managing which guilds to bridge.
* Defined a custom meta type for portals that holds the containing guild
  ID of the channel (if any).
* Transferred the responsibility of building a channel's ChatInfo and
  ChatMemberList to the DiscordChatResync event itself.
2026-01-09 19:57:17 -08:00
Skip R
86544bc7af relocate dbmeta models to discordid
While we're about to introduce more metadata structs, it seems like
mautrix-slack keeps these in slackid, so let's keep ours in discordid.
2026-01-09 17:13:15 -08:00
Skip R
fdcfb2b083 chore: bump copyright year 2026-01-08 16:56:13 -08:00
Skip R
bfebeeb7e5 handlematrix: bridge outgoing typing events 2026-01-08 16:49:27 -08:00
Skip R
4fb0cdb847 login: relocate shared login finalization logic into embedded struct
All of the login methods need to do (effectively) the same thing once we
have a token, so refactor this out into something we can reuse.
2026-01-07 20:11:04 -08:00
Skip R
b764f489de login: implement logging in via browser 2026-01-07 18:52:52 -08:00
Skip R
8a28fa0f95 connector: share session construction logic
So we can't forget to set up the logging handler.
2026-01-07 18:51:32 -08:00
Skip R
4314aa9206 handlematrix: simplify read receipt bridging 2026-01-06 18:10:50 -08:00
Skip R
761a850a50 handlematrix: bridge outgoing read receipts 2026-01-06 18:10:50 -08:00
Skip R
ca1168bfc2 clean up stray zerolog import 2026-01-06 16:49:10 -08:00
Skip R
e71075cd0d handlematrix: bridge outgoing message attachments 2026-01-05 22:29:26 -08:00
Skip R
cbfbe65619 login/remoteauth: lowercase Errorf 2025-12-18 19:43:30 -08:00
Skip R
c015148b63 login/remoteauth: simplify copy 2025-12-18 19:40:49 -08:00
Skip R
1fb161f379 lint 2025-12-18 19:37:42 -08:00
Skip R
b18d908489 login: implement remoteauth (QR code login) 2025-12-18 19:36:33 -08:00
Skip R
099b464f84 client: refactor boot logic into SetUp method
This method only has heartbeat session population logic for now, so it's
actually a no-op during provisioning. However, there's probably some
value in "mandating" that clients call this shortly after construction,
so we have a chance to run any setup logic that we might need in the
future.

This _feels_ unidiomatic...?
2025-12-18 19:34:50 -08:00
Skip R
2075a4b853 client: don't nil out discordgo.Session when disconnecting
I don't have the logs anymore unfortunately, but I witnessed a
(seemingly?) rare nil dereference _within discordgo's event handling
code_ after Disconnect on the client was called (caused by SIGINT). My
thinking is that this caused the Session to get garbage collected, so
the method receiver became nil out from under it.

To let discordgo clean up after itself, keep a reference to it in the
client.
2025-12-18 19:31:40 -08:00
Skip R
776ddd7c96 login: make complete step id a constant 2025-12-18 19:31:30 -08:00
Skip R
2c669413cc login/token: remove misleading comment about LoadUserLogin
This comment seems to imply that specifying this function is a shortcut
of sorts, when it's actually required and mautrix-go doesn't fallback to
the connector in the way described.
2025-12-18 19:30:34 -08:00
Skip R
0c82f6551d login: move token login to own file, rename
To make room for other login flows.
2025-12-18 18:28:19 -08:00
Skip R
d79406e05b handlematrix: clean up stale comment 2025-12-17 19:17:36 -08:00
Skip R
7a19f09683 handlematrix: bridge outgoing message redactions 2025-12-17 19:04:54 -08:00
Skip R
e030c9548c handlematrix: bridge outgoing reactions 2025-12-17 18:51:36 -08:00
Skip R
2cacd4ec81 msgconv: bridge outgoing replies 2025-12-17 18:23:08 -08:00
Skip R
09414cb59d handlediscord: drop messages lacking an author
DiscordMessage's GetSender is dereferencing nil sometimes and I'm not
sure why.
2025-12-17 18:04:37 -08:00
Skip R
d82b74fb29 handlematrix: handle basic matrix rich text messages
Added the necessary room capabilities, too. Support for replies,
editing, deletion, and attachments are forthcoming.
2025-12-16 18:37:35 -08:00
Skip R
60171b4fca lint 2025-12-11 19:56:31 -08:00
Skip R
ab82f8b131 backfill: document why we aren't backfilling reactions (for now) 2025-12-11 19:50:25 -08:00
Skip R
506f42f93b bridge basic emoji reactions from gateway to matrix 2025-12-11 19:24:19 -08:00
Skip R
25b73bd7cb handlediscord: bail if we're in the middle of provisioning 2025-12-11 18:34:47 -08:00
Skip R
d464cb8b66 bridge discord messages to matrix 2025-12-11 18:22:36 -08:00
Skip R
7b32aad13f attachments: remove trace logging
This was for debugging in development only. Also, bridges default to the
DEBUG level, so this would not be logged at all unless you intentionally
modified your logging configuration.
2025-11-26 18:29:00 -08:00
Skip R
b5e6db06f8 msgconv: port most of attachment and text message bridging
* Created a separate discordid package to avoid import cycles.
* Implemented attachment bridging. We still need to implement direct
  media, but this will do for now.
* Corrected how encrypted files (e.g. embed images and attachments) were
  bridged. Previously, the URL field would be empty.

Still a lot of missing pieces. Thoughts:

* Mentions to roles and custom emoji are not rendered properly. We need
  to maintain our own DB.
* We might not need the "attachments" leaf package anymore? It's just
  there to avoid an import cycle.

Bridging actual events (i.e. wiring up discordgo's event handlers) is
probably next.
2025-11-26 18:13:07 -08:00
Skip R
86e18c1f7d msgconv: port the majority of embed and attachment bridging
We still need to implement direct media support, so for now we encrypt
and reupload to Matrix. Notably remaining is conversion to HTML.
2025-11-26 15:18:29 -08:00
Skip R
17fed9aca5 msgconv: initial impl of ToMatrix, porting convertDiscordTextMessage 2025-11-25 18:01:31 -08:00
Skip R
66d9ca6394 backfill: set ConvertedMessagePart.Type for call/guild join 2025-11-25 16:19:12 -08:00
Skip R
45dae8fafb backfill: set ConvertedMessagePart.Type 2025-11-25 16:10:31 -08:00
Skip R
5fa9645012 connector/id: actually use user as UserLogin 2025-11-25 16:10:04 -08:00
Skip R
114df5f2a2 tidy 2025-11-25 15:09:35 -08:00
Skip R
31c1cdda0c connector: sort recent private channels properly 2025-11-25 15:09:20 -08:00
Skip R
56f05bc02c backfill: make messages actually have IDs 2025-11-25 15:08:33 -08:00
Skip R
f8b65fe1f0 clarify comment 2025-11-25 14:31:42 -08:00
Skip R
61ef0c1051 connector: limit the amount of private channels initially synced
Otherwise, we'll hit the ratelimit pretty easily.
2025-11-25 14:28:28 -08:00
Skip R
ab68fae8dd connector: fix UserLogin lifecycle during provisioning
Bridge provisioning would crash because we wouldn't thread the necessary
database models through.
2025-11-25 14:27:36 -08:00
Skip R
063b9d00dd connector/login: rename variable 2025-11-25 13:49:38 -08:00
Skip R
8c8f029e11 connector: do not forward backfill empty rooms
We'll quickly hit ratelimits if we have a bunch of empty rooms, since
forward backfilling apparently doesn't go through the queue.
2025-11-25 13:49:02 -08:00
Skip R
ae98d58dbe connector: set CanBackfill on rooms 2025-11-25 13:48:48 -08:00
Skip R
c15fd3fc82 implement naive, incomplete backfill 2025-11-25 11:10:20 -08:00
Skip R
aecc5234e6 remove test aggressive updates 2025-11-25 11:09:31 -08:00
Skip R
1c599a33bc add aggressive info updating for dev 2025-11-24 13:23:46 -08:00
Skip R
91edeb6054 connector/userinfo: implement user avatars 2025-11-24 13:23:29 -08:00
Skip R
1442b356f2 sync channel avatars 2025-11-24 13:08:02 -08:00
Skip R
f04a8658d9 always add self when creating DM portals
Clients will leave rooms automatically when they realize that they
aren't actually members of the channel.
2025-11-24 13:07:24 -08:00
Skip R
4e41c2f227 sync private channels and their members 2025-11-24 12:42:27 -08:00
Skip R
bc13724b0a use zerolog/local loggers instead of zerolog/log 2025-11-24 11:15:58 -08:00
Skip R
586cb2bfe6 initial pass at wiring up login to discordgo 2025-11-24 11:04:40 -08:00
Skip R
e0e18d7822 connector/config: impl 2025-11-24 10:27:41 -08:00
Tulir Asokan
cda3a84ea5 ci: fix lint job 2025-11-20 10:18:03 +02:00
Tulir Asokan
fed9bc7655 Set correct values for GetName 2025-11-20 10:12:52 +02:00
Tulir Asokan
4f1ae630fc Merge branch 'main' into v2 2025-11-20 10:11:42 +02:00
Tulir Asokan
0a7b8bf41b all: init v2 and delete old bridge 2024-08-15 16:44:23 +03:00
Tulir Asokan
64c92ca783 Add icon for IDEA 2024-08-15 15:51:22 +03:00
202 changed files with 36910 additions and 13468 deletions

View file

@ -11,5 +11,8 @@ insert_final_newline = true
[*.{yaml,yml,sql}]
indent_style = space
[*.html]
indent_size = 2
[.gitlab-ci.yml]
indent_size = 2

View file

@ -2,6 +2,9 @@ name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
@ -23,13 +26,12 @@ jobs:
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Install goimports
- name: Install dependencies
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/beeper/pre-commit-go/bridgelint@latest
export PATH="$HOME/go/bin:$PATH"
- name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a
- name: Run pre-commit
uses: pre-commit/action@v3.0.1

View file

@ -17,7 +17,7 @@ jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@v5
id: lock
with:
issue-inactive-days: 90

2
.gitignore vendored
View file

@ -7,3 +7,5 @@
/mautrix-discord
/start
.env

View file

@ -1,3 +1,3 @@
include:
- project: 'mautrix/ci'
file: '/go.yml'
file: '/gov2-as-default.yml'

16
.idea/icon.svg generated Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 128 128"
version="1.1"
id="svg4"
width="128"
height="128"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<path
fill="#5865f2"
d="m 108.12978,23.89 a 105.15,105.15 0 0 0 -26.230005,-8.07 72.06,72.06 0 0 0 -3.36,6.83 97.68,97.68 0 0 0 -29.11,0 72.37,72.37 0 0 0 -3.36,-6.83 105.89,105.89 0 0 0 -26.25,8.09 C 3.2197751,48.47 -1.2802249,72.42 0.96977514,96.03 v 0 a 105.73,105.73 0 0 0 32.16999986,16.15 77.7,77.7 0 0 0 6.89,-11.11 68.42,68.42 0 0 1 -10.85,-5.18 c 0.91,-0.66 1.8,-1.34 2.66,-2 a 75.57,75.57 0 0 0 64.32,0 c 0.87,0.71 1.76,1.39 2.66,2 a 68.68,68.68 0 0 1 -10.87,5.19 77,77 0 0 0 6.89,11.1 105.25,105.25 0 0 0 32.190005,-16.14 v 0 c 2.64,-27.38 -4.51,-51.11 -18.9,-72.15 z M 42.879775,81.51 c -6.27,0 -11.45,-5.69 -11.45,-12.69 0,-7 5,-12.74 11.43,-12.74 6.43,0 11.57,5.74 11.46,12.74 -0.11,7 -5.05,12.69 -11.44,12.69 z m 42.24,0 c -6.28,0 -11.44,-5.69 -11.44,-12.69 0,-7 5,-12.74 11.44,-12.74 6.44,0 11.54,5.74 11.43,12.74 -0.11,7 -5.04,12.69 -11.43,12.69 z"
id="path2" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -12,9 +12,18 @@ repos:
rev: v1.0.0-rc.4
hooks:
- id: go-imports-repo
args:
- "-local"
- "go.mau.fi/mautrix-discord"
- "-w"
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.5.0
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer
- id: bridgelint
language: system
verbose: true

View file

@ -1,22 +1,3 @@
# v0.7.7 (2026-08-16)
* Updated Docker image to Alpine 3.24.
* Added option to disable DMs to non-friends.
* Fixed panic if editing a message fails.
* Fixed websocket pings when using bot accounts on Discord.
# 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.

View file

@ -14,9 +14,7 @@ ENV UID=1337 \
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
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"]

View file

@ -7,9 +7,7 @@ RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottiec
ARG EXECUTABLE=./mautrix-discord
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"]

12
LICENSE.exceptions Normal file
View file

@ -0,0 +1,12 @@
The mautrix-discord developers grant the following special exceptions:
* to Beeper the right to embed the program in the Beeper clients and servers,
and use and distribute the collective work without applying the license to
the whole.
* to Element the right to distribute compiled binaries of the program as a part
of the Element Server Suite and other server bundles without applying the
license.
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-discord remain publicly available under the terms
of the GNU AGPL version 3 or later.

View file

@ -1,4 +1,12 @@
# mautrix-discord
> [!CAUTION]
> This branch houses a work-in-progress rewrite of the bridge to interface with
> [Megabridge/"bridgev2"][bridgev2]. This branch is **NOT** ready for general
> consumption, especially for self-hosting.
[bridgev2]: https://github.com/mautrix/go/tree/38278ef37d199d3a9deba04b825a094eea6c1d10/bridgev2/unorganized-docs
A Matrix-Discord puppeting bridge based on [discordgo](https://github.com/bwmarrin/discordgo).
## Documentation

View file

@ -1,24 +1,24 @@
# Features & roadmap
* Matrix → Discord
* [ ] Message content
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Custom emojis
* [x] Message redactions
* [x] Reactions
* [x] Unicode emojis
* [ ] Message redactions
* [ ] Reactions
* [ ] Unicode emojis
* [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works)
* [ ] Executing Discord bot commands
* [x] Basic arguments and subcommands
* [ ] Basic arguments and subcommands
* [ ] Subcommand groups
* [ ] Mention arguments
* [ ] Attachment arguments
* [ ] Presence
* [x] Typing notifications
* [x] Own read status
* [ ] Typing notifications
* [ ] Own read status
* [ ] Power level
* [ ] Membership actions
* [ ] Invite
@ -31,37 +31,37 @@
* [ ] Initial room metadata
* Discord → Matrix
* [ ] Message content
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [x] Auto-joining threads when opening
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Auto-joining threads when opening
* [ ] Backfilling threads after joining
* [x] Custom emojis
* [x] Embeds
* [ ] Custom emojis
* [ ] Embeds
* [ ] Interactive components
* [x] Interactions (commands)
* [x] @everyone/@here mentions into @room
* [x] Message deletions
* [x] Reactions
* [x] Unicode emojis
* [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [x] Avatars
* [ ] Interactions (commands)
* [ ] @everyone/@here mentions into @room
* [ ] Message deletions
* [ ] Reactions
* [ ] Unicode emojis
* [ ] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [ ] Avatars
* [ ] Presence
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
* [x] Own read status
* [ ] Own read status
* [ ] Role permissions
* [ ] Membership actions
* [ ] Invite
* [ ] Join
* [ ] Leave
* [ ] Kick
* [x] Channel/group DM metadata changes
* [x] Title
* [x] Avatar
* [x] Description
* [x] Initial channel/group DM metadata
* [ ] Channel/group DM metadata changes
* [ ] Title
* [ ] Avatar
* [ ] Description
* [ ] Initial channel/group DM metadata
* [ ] User metadata changes
* [ ] Display name
* [ ] Avatar
@ -69,11 +69,12 @@
* [ ] Display name
* [ ] Avatar
* Misc
* [x] Login methods
* [x] QR scan from mobile
* [x] Manually providing access token
* [x] Automatic portal creation
* [x] After login
* [x] When receiving DM
* [ ] Login methods
* [ ] QR scan from mobile
* [ ] Username/password
* [ ] Manually providing access token
* [ ] Automatic portal creation
* [ ] After login
* [ ] When receiving DM
* [ ] Private chat creation by inviting Matrix puppet of Discord user to new room
* [x] Option to use own Matrix account for messages sent from other Discord clients
* [ ] Option to use own Matrix account for messages sent from other Discord clients

View file

@ -1,353 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for key, value := range discordgo.DroidDownloadHeaders {
req.Header.Set(key, value)
}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
}
if resp.Header.Get("Content-Length") != "" {
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse content length: %w", err)
} else if length > maxSize {
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
}
return io.ReadAll(resp.Body)
} else {
var mbe *http.MaxBytesError
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
if err != nil && errors.As(err, &mbe) {
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
}
return data, err
}
}
func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return err
}
for key, value := range discordgo.DroidBaseHeaders {
req.Header.Set(key, value)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Referer", "https://discord.com/")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := cli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
respData, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData)
}
return nil
}
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
if content.File != nil {
file = content.File
rawMXC = file.URL
}
mxc, err := rawMXC.Parse()
if err != nil {
return nil, err
}
data, err := intent.DownloadBytes(mxc)
if err != nil {
return nil, err
}
if file != nil {
err = file.DecryptInPlace(data)
if err != nil {
return nil, err
}
}
return data, nil
}
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now()
dbFile.URL = url
dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String()
if meta.MimeType == "" {
meta.MimeType = dbFile.MimeType
}
if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width
dbFile.Height = cfg.Height
}
uploadMime := meta.MimeType
if encrypt {
dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
dbFile.DecryptionInfo.EncryptInPlace(data)
uploadMime = "application/octet-stream"
}
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: uploadMime,
}
if br.Config.Homeserver.AsyncMedia {
resp, err := intent.CreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.MXC = resp.ContentURI
req.UnstableUploadURL = resp.UnstableUploadURL
semaWg.Add(1)
go func() {
defer semaWg.Done()
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
dbFile.Delete()
}
}()
} else {
uploaded, err := intent.UploadMedia(req)
if err != nil {
return nil, err
}
dbFile.MXC = uploaded.ContentURI
}
return dbFile, nil
}
type AttachmentMeta struct {
AttachmentID string
MimeType string
EmojiName string
CopyIfMissing bool
Converter func([]byte) ([]byte, string, error)
}
var NoMeta = AttachmentMeta{}
type attachmentKey struct {
URL string
Encrypt bool
}
func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
fps := br.Config.Bridge.AnimatedSticker.Args.FPS
width := br.Config.Bridge.AnimatedSticker.Args.Width
height := br.Config.Bridge.AnimatedSticker.Args.Height
target := br.Config.Bridge.AnimatedSticker.Target
var lottieTarget, outputMime string
switch target {
case "png":
lottieTarget = "png"
outputMime = "image/png"
fps = 1
case "gif":
lottieTarget = "gif"
outputMime = "image/gif"
case "webm":
lottieTarget = "pngs"
outputMime = "video/webm"
case "webp":
lottieTarget = "pngs"
outputMime = "image/webp"
case "disable":
return data, "application/json", nil
default:
return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target)
}
ctx := context.Background()
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
if err != nil {
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() {
removErr := os.RemoveAll(tempdir)
if removErr != nil {
br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr)
}
}()
lottieOutput := filepath.Join(tempdir, "out_")
if lottieTarget != "pngs" {
lottieOutput = filepath.Join(tempdir, "output."+lottieTarget)
}
cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps))
cmd.Stdin = bytes.NewReader(data)
err = cmd.Run()
if err != nil {
return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err)
}
var path string
if lottieTarget == "pngs" {
var videoCodec string
outputExtension := "." + target
if target == "webm" {
videoCodec = "libvpx-vp9"
} else if target == "webp" {
videoCodec = "libwebp_anim"
} else {
panic(fmt.Errorf("impossible case: unknown target %q", target))
}
path, err = ffmpeg.ConvertPath(
ctx, lottieOutput+"*.png", outputExtension,
[]string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"},
[]string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target},
false,
)
if err != nil {
return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err)
}
} else {
path = lottieOutput
}
data, err = os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("failed to read converted file: %w", err)
}
return data, outputMime, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{})
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
if isCacheable {
onceDBFile = br.DB.File.Get(url, encrypt)
if onceDBFile != nil {
return
}
}
const attachmentSizeVal = 1
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
cancel()
if onceErr != nil {
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
onceErr = fmt.Errorf("reuploading timed out")
return
}
var semaWg sync.WaitGroup
semaWg.Add(1)
defer semaWg.Done()
go func() {
semaWg.Wait()
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
}()
var data []byte
data, onceErr = downloadDiscordAttachment(http.DefaultClient, url, br.MediaConfig.UploadSize)
if onceErr != nil {
return
}
if meta.Converter != nil {
data, meta.MimeType, onceErr = meta.Converter(data)
if onceErr != nil {
onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr)
return
}
}
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg)
if onceErr != nil {
return
}
if isCacheable {
onceDBFile.Insert(nil)
}
br.attachmentTransfers.Delete(transferKey)
return
})
}
return
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated)
if !mxc.IsEmpty() {
return mxc
}
var url, mimeType string
if animated {
url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,
MimeType: mimeType,
EmojiName: name,
})
if err != nil {
portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
return id.ContentURI{}
}
return dbFile.MXC
}

View file

@ -1,383 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"sort"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) {
log := portal.log
defer func() {
log.Debug().Msg("Forward backfill finished, unlocking lock")
portal.forwardBackfillLock.Unlock()
}()
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
if portal.forwardBackfillLock.TryLock() {
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread
thread.initialBackfillAttempted = true
}
}
if limit == 0 {
return
}
with := log.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log = with.Logger()
portal.backfillLimited(log, source, limit, "", thread)
}
func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) {
if portal.MXID == "" {
return
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread
}
}
if limit == 0 {
return
}
with := portal.log.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log := with.Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
var lastMessage *database.Message
if thread != nil {
lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
} else {
lastMessage = portal.bridge.DB.Message.GetLast(portal.Key)
}
if lastMessage == nil || serverLastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Not backfilling, last message in database is newer than last message in metadata")
return
}
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
if err != nil {
return nil, false, err
}
if until != "" {
for i, msg := range newMessages {
if compareMessageIDs(msg.ID, until) <= 0 {
log.Debug().
Str("message_id", msg.ID).
Str("until_id", until).
Msg("Found message that was already bridged")
newMessages = newMessages[:i]
foundAll = true
break
}
}
}
messages = append(messages, newMessages...)
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
break
}
before = newMessages[len(newMessages)-1].ID
}
if len(messages) > limit {
foundAll = false
messages = messages[:limit]
}
return messages, foundAll, nil
}
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread)
if err != nil {
if source.handlePossible40002(err) {
panic(err)
}
log.Err(err).Msg("Error collecting messages to forward backfill")
return
}
log.Info().
Int("count", len(messages)).
Bool("found_all", foundAll).
Msg("Collected messages to backfill")
sort.Sort(MessageSlice(messages))
if !foundAll && after != "" {
_, err = portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Some messages may have been missed here while the bridge was offline.",
}, nil, 0)
if err != nil {
log.Warn().Err(err).Msg("Failed to send missed message warning")
} else {
log.Debug().Msg("Sent warning about possibly missed messages")
}
}
portal.sendBackfillBatch(log, source, messages, thread)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) {
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
if err != nil {
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
return
}
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
sort.Sort(MessageSlice(messages))
portal.sendBackfillBatch(log, source, messages, thread)
if len(messages) < messageFetchChunkSize {
// Assume that was all the missing messages
log.Debug().Msg("Chunk had less than 50 messages, stopping backfill")
return
}
after = messages[len(messages)-1].ID
}
}
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages, thread)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, thread)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread)
if len(evts) == 0 {
log.Warn().Msg("Didn't get any events to backfill")
return
}
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{
Forward: true,
Events: evts,
})
if err != nil {
log.Err(err).Msg("Error sending backfill batch")
return
}
for i, evtID := range resp.EventIDs {
dbMessages[i].MXID = evtID
if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread {
// TODO proper context
ctx := log.WithContext(context.Background())
portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread)
}
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
}
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) {
var discordThreadID string
var threadRootEvent, lastThreadEvent id.EventID
if thread != nil {
discordThreadID = thread.ID
threadRootEvent = thread.RootMXID
lastThreadEvent = threadRootEvent
lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
if lastInThread != nil {
lastThreadEvent = lastInThread.MXID
}
}
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.Message, 0, len(messages))
metas := make([]*discordgo.Message, 0, len(messages))
ctx := context.Background()
for _, msg := range messages {
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
puppet.UpdateInfo(nil, mention, nil)
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author, msg)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true)
mentions := portal.convertDiscordMentions(msg, false)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
log := log.With().
Str("message_id", msg.ID).
Int("message_type", int(msg.Type)).
Str("author_id", msg.Author.ID).
Logger()
parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg)
for i, part := range parts {
if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
part.Content.RelatesTo = &event.RelatesTo{}
}
if threadRootEvent != "" {
part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
}
if replyTo != nil {
part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
// Only set reply for first event
replyTo = nil
}
part.Content.Mentions = mentions
// Only set mentions for first event, but keep empty object for rest
mentions = &event.Mentions{}
partName := part.AttachmentID
// Always use blank part name for first part so that replies and other things
// can reference it without knowing about attachments.
if i == 0 {
partName = ""
}
evt := &event.Event{
ID: portal.deterministicEventID(msg.ID, partName),
Type: part.Type,
Sender: intent.UserID,
Timestamp: ts.UnixMilli(),
Content: event.Content{
Parsed: part.Content,
Raw: part.Extra,
},
}
var err error
evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type)
if err != nil {
log.Err(err).Msg("Failed to encrypt event")
continue
}
intent.AddDoublePuppetValue(&evt.Content)
evts = append(evts, evt)
dbMessages = append(dbMessages, database.Message{
Channel: portal.Key,
DiscordID: msg.ID,
SenderID: msg.Author.ID,
Timestamp: ts,
AttachmentID: part.AttachmentID,
SenderMXID: intent.UserID,
})
if i == 0 {
metas = append(metas, msg)
} else {
metas = append(metas, nil)
}
lastThreadEvent = evt.ID
}
}
return evts, metas, dbMessages
}
func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID {
data := fmt.Sprintf("%s/discord/%s/%s", portal.MXID, messageID, partName)
sum := sha256.Sum256([]byte(data))
return id.EventID(fmt.Sprintf("$%s:discord.com", base64.RawURLEncoding.EncodeToString(sum[:])))
}
// compareMessageIDs compares two Discord message IDs.
//
// If the first ID is lower, -1 is returned.
// If the second ID is lower, 1 is returned.
// If the IDs are equal, 0 is returned.
func compareMessageIDs(id1, id2 string) int {
if id1 == id2 {
return 0
}
if len(id1) < len(id2) {
return -1
} else if len(id2) < len(id1) {
return 1
}
if id1 < id2 {
return -1
}
return 1
}
func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool {
return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1
}
type MessageSlice []*discordgo.Message
var _ sort.Interface = (MessageSlice)(nil)
func (a MessageSlice) Len() int {
return len(a)
}
func (a MessageSlice) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a MessageSlice) Less(i, j int) bool {
return compareMessageIDs(a[i].ID, a[j].ID) == -1
}

View file

@ -1,2 +1,4 @@
#!/bin/sh
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@"

579
cmd/authtester/main.go Normal file
View file

@ -0,0 +1,579 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"maps"
"net/http"
"os"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/google/uuid"
"github.com/rs/zerolog"
"golang.org/x/term"
"go.mau.fi/util/exhttp"
"go.mau.fi/mautrix-discord/pkg/discordauth"
"go.mau.fi/mautrix-discord/pkg/discordtransport"
)
const fallbackClientBuildNumber = 497254
var mainJSRegex = regexp.MustCompile(`src="(/assets/web\.[a-f0-9]{12,32}\.js)"`)
var buildNumberRegex = regexp.MustCompile(`(?:buildNumber|build_number):\s?['"]?(\d{6,})['"]?`)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var buildNumberFlag int
var apiBase string
var verbose bool
flag.IntVar(&buildNumberFlag, "build-number", 0, "Discord client build number (default: auto-detect from discord.com)")
flag.StringVar(&apiBase, "api-base", "https://discord.com/api/v9", "Discord API base URL")
flag.BoolVar(&verbose, "verbose", false, "Lower the log level to debug")
flag.Parse()
log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).
Level(zerolog.InfoLevel).
With().
Timestamp().
Logger()
if verbose {
log = log.Level(zerolog.DebugLevel)
}
ctx, stop := signal.NotifyContext(log.WithContext(context.Background()), os.Interrupt, syscall.SIGTERM)
defer stop()
client, err := discordtransport.CompileTransport(exhttp.SensibleClientSettings, discordtransport.TransportOptions{})
if err != nil {
return fmt.Errorf("failed to create auth http client: %w", err)
}
captchaServer := newCaptchaServer(log.With().Str("component", "authtester captcha").Logger())
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := captchaServer.Close(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "Failed to gracefully terminate CAPTCHA server: %v\n", err)
}
}()
prompter := newPrompter(os.Stdin, os.Stdout, captchaServer)
buildNumber := buildNumberFlag
if buildNumber == 0 {
fmt.Fprintln(os.Stdout, "Detecting an appropriate Discord client build number...")
buildNumber, err = fetchClientBuildNumber(ctx, client)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to detect build number automatically: %v\n", err)
fmt.Fprintf(os.Stderr, "Falling back to build number %d\n", fallbackClientBuildNumber)
buildNumber = fallbackClientBuildNumber
}
}
fmt.Fprintf(os.Stdout, "Using client build number %d\n", buildNumber)
personality, err := newDefaultPersonality(buildNumber)
if err != nil {
return fmt.Errorf("failed to create auth personality: %w", err)
}
machine := discordauth.NewAuthMachine(ctx, client, personality)
machine.APIBase = apiBase
fmt.Fprintln(os.Stdout, "Preparing Discord auth...")
if err = machine.Prepare(ctx); err != nil {
return fmt.Errorf("failed to prepare auth machine: %w", err)
}
fmt.Fprintln(os.Stdout, "Logging in...")
resp, err := prompter.driveAuthMachine(ctx, machine)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
fmt.Fprintln(os.Stdout, "Login succeeded.")
fmt.Fprintf(os.Stdout, "User ID: %s\n", resp.UserID)
fmt.Fprintf(os.Stdout, "Token length: %d\n", len(resp.Token.UnwrapSensitive()))
if resp.UserSettings.Locale != "" {
fmt.Fprintf(os.Stdout, "User locale: %s\n", resp.UserSettings.Locale)
}
if resp.UserSettings.Theme != "" {
fmt.Fprintf(os.Stdout, "User theme: %s\n", resp.UserSettings.Theme)
}
return nil
}
func newDefaultPersonality(buildNumber int) (*discordauth.Personality, error) {
launchSignature, err := discordgo.NewVanillaSignature()
if err != nil {
return nil, fmt.Errorf("failed to generate launch signature: %w", err)
}
extraHeaders := maps.Clone(discordgo.DroidFetchHeaders)
delete(extraHeaders, "User-Agent")
return &discordauth.Personality{
UserAgent: discordgo.DroidBrowserUserAgent,
Locale: "en-US",
TimeZone: defaultTimeZone(),
DebugOptions: discordauth.DefaultDebugOptions,
SuperProperties: discordauth.SuperProperties{
OS: "Windows",
Browser: "Chrome",
SystemLocale: "en-US",
HasClientMods: false,
BrowserUserAgent: discordgo.DroidBrowserUserAgent,
BrowserVersion: discordgo.DroidBrowserVersion,
OSVersion: "10",
ReleaseChannel: "stable",
ClientBuildNumber: buildNumber,
ClientLaunchID: uuid.NewString(),
LaunchSignature: launchSignature,
ClientAppState: "focused",
},
ExtraHeaders: extraHeaders,
}, nil
}
func defaultTimeZone() string {
timeZone := time.Now().Location().String()
if timeZone == "" || timeZone == "Local" {
return "UTC"
}
return timeZone
}
func fetchClientBuildNumber(ctx context.Context, client *http.Client) (int, error) {
mainPageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/channels/@me", nil)
if err != nil {
return 0, fmt.Errorf("failed to create main page request: %w", err)
}
addHeaders(mainPageReq.Header, discordgo.DroidBaseHeaders)
mainPageReq.Header.Set("Sec-Fetch-Dest", "document")
mainPageReq.Header.Set("Sec-Fetch-Mode", "navigate")
mainPageReq.Header.Set("Sec-Fetch-Site", "none")
mainPageReq.Header.Set("Sec-Fetch-User", "?1")
mainPageReq.Header.Set("Upgrade-Insecure-Requests", "1")
mainPageReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
mainPageData, err := doRequest(ctx, client, mainPageReq)
if err != nil {
return 0, fmt.Errorf("failed to fetch main page: %w", err)
}
mainJSMatch := mainJSRegex.FindSubmatch(mainPageData)
if mainJSMatch == nil {
return 0, fmt.Errorf("failed to find main JS URL in Discord main page")
}
jsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com"+string(mainJSMatch[1]), nil)
if err != nil {
return 0, fmt.Errorf("failed to create JS request: %w", err)
}
addHeaders(jsReq.Header, discordgo.DroidBaseHeaders)
jsReq.Header.Set("Sec-Fetch-Dest", "script")
jsReq.Header.Set("Sec-Fetch-Mode", "no-cors")
jsReq.Header.Set("Sec-Fetch-Site", "same-origin")
jsReq.Header.Set("Accept", "*/*")
jsData, err := doRequest(ctx, client, jsReq)
if err != nil {
return 0, fmt.Errorf("failed to fetch main JS: %w", err)
}
buildNumberMatch := buildNumberRegex.FindSubmatch(jsData)
if buildNumberMatch == nil {
return 0, fmt.Errorf("failed to find build number in Discord JS bundle")
}
buildNumber, err := strconv.Atoi(string(buildNumberMatch[1]))
if err != nil {
return 0, fmt.Errorf("failed to parse build number %q: %w", buildNumberMatch[1], err)
}
return buildNumber, nil
}
func doRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read %s %s response body: %w", req.Method, req.URL, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %s for %s %s", resp.Status, req.Method, req.URL)
}
if err := ctx.Err(); err != nil {
return nil, err
}
return body, nil
}
func addHeaders(header http.Header, values map[string]string) {
for key, value := range values {
header.Set(key, value)
}
}
type prompter struct {
in *bufio.Reader
inFile *os.File
out io.Writer
captchaServer *captchaServer
mfaChallenge *discordauth.LoginMFARequired
}
type mfaMethodOption struct {
Type discordauth.AuthenticatorType
Label string
CodePrompt string
}
func newPrompter(in io.Reader, out io.Writer, captchaServer *captchaServer) *prompter {
file, _ := in.(*os.File)
return &prompter{
in: bufio.NewReader(in),
inFile: file,
out: out,
captchaServer: captchaServer,
}
}
func (p *prompter) promptRequired(label string) (string, error) {
value, err := p.prompt(label)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("%s is required", strings.ToLower(label))
}
return value, nil
}
func (p *prompter) prompt(label string) (string, error) {
fmt.Fprintf(p.out, "%s: ", label)
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
if errors.Is(err, io.EOF) && len(line) == 0 {
return "", io.EOF
}
return strings.TrimRight(line, "\r\n"), nil
}
func (p *prompter) promptSecretRequired(label string) (string, error) {
value, err := p.promptSecret(label)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("%s is required", strings.ToLower(label))
}
return value, nil
}
func (p *prompter) promptSecret(label string) (string, error) {
if p.inFile == nil || !term.IsTerminal(int(p.inFile.Fd())) {
return p.prompt(label)
}
fmt.Fprintf(p.out, "%s: ", label)
line, err := term.ReadPassword(int(p.inFile.Fd()))
fmt.Fprintln(p.out)
if err != nil {
return "", err
}
return strings.TrimRight(string(line), "\r\n"), nil
}
func (p *prompter) promptMFAChoice(options []mfaMethodOption) (mfaMethodOption, error) {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Available MFA methods:")
for i, option := range options {
fmt.Fprintf(p.out, " %d. %s\n", i+1, option.Label)
}
for {
choice, err := p.promptRequired("Choose MFA method")
if err != nil {
return mfaMethodOption{}, err
}
index, err := strconv.Atoi(choice)
if err == nil && index >= 1 && index <= len(options) {
return options[index-1], nil
}
fmt.Fprintf(p.out, "Invalid choice %q. Enter a number from 1 to %d.\n", choice, len(options))
}
}
func (p *prompter) driveAuthMachine(ctx context.Context, machine *discordauth.AuthMachine) (*discordauth.LoginCompleted, error) {
var answer *discordauth.Answer
for {
prompt, done, err := machine.Advance(ctx, answer)
if err != nil {
return nil, err
}
if done != nil {
return done, nil
}
if prompt == nil {
return nil, fmt.Errorf("auth machine did not advance")
}
answer, err = p.answerPrompt(ctx, prompt)
if err != nil {
return nil, err
}
}
}
func (p *prompter) answerPrompt(ctx context.Context, prompt *discordauth.Prompt) (*discordauth.Answer, error) {
switch {
case prompt.CredsPrompt != nil:
return p.answerCreds(prompt.CredsPrompt)
case prompt.Captcha != nil:
solution, err := p.SolveCaptcha(ctx, prompt.Captcha)
if err != nil {
return nil, err
}
return &discordauth.Answer{Solution: solution}, nil
case prompt.EmailVerifyPrompt != nil:
if err := p.WaitForEmailVerification(ctx); err != nil {
return nil, err
}
return &discordauth.Answer{}, nil
case prompt.MFAChallengePrompt != nil:
return p.answerMFAMethod(prompt.MFAChallengePrompt)
case prompt.MFACodePrompt != nil:
return p.answerMFACode(prompt.MFACodePrompt)
default:
return nil, fmt.Errorf("auth machine returned an empty prompt")
}
}
func (p *prompter) answerCreds(prompt *discordauth.CredsPrompt) (*discordauth.Answer, error) {
if prompt.Reason != "" {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Discord rejected those credentials: %s\n", prompt.Reason)
}
login, err := p.promptRequired("Email or phone")
if err != nil {
return nil, fmt.Errorf("failed to read login: %w", err)
}
password, err := p.promptSecretRequired("Password")
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
return &discordauth.Answer{
Creds: discordauth.NewCreds(login, password),
}, nil
}
func supportedMFAMethods(challenge *discordauth.LoginMFARequired) []mfaMethodOption {
options := make([]mfaMethodOption, 0, 3)
if challenge.TOTPEnabled {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorTOTP,
Label: "TOTP authenticator",
CodePrompt: "TOTP code",
})
}
if challenge.SMSEnabled {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorSMS,
Label: "SMS code",
CodePrompt: "SMS code",
})
}
if challenge.BackupCodesAccepted {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorBackup,
Label: "Backup code",
CodePrompt: "Backup code",
})
}
return options
}
func newMFAContinue(challenge *discordauth.LoginMFARequired, authType discordauth.AuthenticatorType, code string) *discordauth.MFAContinue {
return &discordauth.MFAContinue{
Type: authType,
MFAContinuation: discordauth.MFAContinuation{
MFAState: challenge.MFAState,
Code: code,
},
}
}
func (p *prompter) answerMFAMethod(prompt *discordauth.MFAChallengePrompt) (*discordauth.Answer, error) {
challenge := prompt.LoginMFARequired
if challenge == nil {
return nil, fmt.Errorf("auth machine returned an MFA prompt without a challenge")
}
if prompt.Reason != "" {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Discord rejected that MFA response: %s\n", prompt.Reason)
}
options := supportedMFAMethods(challenge)
if len(options) == 0 {
if challenge.WebAuthnCredential != nil {
panic("authtester does not support WebAuthn MFA")
}
return nil, fmt.Errorf("discord did not offer a supported MFA method")
}
selected := options[0]
if len(options) == 1 {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Using MFA method: %s\n", selected.Label)
} else {
var err error
selected, err = p.promptMFAChoice(options)
if err != nil {
return nil, err
}
}
if selected.Type == discordauth.AuthenticatorSMS {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Requesting an MFA SMS code...")
}
p.mfaChallenge = challenge
return &discordauth.Answer{
PickedMFAType: &selected.Type,
}, nil
}
func (p *prompter) answerMFACode(prompt *discordauth.MFACodePrompt) (*discordauth.Answer, error) {
if p.mfaChallenge == nil {
return nil, fmt.Errorf("auth machine requested an MFA code before an MFA challenge")
}
selected := mfaMethodOption{
Type: prompt.Type,
}
switch prompt.Type {
case discordauth.AuthenticatorTOTP:
selected.CodePrompt = "TOTP code"
case discordauth.AuthenticatorSMS:
fmt.Fprintln(p.out, "Discord sent an MFA SMS code.")
selected.CodePrompt = "SMS code"
case discordauth.AuthenticatorBackup:
selected.CodePrompt = "Backup code"
default:
return nil, fmt.Errorf("unsupported MFA code prompt type %q", prompt.Type)
}
code, err := p.promptSecretRequired(selected.CodePrompt)
if err != nil {
return nil, err
}
if selected.Type == discordauth.AuthenticatorBackup {
code = strings.ReplaceAll(code, "-", "")
}
return &discordauth.Answer{
MFAContinue: newMFAContinue(p.mfaChallenge, selected.Type, code),
}, nil
}
func (p *prompter) SolveCaptcha(ctx context.Context, captcha *discordauth.Captcha) (*discordauth.CaptchaSolution, error) {
captchaData, err := json.MarshalIndent(captcha, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to encode captcha challenge: %w", err)
}
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Received CAPTCHA challenge:")
fmt.Fprintln(p.out, string(captchaData))
if p.captchaServer != nil && supportsBrowserCaptcha(captcha) {
pageURL, waitForSolution, err := p.captchaServer.startChallenge(captcha)
if err != nil {
fmt.Fprintf(p.out, "Failed to start local CAPTCHA page: %v\n", err)
fmt.Fprintln(p.out, "Falling back to manual token entry.")
} else {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Open this page in your browser and solve the CAPTCHA:")
fmt.Fprintf(p.out, " %s\n", pageURL)
fmt.Fprintln(p.out, "If the page reports an error or you cancel it, authtester will fall back to manual token entry.")
solution, err := waitForSolution(ctx)
switch {
case err == nil:
return &discordauth.CaptchaSolution{Solution: solution}, nil
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return nil, err
case errors.Is(err, errCaptchaBrowserCanceled):
fmt.Fprintln(p.out, "Local CAPTCHA page was canceled.")
fmt.Fprintln(p.out, "Falling back to manual token entry.")
default:
fmt.Fprintf(p.out, "Local CAPTCHA page failed: %v\n", err)
fmt.Fprintln(p.out, "Falling back to manual token entry.")
}
}
} else {
fmt.Fprintln(p.out, "Local browser flow only supports hCaptcha challenges with a sitekey.")
}
solution, err := p.promptRequired("CAPTCHA solution")
if err != nil {
return nil, err
}
return &discordauth.CaptchaSolution{Solution: solution}, nil
}
func (p *prompter) WaitForEmailVerification(ctx context.Context) error {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Discord detected a login from a new location and emailed a")
fmt.Fprintln(p.out, "verification link. Open the email, click the link to authorize")
fmt.Fprintln(p.out, "this login, then continue.")
// The return value is irrelevant; we just block until the user confirms.
_, err := p.prompt("Press enter once you've verified")
return err
}

View file

@ -0,0 +1,460 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordauth"
)
var errCaptchaBrowserCanceled = errors.New("captcha browser flow canceled")
//go:embed main_captcha.html
var captchaPageHTML string
type captchaServer struct {
mu sync.Mutex
log zerolog.Logger
handler http.Handler
server *http.Server
ln net.Listener
baseURL string
active *activeCaptcha
}
type activeCaptcha struct {
challenge browserCaptchaChallenge
resultCh chan captchaBrowserResult
}
type browserCaptchaChallenge struct {
ID string `json:"id"`
Service discordauth.CaptchaService `json:"service"`
SiteKey string `json:"site_key"`
RqData string `json:"rqdata,omitempty"`
Invisible bool `json:"invisible"`
}
type captchaBrowserResult struct {
token string
err error
}
type captchaSolveRequest struct {
ID string `json:"id"`
Token string `json:"token"`
}
type captchaCancelRequest struct {
ID string `json:"id"`
}
type captchaErrorRequest struct {
ID string `json:"id"`
Error string `json:"error"`
}
type captchaErrorResponse struct {
Error string `json:"error"`
}
func newCaptchaServer(log zerolog.Logger) *captchaServer {
cs := &captchaServer{log: log}
mux := http.NewServeMux()
mux.HandleFunc("/", cs.handlePage)
mux.HandleFunc("/api/challenge", cs.handleChallenge)
mux.HandleFunc("/api/solve", cs.handleSolve)
mux.HandleFunc("/api/cancel", cs.handleCancel)
mux.HandleFunc("/api/error", cs.handleError)
cs.handler = mux
return cs
}
func supportsBrowserCaptcha(captcha *discordauth.Captcha) bool {
return captcha != nil &&
captcha.Service == discordauth.CaptchaServiceHCaptcha &&
captcha.SiteKey != nil &&
strings.TrimSpace(*captcha.SiteKey) != ""
}
func (cs *captchaServer) startChallenge(captcha *discordauth.Captcha) (string, func(context.Context) (string, error), error) {
if !supportsBrowserCaptcha(captcha) {
return "", nil, fmt.Errorf("browser flow only supports hcaptcha challenges with a sitekey")
}
if err := cs.ensureStarted(); err != nil {
return "", nil, err
}
challenge := &activeCaptcha{
challenge: browserCaptchaChallenge{
ID: uuid.NewString(),
Service: captcha.Service,
SiteKey: strings.TrimSpace(*captcha.SiteKey),
Invisible: captcha.Invisible,
},
resultCh: make(chan captchaBrowserResult, 1),
}
if captcha.RqData != nil {
challenge.challenge.RqData = *captcha.RqData
}
cs.mu.Lock()
if cs.active != nil {
cs.log.Warn().
Str("replaced_challenge_id", cs.active.challenge.ID).
Msg("Replacing active CAPTCHA challenge before it was resolved")
}
cs.active = challenge
pageURL := cs.baseURL
cs.mu.Unlock()
cs.log.Info().
Str("challenge_id", challenge.challenge.ID).
Str("captcha_service", string(challenge.challenge.Service)).
Bool("captcha_invisible", challenge.challenge.Invisible).
Bool("captcha_has_rqdata", challenge.challenge.RqData != "").
Str("page_url", pageURL).
Msg("Started local CAPTCHA challenge")
wait := func(ctx context.Context) (string, error) {
defer cs.clearActiveChallenge(challenge.challenge.ID)
select {
case result := <-challenge.resultCh:
if result.err != nil {
cs.log.Warn().
Str("challenge_id", challenge.challenge.ID).
Err(result.err).
Msg("Local CAPTCHA challenge completed with error")
return "", result.err
}
if result.token == "" {
return "", fmt.Errorf("browser page returned an empty CAPTCHA token")
}
cs.log.Info().
Str("challenge_id", challenge.challenge.ID).
Int("token_length", len(result.token)).
Msg("Local CAPTCHA challenge returned a token")
return result.token, nil
case <-ctx.Done():
cs.log.Warn().
Str("challenge_id", challenge.challenge.ID).
Err(ctx.Err()).
Msg("Stopped waiting for local CAPTCHA challenge")
return "", ctx.Err()
}
}
return pageURL, wait, nil
}
func (cs *captchaServer) ensureStarted() error {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.server != nil {
return nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("failed to listen on 127.0.0.1: %w", err)
}
addr := ln.Addr().(*net.TCPAddr)
server := &http.Server{
Handler: cs.handler,
ReadHeaderTimeout: 5 * time.Second,
}
cs.ln = ln
cs.server = server
cs.baseURL = fmt.Sprintf("http://localhost:%d/", addr.Port)
cs.log.Info().
Str("listen_addr", ln.Addr().String()).
Str("page_url", cs.baseURL).
Msg("Started local CAPTCHA server")
go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
cs.log.Error().Err(err).Msg("Local CAPTCHA server stopped unexpectedly")
cs.failActiveChallenge(fmt.Errorf("captcha server stopped unexpectedly: %w", err))
}
}()
return nil
}
func (cs *captchaServer) Close(ctx context.Context) error {
cs.mu.Lock()
server := cs.server
cs.server = nil
cs.ln = nil
cs.baseURL = ""
cs.active = nil
cs.mu.Unlock()
if server == nil {
return nil
}
cs.log.Info().Msg("Shutting down local CAPTCHA server")
return server.Shutdown(ctx)
}
func (cs *captchaServer) clearActiveChallenge(id string) {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.active != nil && cs.active.challenge.ID == id {
cs.active = nil
}
}
func (cs *captchaServer) failActiveChallenge(err error) {
cs.log.Error().Err(err).Msg("Failing active CAPTCHA challenge")
cs.mu.Lock()
active := cs.active
cs.active = nil
cs.mu.Unlock()
if active == nil {
return
}
select {
case active.resultCh <- captchaBrowserResult{err: err}:
default:
}
}
func (cs *captchaServer) resolveActiveChallenge(id string, result captchaBrowserResult) error {
cs.mu.Lock()
active := cs.active
if active == nil {
cs.mu.Unlock()
cs.log.Warn().
Str("challenge_id", id).
Msg("Attempted to resolve CAPTCHA challenge, but none is active")
return fmt.Errorf("no active captcha challenge")
}
if active.challenge.ID != id {
cs.mu.Unlock()
cs.log.Warn().
Str("challenge_id", id).
Str("active_challenge_id", active.challenge.ID).
Msg("Attempted to resolve a stale CAPTCHA challenge")
return fmt.Errorf("captcha challenge is no longer current")
}
cs.active = nil
cs.mu.Unlock()
select {
case active.resultCh <- result:
return nil
default:
cs.log.Warn().
Str("challenge_id", id).
Msg("CAPTCHA challenge was already resolved")
return fmt.Errorf("captcha challenge already resolved")
}
}
func (cs *captchaServer) currentChallenge() *browserCaptchaChallenge {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.active == nil {
return nil
}
challenge := cs.active.challenge
return &challenge
}
func (cs *captchaServer) handlePage(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodGet {
log.Warn().Msg("Rejected CAPTCHA page request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(captchaPageHTML))
log.Info().Msg("Served CAPTCHA page")
}
func (cs *captchaServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodGet {
log.Warn().Msg("Rejected CAPTCHA challenge request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodGet)
return
}
challenge := cs.currentChallenge()
if challenge == nil {
log.Warn().Msg("Requested CAPTCHA challenge, but none is active")
writeCaptchaJSON(w, http.StatusNotFound, captchaErrorResponse{Error: "no active captcha challenge"})
return
}
log.Info().
Str("challenge_id", challenge.ID).
Str("captcha_service", string(challenge.Service)).
Bool("captcha_invisible", challenge.Invisible).
Bool("captcha_has_rqdata", challenge.RqData != "").
Msg("Served active CAPTCHA challenge")
writeCaptchaJSON(w, http.StatusOK, challenge)
}
func (cs *captchaServer) handleSolve(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA solve request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaSolveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA solve request with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA solve request without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
log.Warn().
Str("challenge_id", req.ID).
Msg("Rejected CAPTCHA solve request with empty token")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing captcha token"})
return
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{token: req.Token}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA solve request")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Info().
Str("challenge_id", req.ID).
Int("token_length", len(req.Token)).
Msg("Accepted CAPTCHA token from browser page")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) handleCancel(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA cancel request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaCancelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA cancel request with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA cancel request without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: errCaptchaBrowserCanceled}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA cancel request")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Info().
Str("challenge_id", req.ID).
Msg("Browser page canceled CAPTCHA flow")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) handleError(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA error report with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA error report with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA error report without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
message := strings.TrimSpace(req.Error)
if message == "" {
message = "browser page reported an unknown error"
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: fmt.Errorf("%s", message)}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA browser error report")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Warn().
Str("challenge_id", req.ID).
Str("browser_error", message).
Msg("Browser page reported CAPTCHA error")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) requestLogger(r *http.Request) zerolog.Logger {
return cs.log.With().
Str("http_method", r.Method).
Str("http_path", r.URL.Path).
Str("remote_addr", r.RemoteAddr).
Logger()
}
func writeCaptchaJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeCaptchaMethodNotAllowed(w http.ResponseWriter, allowed string) {
w.Header().Set("Allow", allowed)
writeCaptchaJSON(w, http.StatusMethodNotAllowed, captchaErrorResponse{Error: "method not allowed"})
}

View file

@ -0,0 +1,225 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mautrix-discord hCaptcha</title>
<style>
:root {
color-scheme: light dark;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
#status {
margin: 1rem 0;
padding: 1rem;
background-color: light-dark(#ddd, #333);
&.error {
background-color: hsla(0 62.7% 52.9% / 0.2);
color: red;
font-weight: bold;
}
}
#captcha {
min-height: 90px;
}
button {
display: block;
margin-top: 1rem;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
</head>
<body>
<main>
<h1>mautrix-discord hCaptcha</h1>
<main>
<div id="status">Loading challenge state...</div>
<div id="captcha"></div>
<button id="cancel" type="button">
Cancel & Use Terminal Fallback
</button>
</main>
</main>
<script>
let challenge = null;
let widgetID = null;
const statusEl = document.getElementById("status");
const cancelButton = document.getElementById("cancel");
function setStatus(message, isError) {
statusEl.textContent = message;
statusEl.classList.toggle("error", Boolean(isError));
}
async function parseError(response) {
let message = "Request failed";
try {
const body = await response.json();
if (body && body.error) {
message = body.error;
}
} catch (err) {}
return message;
}
async function postJSON(path, payload) {
const response = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await parseError(response));
}
}
async function reportError(message) {
setStatus(message, true);
if (!challenge) {
return;
}
try {
await postJSON("/api/error", {id: challenge.id, error: message});
} catch (err) {}
}
async function cancelChallenge() {
if (!challenge) {
setStatus("No active challenge to cancel.", true);
return;
}
cancelButton.disabled = true;
setStatus(
"Canceling browser flow and switching back to terminal fallback...",
);
try {
await postJSON("/api/cancel", {id: challenge.id});
setStatus("Canceled! Check your terminal for manual entry.");
} catch (err) {
setStatus("Failed to cancel: " + err.message, true);
cancelButton.disabled = false;
}
}
async function submitToken(token) {
setStatus("Submitting CAPTCHA token...");
cancelButton.disabled = true;
await postJSON("/api/solve", {id: challenge.id, token: token});
setStatus("CAPTCHA token submitted! Check your terminal.");
}
function loadHCaptchaScript() {
const script = document.createElement("script");
script.src =
"https://js.hcaptcha.com/1/api.js?render=explicit&onload=onHCaptchaLoad&recaptchacompat=off&host=discord.com";
script.async = true;
script.defer = true;
script.onerror = function () {
reportError("Failed to load hCaptcha API script.");
};
document.head.appendChild(script);
}
function applyRqData() {
if (!challenge || !challenge.rqdata) {
return;
}
hcaptcha.setData(widgetID, {rqdata: challenge.rqdata});
}
function renderHCaptcha() {
widgetID = hcaptcha.render("captcha", {
sitekey: challenge.site_key,
size: challenge.invisible ? "invisible" : "normal",
callback: function (token) {
submitToken(token).catch(function (err) {
reportError("Failed to submit token: " + err.message);
});
},
"open-callback": function () {
setStatus("CAPTCHA challenge opened. Complete it in this page.");
},
"close-callback": function () {
setStatus(
"CAPTCHA challenge was closed. Re-open it here or cancel to use terminal fallback.",
);
},
"expired-callback": function () {
reportError("hCaptcha token expired before submission.");
},
"chalexpired-callback": function () {
reportError("hCaptcha challenge expired.");
},
"error-callback": function (message) {
reportError("hCaptcha error: " + message);
},
});
applyRqData();
if (challenge.invisible) {
setStatus("Launching invisible challenge...");
hcaptcha.execute(widgetID);
} else {
setStatus(
"Solve the CAPTCHA below. The token will submit automatically.",
);
}
}
async function loadChallenge() {
const response = await fetch("/api/challenge", {cache: "no-store"});
if (!response.ok) {
throw new Error(await parseError(response));
}
challenge = await response.json();
if (challenge.service !== "hcaptcha") {
throw new Error(
"Only hCaptcha challenges are supported by this test page.",
);
}
if (!challenge.site_key) {
throw new Error("Challenge is missing captcha_sitekey.");
}
}
async function init() {
cancelButton.addEventListener("click", function () {
cancelChallenge().catch(function (err) {
setStatus("Failed to cancel: " + err.message, true);
});
});
try {
await loadChallenge();
setStatus("Challenge state loaded. Loading hCaptcha...");
loadHCaptchaScript();
} catch (err) {
reportError(err.message);
}
}
window.onHCaptchaLoad = function () {
try {
renderHCaptcha();
} catch (err) {
reportError("Failed to initialize hCaptcha: " + err.message);
}
};
init();
</script>
</body>
</html>

View file

@ -0,0 +1,37 @@
// 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 main
import (
_ "embed"
)
const legacyMigrateRenameTables = `
ALTER TABLE portal RENAME TO portal_old;
ALTER TABLE puppet RENAME TO puppet_old;
ALTER TABLE "user" RENAME TO user_old;
ALTER TABLE message RENAME TO message_old;
ALTER TABLE reaction RENAME TO reaction_old;
ALTER TABLE user_portal RENAME TO user_portal_old;
ALTER TABLE guild RENAME TO guild_old;
ALTER TABLE role RENAME TO role_old;
ALTER TABLE thread RENAME TO thread_old;
ALTER TABLE discord_file RENAME TO discord_file_old;
`
//go:embed legacymigrate.sql
var legacyMigrateCopyData string

Some files were not shown because too many files have changed in this diff Show more