297 Commits

Author SHA1 Message Date
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
d92d7c4314 Install lottieconverter from Alpine repos 2025-08-19 17:44:20 +03:00
Tulir Asokan
5c22ed85a7 Bump minimum Go version to 1.24 2025-08-17 00:02:45 +03:00
Tulir Asokan
98e5e9de4a Revert "Allow v12 rooms to be created"
This reverts commit d2988096e4.

The bridge can handle v12 rooms fine, but creation requires additional considerations

Fixes #193
2025-08-17 00:02:28 +03:00
Tulir Asokan
820951cb6e Add support for disabling link previews via MSC4095 2025-08-10 23:47:39 +03:00
Tulir Asokan
52ebc21d9b Update mautrix-go 2025-08-10 23:28:01 +03:00
Tulir Asokan
16469259f7 Update issue templates 2025-07-18 17:30:19 +03:00
Tulir Asokan
d2988096e4 Allow v12 rooms to be created 2025-07-18 17:30:19 +03:00
Tulir Asokan
3f7622be19 Add support for following tombstones 2025-07-18 17:30:19 +03:00
Tulir Asokan
40a6992151 Bump version to v0.7.5 2025-07-16 11:45:58 +03:00
Tulir Asokan
111824486b Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:43:53 +03:00
Tulir Asokan
d4e7289315 Update prefix_webhook_messages option to use MSC4144 fallbacks 2025-07-01 00:59:17 +03:00
Tulir Asokan
e2151defc6 Update mautrix-go to fix federation key response
[skip cd]
2025-06-29 19:15:05 +03:00
Tulir Asokan
30c2cd94a7 Bump version to v0.7.4 2025-06-16 18:53:05 +03:00
Tulir Asokan
847d4cb98e Update Docker image to Alpine 3.22
Closes #183
2025-06-08 00:58:00 +03:00
Tulir Asokan
9fd89cdfc5 Add support for forwarded messages
Fixes #170
Closes #182
2025-06-08 00:49:16 +03:00
LeaPhant
dc4538aab6 Add support for MSC4193 media spoilers (#189) 2025-06-08 00:27:29 +03:00
Tulir Asokan
a6fca7ce43 Add channel is bridgeable check to channel update handler 2025-06-08 00:24:19 +03:00
Tulir Asokan
d69e4e9881 Update mautrix-go to rename cross-room reply field 2025-06-08 00:15:45 +03:00
Tulir Asokan
ccc6c77911 Update mautrix-go to enable MSC4190. Closes #181 2025-05-03 22:13:06 +03:00
Tulir Asokan
001c88c400 Bump version to v0.7.3 2025-04-16 14:09:51 +03:00
Ping Chen
d37b5028e1 portal: fix isPlainGifMessage to get link preview working (#179) 2025-04-09 20:37:25 +09:00
Tulir Asokan
ef093b129f dependencies: update discordgo 2025-03-20 17:43:03 +02:00
batuhan
84e56c73fa portal: add com.beeper.room_type.v2 to m.bridge events (#178) 2025-03-20 16:23:00 +02:00
ginnyTheCat
5854ad0c14 portal: fix typo in msc1767 field name (#177) 2025-03-20 16:22:37 +02:00
Tulir Asokan
9605992758 portal: add id field for per-message profiles
Closes #176
2025-03-20 16:21:38 +02:00
Tulir Asokan
4d67dbcd00 client: only load main page for users 2025-02-22 20:09:40 +02:00
Tulir Asokan
31a75d871f login: add filename when sending QR image 2025-02-22 20:09:36 +02:00
Tulir Asokan
b8892ed59f dependencies: update 2025-02-22 20:04:47 +02:00
Tulir Asokan
65ef2c4ff6 portal: add support for no-mention replies 2025-02-22 19:48:16 +02:00
mat
4a8e9f5c21 portal: fix per-message profiles for guild-specific avatars (#172) 2025-02-09 02:34:44 +02:00
Tulir Asokan
4aad353603 Bump version to v0.7.2 2024-12-16 16:07:46 +02:00
Tulir Asokan
0e59e2da68 dependencies: update 2024-12-16 16:06:33 +02:00
Tulir Asokan
5a029367b3 dependencies: update 2024-11-29 20:22:31 +02:00
Tulir Asokan
f2897d9b14 client: load version number dynamically 2024-11-29 20:15:04 +02:00
Tulir Asokan
8b61dc5352 config: add support for using a proxy 2024-11-29 20:15:00 +02:00
Tulir Asokan
b330c5836e client: set referers properly 2024-11-29 20:14:52 +02:00
Tulir Asokan
8219516ede dependencies: update discordgo 2024-11-22 00:29:29 +02:00
Tulir Asokan
c01f502e04 Bump version to v0.7.1 2024-11-16 18:06:31 +02:00
Tulir Asokan
1e3b854ee1 dependencies: update golang.org/x deps and bump minimum go version 2024-11-15 13:11:13 +02:00
Tulir Asokan
a9df85fdca portal: add missing fi.mau.gif field to gifvs 2024-11-14 22:57:09 +02:00
Tulir Asokan
0d148ffad6 ci: lock closed issues automatically after 90 days 2024-11-13 15:17:33 +02:00
Tulir Asokan
024577d822 user: catch 40002 responses 2024-11-13 15:15:36 +02:00
Tulir Asokan
449c9264d8 dependencies: update discordgo 2024-11-13 14:58:00 +02:00
Tulir Asokan
a0ee1fd508 .github: update bug report template 2024-11-13 14:12:52 +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
Tulir Asokan
ce1f401ddc Bump version to v0.7.0 2024-07-16 11:28:58 +03:00
Tulir Asokan
2f5b3fcbfb Don't use mxid in mention pills 2024-07-15 19:26:09 +03:00
Tulir Asokan
035f2a408b Add support for authenticated media 2024-07-12 20:09:07 +03:00
Tulir Asokan
a126a36249 Add create-portal command 2024-06-24 21:43:11 +03:00
Tulir Asokan
1fef7a0ee2 Create category space if necessary when creating channel room 2024-06-24 21:36:38 +03:00
Tulir Asokan
2da2aa47e9 Always use guild room for join rule 2024-06-24 21:28:45 +03:00
Tulir Asokan
a6d9e62b49 Add support for MSC3916 endpoints for direct media 2024-05-31 21:45:50 +03:00
Tulir Asokan
8d01c30014 Fix finding client to fetch messages through 2024-02-18 23:41:25 +02:00
Tulir Asokan
2a7a2c3895 Parse expiry from URL 2024-02-18 23:41:25 +02:00
Tulir Asokan
23ae2d314f Update changelog 2024-02-18 23:26:03 +02:00
Tulir Asokan
737e4c89e0 Update minimum Go version 2024-02-18 23:12:35 +02:00
Tulir Asokan
9402d0d291 Fix mass inserting messages 2024-02-18 23:11:31 +02:00
Tulir Asokan
d0e3d2966a Redo direct media access with URL refreshing (#135) 2024-02-18 23:10:19 +02:00
Tulir Asokan
a5813a9d78 Bump version to v0.6.5 2024-01-16 16:19:53 +02:00
Tulir Asokan
5de499a3b5 Reuse existing getEvent function 2024-01-06 11:11:39 +02:00
Tulir Asokan
3f5484c73e Add support for encrypted events in webhook replies
Obviously won't help if the encryption hardening options are enabled,
because the point of those is to prevent the bridge from decrypting old
messages.

Fixes #131
2024-01-06 11:09:31 +02:00
Tulir Asokan
8035a2d3a1 Update actions and run on both supported Go versions
[skip cd]
2023-12-28 17:26:36 +01:00
Sumner Evans
f69c02acb6 pre-commit: ban Msgf() from zerolog
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-12-24 11:26:41 -07:00
Tulir Asokan
8c8cfa8f6b Update dependencies 2023-12-24 20:25:05 +02:00
Toni Spets
643d4c6e39 Expose debug API with pprof
Runs along the provisioning API with same authentication.
2023-12-05 12:11:57 +02:00
Tulir Asokan
c013873d1c Bump version to v0.6.4 2023-11-16 15:29:23 +02:00
Tulir Asokan
394c0a05d3 Update dependencies 2023-11-16 15:27:12 +02:00
Tulir Asokan
2138b6115f Update .gitignore 2023-11-08 17:45:43 +02:00
Tulir Asokan
5b8473b3de Send error messages in thread if applicable 2023-11-08 17:45:17 +02:00
Tulir Asokan
45359853de Bump version to v0.6.3 2023-10-16 13:27:01 +03:00
Tulir Asokan
a51ed70f45 Update dependencies 2023-10-16 13:22:40 +03:00
Tulir Asokan
d9e1292a9e Update changelog 2023-10-13 21:32:54 +03:00
Tulir Asokan
0f35e27d81 Update discordgo to fix handling op7 while connecting 2023-10-13 21:31:38 +03:00
Tulir Asokan
318d6f3fe6 Try to avoid syncing other user into DM portals 2023-10-03 17:22:29 +03:00
Tulir Asokan
b0a7cbca13 Update custom emoji status in roadmap 2023-10-03 17:22:25 +03:00
Tulir Asokan
308f47e2fa Bump version to v0.6.2 2023-09-16 10:34:07 -04:00
Florian Badie
2c396e553e Fix "video" embeds with missing video URLs (#110) 2023-09-01 08:22:53 +00:00
Tulir Asokan
c710ea18aa Don't panic if redacting attachment fails 2023-08-29 11:43:36 +03:00
Tulir Asokan
185f9a8963 Move double puppeting login code to mautrix-go 2023-08-22 19:01:08 +03:00
Tulir Asokan
345391f8b1 Allow inline links in normal messages 2023-08-17 20:46:18 +03:00
Tulir Asokan
fb6d89a88f Bump version to v0.6.1 2023-08-17 00:57:00 +03:00
Tulir Asokan
acaaa9f0f8 Update dependencies 2023-08-17 00:54:38 +03:00
Tulir Asokan
2ec3b0ebce Update discordgo 2023-08-04 18:42:02 +03:00
Tulir Asokan
802ec555d6 Update discordgo to remove need to fetch own member info manually 2023-08-04 14:16:36 +03:00
Tulir Asokan
84a6fbc571 Move channelIsBridgeable check when syncing guild channels
Fixes #107
2023-08-04 13:47:25 +03:00
Tulir Asokan
0391750fea Fix handling gifs where canonical URL is different 2023-08-03 00:17:15 +03:00
Tulir Asokan
5467ab074d Update mautrix-go 2023-07-29 14:43:44 +03:00
Tulir Asokan
ff0a9bcafa Update mautrix-go 2023-07-22 20:35:36 +03:00
Tulir Asokan
aef54fcc3b Update usernames in login/ping commands 2023-07-18 22:58:59 +03:00
Tulir Asokan
dab1aba6e5 Bump version to v0.6.0 2023-07-16 12:57:13 +03:00
Tulir Asokan
792ad54b9c Fix error messages in portals with no relay webhook 2023-07-15 18:55:16 +03:00
Tulir Asokan
9b7b60966f Redact relay webhook secret in error messages. Fixes #105 2023-07-15 18:53:01 +03:00
Tulir Asokan
104ee2da57 Fix panic if lottieconverter isn't installed 2023-07-03 17:09:26 +03:00
Tulir Asokan
41d0ffcf3b Update changelog 2023-07-03 17:07:54 +03:00
Tulir Asokan
b87421f0fb Ignore guild delete events with unavailable=true 2023-06-30 22:20:32 +03:00
Tulir Asokan
3c4561113b Remove long wait for semaphore 2023-06-30 15:04:47 +03:00
Tulir Asokan
3eb5c44be3 Fix attachment semaphore unlocking when download fails 2023-06-30 15:03:50 +03:00
Tulir Asokan
a67d6d2af7 Add italics for bridging emotes 2023-06-29 15:23:44 +03:00
Tulir Asokan
f4284e7b3f Prevent attachment semaphore from blocking permanently 2023-06-29 15:19:52 +03:00
Tulir Asokan
07785997bf Add some debug logs for backfill lock 2023-06-29 15:19:52 +03:00
Sumner Evans
62a1d83508 deps/mautrix: upgrade to reduce logs on database transactions
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-06-28 15:33:06 -06:00
Sumner Evans
57b7be8cbb logging: remove 'Starting' log and use duration instead
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-06-27 09:22:13 -06:00
Sumner Evans
f5ffbe1311 deps/mautrix: upgrade to reduce logs of requests
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-06-27 09:22:13 -06:00
Tulir Asokan
be1128fd50 Update Docker image to Alpine 3.18 2023-06-26 13:21:44 +03:00
Tulir Asokan
b4249488db Prevent handling too many attachments in parallel 2023-06-23 15:32:18 +03:00
Tulir Asokan
b446d865d0 Update mautrix-go 2023-06-23 15:20:11 +03:00
Tulir Asokan
25d07c9c34 Log event IDs after handling message 2023-06-22 13:18:32 +03:00
Tulir Asokan
200c4fc9d0 Expose Application flag to displayname templates
Fixes #94
2023-06-22 13:18:27 +03:00
Tulir Asokan
d39499cdcf Update username format in custom bridge identifier metadata 2023-06-20 16:32:25 +03:00
Tulir Asokan
c449696120 Handle usernames properly in bridge state remote name 2023-06-20 15:29:46 +03:00
Tulir Asokan
914b360720 Switch to new beeper batch send endpoint 2023-06-19 14:55:44 +03:00
Tulir Asokan
11b91dc299 Backfill threads when found and from server thread list sync 2023-06-18 22:13:20 +03:00
Tulir Asokan
b77eea4586 Create threads for backfilled messages 2023-06-18 20:49:27 +03:00
Tulir Asokan
8ebad277f5 Make backfilling code compatible with threads
This doesn't trigger thread backfill yet, but the backfill methods can
handle threads now.
2023-06-18 20:09:23 +03:00
Tulir Asokan
248664f8b0 Set guild bridging mode when using bridge command without entire flag 2023-06-17 19:37:21 +03:00
Tulir Asokan
3247709abb Improve logs and fix things with avatar reuploads 2023-06-17 19:37:08 +03:00
Tulir Asokan
00465bb715 Bump version to v0.5.0 2023-06-16 14:42:12 +03:00
Tulir Asokan
cf640ac83d Ignore incoming typing notifications from logged-in users 2023-06-14 10:56:24 +03:00
Tulir Asokan
67c8d9237e Remove updating custom ghost info on startup 2023-06-09 17:28:51 +03:00
Tulir Asokan
b2d7077e8d Update mautrix-go to enable appservice websockets 2023-06-09 17:28:24 +03:00
Tulir Asokan
d5db336eee Update mautrix-go 2023-06-09 12:41:17 +03:00
Tulir Asokan
b153e70f2a Don't send missed message warning on initial backfill 2023-06-06 17:03:33 +03:00
Tulir Asokan
cc30353075 Update mautrix-go 2023-06-06 13:52:43 +03:00
Tulir Asokan
4c62fe8b12 Don't add reply sender to mentions array manually
Discord already adds it to the native mentions array
2023-06-04 11:34:04 +03:00
Tulir Asokan
8c57b7a69b Fix adding custom avatar URL in member metadata 2023-06-02 20:38:56 +03:00
Tulir Asokan
a265d03319 Add support for bulk message delete from Discord 2023-06-02 16:13:22 +03:00
Tulir Asokan
1c606e97a6 Enable ATX headers in Discord markdown 2023-05-31 11:45:10 +03:00
Tulir Asokan
e6108cb25d Include guild profiles in custom event field 2023-05-27 14:41:06 +03:00
Tulir Asokan
d004aea9cb Fix typo in query 2023-05-27 13:55:30 +03:00
Tulir Asokan
0fd88fedea Make mxc column non-unique for files. Fixes #71 2023-05-27 13:50:28 +03:00
Tulir Asokan
1e9099e989 Fix typo in db migration name 2023-05-27 13:44:54 +03:00
Tulir Asokan
52fa4da8b2 Reupload webhook avatars to fill custom metadata 2023-05-27 13:35:37 +03:00
Tulir Asokan
4393772ccc Add options to improve handling of webhook messages sent by other bridges 2023-05-27 13:01:24 +03:00
Tulir Asokan
824dea4745 Store global name and webhook status for puppets 2023-05-27 12:31:57 +03:00
Tulir Asokan
07182efddd Update mautrix-go 2023-05-26 15:42:47 +03:00
Tulir Asokan
280e01969a Update changelog 2023-05-25 13:25:46 +03:00
Tulir Asokan
084cde0162 Handle raw image link embeds like video gif embeds 2023-05-25 13:22:54 +03:00
Tulir Asokan
434f27c8b4 Add support for intentional mentions 2023-05-25 13:22:07 +03:00
Tulir Asokan
75181741da Update default displayname template 2023-05-22 20:32:36 +03:00
Tulir Asokan
e85f50633d Update changelog
[skip ci]
2023-05-16 18:18:44 +03:00
Tulir Asokan
a5f9d6510b Bump version to v0.4.0 2023-05-16 17:53:02 +03:00
Tulir Asokan
cf7ae7c4db Ignore updates to outgoing webhook messages 2023-05-15 20:00:15 +03:00
Tulir Asokan
ad8efb864b Add option to disable direct CDN uploads 2023-05-14 14:46:04 +03:00
Tulir Asokan
de80a77708 Update mautrix-go 2023-05-12 00:56:18 +03:00
Tulir Asokan
1ca06f7731 Include discord style identifier with timestamps 2023-05-10 15:25:57 +03:00
Tulir Asokan
d3613d1ec0 Use helper methods for generating matrix.to URLs 2023-05-10 15:25:57 +03:00
Tulir Asokan
6f4c5c1d77 Fix bridging animated emojis in messages
Fixes #87
2023-05-10 15:25:57 +03:00
Tulir Asokan
d3b6c3bc9f Update mautrix-go 2023-05-10 15:25:57 +03:00
vurpo
7655ff1a64 Set contact info for puppets on startup (#85) 2023-05-08 16:58:30 +03:00
Tulir Asokan
87c90d3f12 Maybe fix db upgrade for sqlites in weird states 2023-05-06 23:08:29 +03:00
Tulir Asokan
8100386f88 Set times to utc when reading from database 2023-05-06 22:59:32 +03:00
Tulir Asokan
102b1510f8 Bridge incoming reply embeds as replies 2023-05-06 22:59:23 +03:00
Tulir Asokan
4324b60a2c Store edit timestamp in database to deduplicate edits. Fixes #86 2023-05-06 22:23:19 +03:00
Tulir Asokan
c26de9c7df Update changelog 2023-05-06 21:44:07 +03:00
Tulir Asokan
2937c3ea2e Add new field to reactions 2023-05-06 21:43:57 +03:00
Tulir Asokan
6738a04715 Move zerolog.CallerMarshalFunc to mautrix-go 2023-05-06 20:18:13 +03:00
Tulir Asokan
35f534affa Use convert replies to embeds when sending via webhook
Fixes #68
2023-05-06 18:48:53 +03:00
Tulir Asokan
2e07cbfa0b Update dependencies 2023-05-06 17:45:56 +03:00
Tulir Asokan
cc2d0ae40d Add options to disable or force-enable caching media 2023-05-05 12:51:12 +03:00
Tulir Asokan
9793e00434 Fix message on captcha errors 2023-05-03 18:44:51 +03:00
Tulir Asokan
bd56d33c89 Convert Portal to zerolog 2023-04-30 18:50:30 +03:00
Tulir Asokan
a44ceea836 Fix some unused parameters 2023-04-28 16:06:20 +03:00
Tulir Asokan
f6c4f49bb0 Ensure user invited when updating portal info. Probably fixes #62 2023-04-28 14:58:24 +03:00
Tulir Asokan
14c6ae8c75 Set db compat version 2023-04-28 14:50:47 +03:00
Tulir Asokan
568e270540 Receive all events in same function 2023-04-26 22:04:29 +03:00
Tulir Asokan
3e1d1740f7 Sync group DM participants on change 2023-04-26 21:19:06 +03:00
Tulir Asokan
0e5faa5510 Store username/discriminator/bot status in puppet table 2023-04-26 21:18:46 +03:00
Tulir Asokan
f6f6ed29ec Add option to bypass homeserver for Discord media 2023-04-26 01:39:17 +03:00
Tulir Asokan
f247c679de Add user ID to discordgo logs 2023-04-25 20:48:06 +03:00
Tulir Asokan
aea88ad68f Update mautrix-go 2023-04-25 20:38:38 +03:00
Tulir Asokan
7b93d9099d Enable discordgo info logs by default 2023-04-25 20:33:47 +03:00
Tulir Asokan
3f3c86754d Bridge friend nicks as DM room name 2023-04-22 02:50:14 +03:00
Tulir Asokan
049ef48fb0 Make error messages cleaner 2023-04-22 01:44:51 +03:00
Tulir Asokan
29e0b9fa02 Merge pull request #81 from odrling/backfill-collect-fix
Fix backfill only collecting the last 50 messages
2023-04-20 21:15:06 +03:00
odrling
f298230dcf Fix backfill only collecting the last 50 messages 2023-04-20 19:09:41 +02:00
Tulir Asokan
e3ff8d2269 Sort private channel list before syncing 2023-04-20 14:27:37 +03:00
Tulir Asokan
3df81f40d5 Fix is_network_bot flag name and omit is_bridge_bot 2023-04-18 19:14:38 +03:00
Tulir Asokan
f0bab64e5b Unsplit fetching user info from Puppet.UpdateInfo 2023-04-18 18:43:32 +03:00
Tulir Asokan
1048a41c48 Split converting batch messages into separate function 2023-04-18 18:40:45 +03:00
Sumner Evans
e7f73c3ae2 puppet: update contact info as part of member event changes
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-18 09:14:30 -06:00
Sumner Evans
7469b2577d db/puppet: add contact_info_set column
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-17 21:56:05 -06:00
103 changed files with 4765 additions and 10448 deletions

View File

@@ -1,7 +1,16 @@
---
name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs.
labels: bug
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
is strongly recommended.
type: Bug
---
<!--
Remember to include relevant logs, the bridge version and any other details.
It's always best to ask in the Matrix room first, especially if you aren't sure
what details are needed. Issues with insufficient detail will likely just be
ignored or closed immediately.
-->

View File

@@ -1,6 +1,6 @@
---
name: Enhancement request
about: Submit a feature request or other suggestion
labels: enhancement
type: Feature
---

View File

@@ -2,27 +2,35 @@ name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.24", "1.25"]
name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: "1.20"
go-version: ${{ matrix.go-version }}
cache: true
- 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
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

29
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 21 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
*.db*
*.log*
/mautrix-discord
/start

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

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@@ -9,7 +9,18 @@ repos:
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1
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.4.2
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View File

@@ -1,3 +1,155 @@
# v0.7.5 (2025-07-16)
* Fixed federation key response when using direct media.
* Changed `prefix_webhook_messages` option to generate [MSC4144] fallbacks,
so that any compatible clients will hide the prefix.
* Changed new room creation to hardcode room v11 to avoid v12 rooms being
created before proper support for them can be added.
# v0.7.4 (2025-06-16)
* Added support for forwarded messages
* Added support for [MSC4193] media spoilers (thanks to [@LeaPhant] in [#189]).
* Added support for [MSC4190] for MAS-compatible encryption.
* Updated Docker image to Alpine 3.22
[MSC4193]: https://github.com/matrix-org/matrix-spec-proposals/pull/4193
[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
[@LeaPhant]: https://github.com/mautrix/discord/pull/189
[#189]: https://github.com/mautrix/discord/pull/189
# v0.7.3 (2025-04-16)
* Added support for sending no-mention replies from Matrix
(uses intentional mentions and requires client support).
* Added file name to QR image message when logging in to fix rendering in dumb
clients that validate the file extension.
* Added `id` field to per-message profiles to match [MSC4144].
* Fixed guild avatars in per-message profiles (thanks to [@mat-1] in [#172]).
* Fixed typo in MSC1767 field name in voice messages (thanks to [@ginnyTheCat] in [#177]).
[@mat-1]: https://github.com/mat-1
[@ginnyTheCat]: https://github.com/ginnyTheCat
[#172]: https://github.com/mautrix/discord/pull/172
[#177]: https://github.com/mautrix/discord/pull/177
[MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144
# v0.7.2 (2024-12-16)
* Fixed some headers being set incorrectly.
# v0.7.1 (2024-11-16)
* Bumped minimum Go version to 1.22.
* Updated Discord version numbers.
# v0.7.0 (2024-07-16)
* Bumped minimum Go version to 1.21.
* Added support for Matrix v1.11 authenticated media.
* This also changes how avatars are sent to Discord when using relay webhooks.
To keep avatars working, you must configure `public_address` in the *bridge*
section of the config and proxy `/mautrix-discord/avatar/*` from that
address to the bridge.
* Added `create-portal` command to create individual portals bypassing the
bridging mode. When used in combination with the `if-portal-exists` bridging
mode, this can be used to bridge individual channels from a guild.
* Changed how direct media access works to make it compatible with Discord's
signed URL requirement. The new system must be enabled manually, see
[docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for info.
# v0.6.5 (2024-01-16)
* Fixed adding reply embed to webhook sends if the Matrix room is encrypted.
# v0.6.4 (2023-11-16)
* Changed error messages to be sent in a thread if the errored message was in
a thread.
# v0.6.3 (2023-10-16)
* Fixed op7 reconnects during connection causing the bridge to get stuck
disconnected.
* Fixed double puppet of recipient joining DM portals when both ends of a DM
are using the same bridge.
# v0.6.2 (2023-09-16)
* Added support for double puppeting with arbitrary `as_token`s.
See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
* Adjusted markdown parsing rules to allow inline links in normal messages.
* Fixed panic if redacting an attachment fails.
* Fixed panic when handling video embeds with no URLs
(thanks to [@odrling] in [#110]).
[@odrling]: https://github.com/odrling
[#110]: https://github.com/mautrix/discord/pull/110
# v0.6.1 (2023-08-16)
* Bumped minimum Go version to 1.20.
* Fixed all logged-in users being invited to existing portal rooms even if they
don't have permission to view the channel on Discord.
* Fixed gif links not being treated as embeds if the canonical URL is different
than the URL in the message body.
# v0.6.0 (2023-07-16)
* Added initial support for backfilling threads.
* Exposed `Application` flag to displayname template.
* Changed `m.emote` bridging to use italics on Discord.
* Updated Docker image to Alpine 3.18.
* Added limit to parallel media transfers to avoid high memory usage if lots
of messages are received at the same time.
* Fixed guilds being unbridged if Discord has server issues and temporarily
marks a guild as unavailable.
* Fixed using `guilds bridge` command without `--entire` flag.
* Fixed panic if lottieconverter isn't installed.
* Fixed relay webhook secret being leaked in network error messages.
# v0.5.0 (2023-06-16)
* Added support for intentional mentions in Matrix (MSC3952).
* Added `GlobalName` variable to displayname templates and updated the default
template to prefer it over usernames.
* Added `Webhook` variable to displayname templates to allow determining if a
ghost user is a webhook.
* Added guild profiles and webhook profiles as a custom field in Matrix
message events.
* Added support for bulk message delete from Discord.
* Added support for appservice websockets.
* Enabled parsing headers (`#`) in Discord markdown.
* Messages that consist of a single image link are now bridged as images to
closer match Discord.
* Stopped bridging incoming typing notifications from users who are logged into
the bridge to prevent echoes.
# v0.4.0 (2023-05-16)
* Added bridging of friend nicks into DM room names.
* Added option to bypass homeserver for Discord media.
See [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for more info.
* Added conversion of replies to embeds when sending messages via webhook.
* Added option to disable caching reuploaded media. This may be necessary when
using a media repo that doesn't create a unique mxc URI for each upload.
* Added option to disable uploading files directly to the Discord CDN
(and send as form parts in the message send request instead).
* Improved formatting of error messages returned by Discord.
* Enabled discordgo info logs by default.
* Fixed limited backfill always stopping after 50 messages
(thanks to [@odrling] in [#81]).
* Fixed startup sync to sync most recent private channels first.
* Fixed syncing group DM participants when they change.
* Fixed bridging animated emojis in messages.
* Stopped handling all message edits from relay webhook to prevent incorrect
edits.
* Possibly fixed inviting to portal rooms when multiple Matrix users use the
bridge.
[@odrling]: https://github.com/odrling
[#81]: https://github.com/mautrix/discord/pull/81
# v0.3.0 (2023-04-16)
* Added support for backfilling on room creation and missed messages on startup.

View File

@@ -1,6 +1,4 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM golang:1-alpine3.17 AS builder
FROM golang:1-alpine3.22 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -8,18 +6,14 @@ COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
FROM alpine:3.17
FROM alpine:3.22
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/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

View File

@@ -1,18 +1,12 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM alpine:3.17
FROM alpine:3.22
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \
zlib libpng giflib libstdc++ libgcc
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
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

View File

@@ -1,18 +0,0 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM golang:1-alpine3.17 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
# Setup development stack using gow
RUN go install github.com/mitranim/gow@latest
RUN echo 'gow run /build $@' > /usr/bin/mautrix-discord \
&& chmod +x /usr/bin/mautrix-discord
VOLUME /data

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,23 +1,24 @@
# Features & roadmap
* Matrix → Discord
* [x] Message content
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [x] Message redactions
* [x] Reactions
* [x] Unicode emojis
* [ ] Message content
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Custom 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
@@ -30,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 (not yet supported on Matrix)
* [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
@@ -68,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,309 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"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"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/ffmpeg"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(url string) ([]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 := http.DefaultClient.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: %s", resp.StatusCode, data)
}
return io.ReadAll(resp.Body)
}
func uploadDiscordAttachment(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.DroidFetchHeaders {
req.Header.Set(key, value)
}
resp, err := http.DefaultClient.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) (*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.UnstableCreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.UnstableMXC = resp.ContentURI
req.UploadURL = resp.UploadURL
go func() {
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.UnstableMXC, 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 := !encrypt
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &util.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
}
}
var data []byte
data, onceErr = downloadDiscordAttachment(url)
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)
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 {
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.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err)
return id.ContentURI{}
}
return dbFile.MXC
}

View File

@@ -1,40 +0,0 @@
package main
import (
"fmt"
"io"
"net/http"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
"github.com/bwmarrin/discordgo"
)
func uploadAvatar(intent *appservice.IntentAPI, url string) (id.ContentURI, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return id.ContentURI{}, fmt.Errorf("failed to prepare request: %w", err)
}
for key, value := range discordgo.DroidImageHeaders {
req.Header.Set(key, value)
}
getResp, err := http.DefaultClient.Do(req)
if err != nil {
return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
}
data, err := io.ReadAll(getResp.Body)
_ = getResp.Body.Close()
if err != nil {
return id.ContentURI{}, fmt.Errorf("failed to read avatar data: %w", err)
}
mime := http.DetectContentType(data)
resp, err := intent.UploadBytes(data, mime)
if err != nil {
return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
}
return resp.ContentURI, nil
}

View File

@@ -1,303 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"sort"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func (portal *Portal) forwardBackfillInitial(source *User) {
defer 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 limit == 0 {
return
}
log := portal.zlog.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
portal.backfillLimited(log, source, limit, "")
}
func (portal *Portal) ForwardBackfillMissed(source *User, meta *discordgo.Channel) {
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 limit == 0 {
return
}
log := portal.zlog.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
lastMessage := portal.bridge.DB.Message.GetLast(portal.Key)
if lastMessage == nil || meta.LastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, meta.LastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", meta.LastMessageID).
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", meta.LastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, before, "", "")
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) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after)
if err != nil {
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 {
_, 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)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string) {
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, "", after, "")
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)
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) {
if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, nil)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message) {
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.Message, 0, len(messages))
for _, msg := range messages {
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
puppet.UpdateInfo(nil, mention)
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, msg.MessageReference, true)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
parts := portal.convertDiscordMessage(intent, msg)
for i, part := range parts {
if replyTo != nil {
part.Content.RelatesTo = &event.RelatesTo{InReplyTo: replyTo}
// Only set reply for first event
replyTo = nil
}
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,
})
}
}
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().BatchSend(portal.MXID, &mautrix.ReqBatchSend{
BeeperNewMessages: 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
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
log.Info().Msg("Inserted backfilled batch to database")
}
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 "$@"

View File

@@ -0,0 +1,44 @@
// 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 (
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"go.mau.fi/mautrix-discord/pkg/connector"
)
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var c = &connector.DiscordConnector{}
var m = mxmain.BridgeMain{
Name: "mautrix-discord",
Description: "A Matrix-Discord puppeting bridge",
URL: "https://github.com/mautrix/discord",
Version: "26.03",
SemCalVer: true,
Connector: c,
}
func main() {
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}

View File

@@ -1,860 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"html"
"net/http"
"strconv"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/skip2/go-qrcode"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth"
)
type WrappedCommandEvent struct {
*commands.Event
Bridge *DiscordBridge
User *User
Portal *Portal
}
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
func (br *DiscordBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers(
cmdLoginToken,
cmdLoginQR,
cmdLogout,
cmdPing,
cmdReconnect,
cmdDisconnect,
cmdBridge,
cmdUnbridge,
cmdDeletePortal,
cmdSetRelay,
cmdUnsetRelay,
cmdGuilds,
cmdRejoinSpace,
cmdDeleteAllPortals,
cmdExec,
cmdCommands,
)
}
func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) {
return func(ce *commands.Event) {
user := ce.User.(*User)
var portal *Portal
if ce.Portal != nil {
portal = ce.Portal.(*Portal)
}
br := ce.Bridge.Child.(*DiscordBridge)
handler(&WrappedCommandEvent{ce, br, user, portal})
}
}
var cmdLoginToken = &commands.FullHandler{
Func: wrapCommand(fnLoginToken),
Name: "login-token",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by extracting the access token manually.",
Args: "<user/bot/oauth> <_token_>",
},
}
const discordTokenEpoch = 1293840000
func decodeToken(token string) (userID int64, err error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
err = fmt.Errorf("invalid number of parts in token")
return
}
var userIDStr []byte
userIDStr, err = base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
err = fmt.Errorf("invalid base64 in user ID part: %w", err)
return
}
_, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
err = fmt.Errorf("invalid base64 in random part: %w", err)
return
}
_, err = base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
err = fmt.Errorf("invalid base64 in checksum part: %w", err)
return
}
userID, err = strconv.ParseInt(string(userIDStr), 10, 64)
if err != nil {
err = fmt.Errorf("invalid number in decoded user ID part: %w", err)
return
}
return
}
func fnLoginToken(ce *WrappedCommandEvent) {
if len(ce.Args) != 2 {
ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
return
}
ce.MarkRead()
defer ce.Redact()
if ce.User.IsLoggedIn() {
ce.Reply("You're already logged in")
return
}
token := ce.Args[1]
userID, err := decodeToken(token)
if err != nil {
ce.Reply("Invalid token")
return
}
switch strings.ToLower(ce.Args[0]) {
case "user":
// Token is used as-is
case "bot":
token = "Bot " + token
case "oauth":
token = "Bearer " + token
default:
ce.Reply("Token type must be `user`, `bot` or `oauth`")
return
}
ce.Reply("Connecting to Discord as user ID %d", userID)
if err = ce.User.Login(token); err != nil {
ce.Reply("Error connecting to Discord: %v", err)
return
}
ce.Reply("Successfully logged in as %s#%s", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator)
}
var cmdLoginQR = &commands.FullHandler{
Func: wrapCommand(fnLoginQR),
Name: "login-qr",
Aliases: []string{"login"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by scanning a QR code.",
},
}
func fnLoginQR(ce *WrappedCommandEvent) {
if ce.User.IsLoggedIn() {
ce.Reply("You're already logged in")
return
}
client, err := remoteauth.New()
if err != nil {
ce.Reply("Failed to prepare login: %v", err)
return
}
qrChan := make(chan string)
doneChan := make(chan struct{})
var qrCodeEvent id.EventID
go func() {
code := <-qrChan
resp := sendQRCode(ce, code)
qrCodeEvent = resp
}()
ctx := context.Background()
if err = client.Dial(ctx, qrChan, doneChan); err != nil {
close(qrChan)
close(doneChan)
ce.Reply("Error connecting to login websocket: %v", err)
return
}
<-doneChan
if qrCodeEvent != "" {
_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)
}
user, err := client.Result()
if err != nil || len(user.Token) == 0 {
if restErr := (&discordgo.RESTError{}); errors.As(err, &restErr) &&
restErr.Response.StatusCode == http.StatusBadRequest &&
bytes.Contains(restErr.ResponseBody, []byte("captcha-required")) {
ce.Reply("Error logging in: %v\n\nCAPTCHAs are currently not supported - use token login instead", err)
} else {
ce.Reply("Error logging in: %v", err)
}
return
} else if err = ce.User.Login(user.Token); err != nil {
ce.Reply("Error connecting after login: %v", err)
return
}
ce.User.Lock()
ce.User.DiscordID = user.UserID
ce.User.Update()
ce.User.Unlock()
ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
}
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
url, ok := uploadQRCode(ce, code)
if !ok {
return ""
}
content := event.MessageEventContent{
MsgType: event.MsgImage,
Body: code,
URL: url.CUString(),
}
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
if err != nil {
ce.Log.Errorfln("Failed to send QR code: %v", err)
return ""
}
return resp.EventID
}
func uploadQRCode(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
if err != nil {
ce.Log.Errorln("Failed to encode QR code:", err)
ce.Reply("Failed to encode QR code: %v", err)
return id.ContentURI{}, false
}
resp, err := ce.Bot.UploadBytes(qrCode, "image/png")
if err != nil {
ce.Log.Errorln("Failed to upload QR code:", err)
ce.Reply("Failed to upload QR code: %v", err)
return id.ContentURI{}, false
}
return resp.ContentURI, true
}
var cmdLogout = &commands.FullHandler{
Func: wrapCommand(fnLogout),
Name: "logout",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Forget the stored Discord auth token.",
},
}
func fnLogout(ce *WrappedCommandEvent) {
wasLoggedIn := ce.User.DiscordID != ""
ce.User.Logout(false)
if wasLoggedIn {
ce.Reply("Logged out successfully.")
} else {
ce.Reply("You weren't logged in, but data was re-cleared just to be safe.")
}
}
var cmdPing = &commands.FullHandler{
Func: wrapCommand(fnPing),
Name: "ping",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Check your connection to Discord",
},
}
func fnPing(ce *WrappedCommandEvent) {
if ce.User.Session == nil {
if ce.User.DiscordToken == "" {
ce.Reply("You're not logged in")
} else {
ce.Reply("You have a Discord token stored, but are not connected for some reason 🤔")
}
} else if ce.User.wasDisconnected {
ce.Reply("You're logged in, but the Discord connection seems to be dead 💥")
} else {
ce.Reply("You're logged in as %s#%s (`%s`)", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator, ce.User.DiscordID)
}
}
var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect),
Name: "disconnect",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Disconnect from Discord (without logging out)",
},
RequiresLogin: true,
}
func fnDisconnect(ce *WrappedCommandEvent) {
if !ce.User.Connected() {
ce.Reply("You're already not connected")
} else if err := ce.User.Disconnect(); err != nil {
ce.Reply("Error while disconnecting: %v", err)
} else {
ce.Reply("Successfully disconnected")
}
}
var cmdReconnect = &commands.FullHandler{
Func: wrapCommand(fnReconnect),
Name: "reconnect",
Aliases: []string{"connect"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Reconnect to Discord after disconnecting",
},
RequiresLogin: true,
}
func fnReconnect(ce *WrappedCommandEvent) {
if ce.User.Connected() {
ce.Reply("You're already connected")
} else if err := ce.User.Connect(); err != nil {
ce.Reply("Error while reconnecting: %v", err)
} else {
ce.Reply("Successfully reconnected")
}
}
var cmdRejoinSpace = &commands.FullHandler{
Func: wrapCommand(fnRejoinSpace),
Name: "rejoin-space",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Ask the bridge for an invite to a space you left",
Args: "<_guild ID_/main/dms>",
},
RequiresLogin: true,
}
func fnRejoinSpace(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
return
}
user := ce.User
if ce.Args[0] == "main" {
user.ensureInvited(nil, user.GetSpaceRoom(), false)
ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if ce.Args[0] == "dms" {
user.ensureInvited(nil, user.GetDMSpaceRoom(), false)
ce.Reply("Invited you to your DM space ([link](%s))", user.GetDMSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
ce.Reply("Rejoining guild spaces is not yet implemented")
} else {
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
return
}
}
var roomModerator = event.Type{Type: "fi.mau.discord.admin", Class: event.StateEventType}
var cmdSetRelay = &commands.FullHandler{
Func: wrapCommand(fnSetRelay),
Name: "set-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Create or set a relay webhook for a portal",
Args: "[room ID] <--url URL> OR <--create [name]>",
},
RequiresLogin: true,
RequiresEventLevel: roomModerator,
}
const webhookURLFormat = "https://discord.com/api/webhooks/%d/%s"
const selectRelayHelp = "Usage: `$cmdprefix [room ID] <--url URL> OR <--create [name]>`"
func fnSetRelay(ce *WrappedCommandEvent) {
portal := ce.Portal
if len(ce.Args) > 0 && strings.HasPrefix(ce.Args[0], "!") {
portal = ce.Bridge.GetPortalByMXID(id.RoomID(ce.Args[0]))
if portal == nil {
ce.Reply("Portal with room ID %s not found", ce.Args[0])
return
}
if ce.User.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
levels, err := portal.MainIntent().PowerLevels(ce.RoomID)
if err != nil {
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
ce.Reply("Failed to get room power levels to see if you're allowed to use that command")
return
} else if levels.GetUserLevel(ce.User.GetMXID()) < levels.GetEventLevel(roomModerator) {
ce.Reply("You don't have admin rights in that room")
return
}
}
ce.Args = ce.Args[1:]
} else if portal == nil {
ce.Reply("You must either run the command in a portal, or specify an internal room ID as the first parameter")
return
}
log := ce.ZLog.With().Str("channel_id", portal.Key.ChannelID).Logger()
if portal.GuildID == "" {
ce.Reply("Only guild channels can have relays")
return
} else if portal.RelayWebhookID != "" {
webhookMeta, err := relayClient.WebhookWithToken(portal.RelayWebhookID, portal.RelayWebhookSecret)
if err != nil {
log.Warn().Err(err).Msg("Failed to get existing webhook info")
ce.Reply("This channel has a relay webhook set, but getting its info failed: %v", err)
return
}
ce.Reply("This channel already has a relay webhook %s (%s)", webhookMeta.Name, webhookMeta.ID)
return
} else if len(ce.Args) == 0 {
ce.Reply(selectRelayHelp)
return
}
createType := strings.ToLower(strings.TrimLeft(ce.Args[0], "-"))
var webhookMeta *discordgo.Webhook
switch createType {
case "url":
if len(ce.Args) < 2 {
ce.Reply("Usage: `$cmdprefix [room ID] --url <URL>")
return
}
ce.Redact()
var webhookID int64
var webhookSecret string
_, err := fmt.Sscanf(ce.Args[1], webhookURLFormat, &webhookID, &webhookSecret)
if err != nil {
log.Warn().Str("webhook_url", ce.Args[1]).Err(err).Msg("Failed to parse provided webhook URL")
ce.Reply("Invalid webhook URL")
return
}
webhookMeta, err = relayClient.WebhookWithToken(strconv.FormatInt(webhookID, 10), webhookSecret)
if err != nil {
log.Warn().Err(err).Msg("Failed to get webhook info")
ce.Reply("Failed to get webhook info: %v", err)
return
}
case "create":
perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID)
if err != nil {
log.Warn().Err(err).Msg("Failed to check user permissions")
ce.Reply("Failed to check if you have permission to create webhooks")
return
} else if perms&discordgo.PermissionManageWebhooks == 0 {
log.Debug().Int64("perms", perms).Msg("User doesn't have permissions to manage webhooks in channel")
ce.Reply("You don't have permission to manage webhooks in that channel")
return
}
name := "mautrix"
if len(ce.Args) > 1 {
name = strings.Join(ce.Args[1:], " ")
}
log.Debug().Str("webhook_name", name).Msg("Creating webhook")
webhookMeta, err = ce.User.Session.WebhookCreate(portal.Key.ChannelID, name, "")
if err != nil {
log.Warn().Err(err).Msg("Failed to create webhook")
ce.Reply("Failed to create webhook: %v", err)
return
}
default:
ce.Reply(selectRelayHelp)
return
}
if portal.Key.ChannelID != webhookMeta.ChannelID {
log.Debug().
Str("portal_channel_id", portal.Key.ChannelID).
Str("webhook_channel_id", webhookMeta.ChannelID).
Msg("Provided webhook is for wrong channel")
ce.Reply("That webhook is not for the right channel (expected %s, webhook is for %s)", portal.Key.ChannelID, webhookMeta.ChannelID)
return
}
log.Debug().Str("webhook_id", webhookMeta.ID).Msg("Setting portal relay webhook")
portal.RelayWebhookID = webhookMeta.ID
portal.RelayWebhookSecret = webhookMeta.Token
portal.Update()
ce.Reply("Saved webhook %s (%s) as portal relay webhook", webhookMeta.Name, portal.RelayWebhookID)
}
var cmdUnsetRelay = &commands.FullHandler{
Func: wrapCommand(fnUnsetRelay),
Name: "unset-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Disable the relay webhook and optionally delete it on Discord",
Args: "[--delete]",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
func fnUnsetRelay(ce *WrappedCommandEvent) {
if ce.Portal.RelayWebhookID == "" {
ce.Reply("This portal doesn't have a relay webhook")
return
}
if len(ce.Args) > 0 && ce.Args[0] == "--delete" {
err := relayClient.WebhookDeleteWithToken(ce.Portal.RelayWebhookID, ce.Portal.RelayWebhookSecret)
if err != nil {
ce.Reply("Failed to delete webhook: %v", err)
return
} else {
ce.Reply("Successfully deleted webhook")
}
} else {
ce.Reply("Relay webhook disabled")
}
ce.Portal.RelayWebhookID = ""
ce.Portal.RelayWebhookSecret = ""
ce.Portal.Update()
}
var cmdGuilds = &commands.FullHandler{
Func: wrapCommand(fnGuilds),
Name: "guilds",
Aliases: []string{"servers", "guild", "server"},
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Guild bridging management",
Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
},
RequiresLogin: true,
}
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [...]`"
const fullGuildsHelp = smallGuildsHelp + `
* **help** - View this help message.
* **status** - View the list of guilds and their bridging status.
* **bridge <_guild ID_> [--entire]** - Enable bridging for a guild. The --entire flag auto-creates portals for all channels.
* **bridging-mode <_guild ID_> <_mode_>** - Set the mode for bridging messages and new channels in a guild.
* **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.`
func fnGuilds(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply(fullGuildsHelp)
return
}
subcommand := strings.ToLower(ce.Args[0])
ce.Args = ce.Args[1:]
switch subcommand {
case "status", "list":
fnListGuilds(ce)
case "bridge":
fnBridgeGuild(ce)
case "unbridge", "delete":
fnUnbridgeGuild(ce)
case "bridging-mode", "mode":
fnGuildBridgingMode(ce)
case "help":
ce.Reply(fullGuildsHelp)
default:
ce.Reply("Unknown subcommand `%s`\n\n"+smallGuildsHelp, subcommand)
}
}
func fnListGuilds(ce *WrappedCommandEvent) {
var items []string
for _, userGuild := range ce.User.GetPortals() {
guild := ce.Bridge.GetGuildByID(userGuild.DiscordID, false)
if guild == nil {
continue
}
var avatarHTML string
if !guild.AvatarURL.IsEmpty() {
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String())
}
items = append(items, fmt.Sprintf("<li>%s%s (<code>%s</code>) - %s</li>", avatarHTML, html.EscapeString(guild.Name), guild.ID, guild.BridgingMode.Description()))
}
if len(items) == 0 {
ce.Reply("No guilds found")
} else {
ce.ReplyAdvanced(fmt.Sprintf("<p>List of guilds:</p><ul>%s</ul>", strings.Join(items, "")), false, true)
}
}
func fnBridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 || len(ce.Args) > 2 {
ce.Reply("**Usage**: `$cmdprefix guilds bridge <guild ID> [--entire]")
} else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
ce.Reply("Error bridging guild: %v", err)
} else {
ce.Reply("Successfully bridged guild")
}
}
func fnUnbridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) != 1 {
ce.Reply("**Usage**: `$cmdprefix guilds unbridge <guild ID>")
} else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
ce.Reply("Error unbridging guild: %v", err)
} else {
ce.Reply("Successfully unbridged guild")
}
}
const availableModes = "Available modes:\n" +
"* `nothing` to never bridge any messages (default when unbridged)\n" +
"* `if-portal-exists` to bridge messages in existing portals, but drop messages in unbridged channels\n" +
"* `create-on-message` to bridge all messages and create portals if necessary on incoming messages (default after bridging)\n" +
"* `everything` to bridge all messages and create portals proactively on bridge startup (default if bridged with `--entire`)\n"
func fnGuildBridgingMode(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 || len(ce.Args) > 2 {
ce.Reply("**Usage**: `$cmdprefix guilds bridging-mode <guild ID> [mode]`\n\n" + availableModes)
return
}
guild := ce.Bridge.GetGuildByID(ce.Args[0], false)
if guild == nil {
ce.Reply("Guild not found")
return
}
if len(ce.Args) == 1 {
ce.Reply("%s (%s) is currently set to %s (`%s`)\n\n%s", guild.PlainName, guild.ID, guild.BridgingMode.Description(), guild.BridgingMode.String(), availableModes)
return
}
mode := database.ParseGuildBridgingMode(ce.Args[1])
if mode == database.GuildBridgeInvalid {
ce.Reply("Invalid guild bridging mode `%s`", ce.Args[1])
return
}
guild.BridgingMode = mode
guild.Update()
ce.Reply("Set guild bridging mode to %s", mode.Description())
}
var cmdBridge = &commands.FullHandler{
Func: wrapCommand(fnBridge),
Name: "bridge",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Bridge this room to a specific Discord channel",
Args: "[--replace[=delete]] <_channel ID_>",
},
RequiresEventLevel: roomModerator,
}
func isNumber(str string) bool {
for _, chr := range str {
if chr < '0' || chr > '9' {
return false
}
}
return true
}
func fnBridge(ce *WrappedCommandEvent) {
if ce.Portal != nil {
ce.Reply("This is already a portal room. Unbridge with `$cmdprefix unbridge` first if you want to link it to a different channel.")
return
}
var channelID string
var unbridgeOld, deleteOld bool
fail := true
for _, arg := range ce.Args {
arg = strings.ToLower(arg)
if arg == "--replace" {
unbridgeOld = true
} else if arg == "--replace=delete" {
unbridgeOld = true
deleteOld = true
} else if channelID == "" && isNumber(arg) {
channelID = arg
fail = false
} else {
fail = true
break
}
}
if fail {
ce.Reply("**Usage**: `$cmdprefix bridge [--replace[=delete]] <channel ID>`")
return
}
portal := ce.User.GetExistingPortalByID(channelID)
if portal == nil {
ce.Reply("Channel not found")
return
}
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
if portal.MXID != "" {
hasUnbridgePermission := ce.User.PermissionLevel >= bridgeconfig.PermissionLevelAdmin
if !hasUnbridgePermission {
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if errors.Is(err, mautrix.MNotFound) {
ce.ZLog.Debug().Err(err).Msg("Got M_NOT_FOUND trying to get power levels to check if user can unbridge it, assuming the room is gone")
hasUnbridgePermission = true
} else if err != nil {
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
ce.Reply("Failed to get power levels in old room to see if you're allowed to unbridge it")
return
} else {
hasUnbridgePermission = levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(roomModerator)
}
}
if !unbridgeOld || !hasUnbridgePermission {
extraHelp := "Rerun the command with `--replace` or `--replace=delete` to unbridge the old room."
if !hasUnbridgePermission {
extraHelp = "Additionally, you do not have the permissions to unbridge the old room."
}
ce.Reply("That channel is already bridged to [%s](https://matrix.to/#/%s). %s", portal.Name, portal.MXID, extraHelp)
return
}
ce.ZLog.Debug().
Str("old_room_id", portal.MXID.String()).
Bool("delete", deleteOld).
Msg("Unbridging old room")
portal.removeFromSpace()
portal.cleanup(!deleteOld)
portal.RemoveMXID()
ce.ZLog.Info().
Str("old_room_id", portal.MXID.String()).
Bool("delete", deleteOld).
Msg("Unbridged old room to make space for new bridge")
}
if portal.Guild != nil && portal.Guild.BridgingMode < database.GuildBridgeIfPortalExists {
ce.ZLog.Debug().Str("guild_id", portal.Guild.ID).Msg("Bumping bridging mode of portal guild to if-portal-exists")
portal.Guild.BridgingMode = database.GuildBridgeIfPortalExists
portal.Guild.Update()
}
ce.ZLog.Debug().Str("channel_id", portal.Key.ChannelID).Msg("Bridging room")
portal.MXID = ce.RoomID
portal.bridge.portalsLock.Lock()
portal.bridge.portalsByMXID[portal.MXID] = portal
portal.bridge.portalsLock.Unlock()
portal.updateRoomName()
portal.updateRoomAvatar()
portal.updateRoomTopic()
portal.updateSpace()
portal.UpdateBridgeInfo()
state, err := portal.MainIntent().State(portal.MXID)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to update state cache for room")
} else {
encryptionEvent, isEncrypted := state[event.StateEncryption][""]
portal.Encrypted = isEncrypted && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1
}
portal.Update()
ce.Reply("Room successfully bridged")
ce.ZLog.Info().
Str("channel_id", portal.Key.ChannelID).
Bool("encrypted", portal.Encrypted).
Msg("Manual bridging complete")
}
var cmdUnbridge = &commands.FullHandler{
Func: wrapCommand(fnUnbridge),
Name: "unbridge",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Unbridge this room from the linked Discord channel",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
var cmdDeletePortal = &commands.FullHandler{
Func: wrapCommand(fnUnbridge),
Name: "delete-portal",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Unbridge this room and kick all Matrix users",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
func fnUnbridge(ce *WrappedCommandEvent) {
ce.Portal.roomCreateLock.Lock()
defer ce.Portal.roomCreateLock.Unlock()
ce.Portal.removeFromSpace()
ce.Portal.cleanup(ce.Command == "unbridge")
ce.Portal.RemoveMXID()
}
var cmdDeleteAllPortals = &commands.FullHandler{
Func: wrapCommand(fnDeleteAllPortals),
Name: "delete-all-portals",
Help: commands.HelpMeta{
Section: commands.HelpSectionAdmin,
Description: "Delete all portals.",
},
RequiresAdmin: true,
}
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortals()
guilds := ce.Bridge.GetAllGuilds()
if len(portals) == 0 && len(guilds) == 0 {
ce.Reply("Didn't find any portals")
return
}
leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
Reason: "Deleting portal",
UserID: ce.User.MXID,
})
}
}
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent()
leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.LeaveRoom(mxid)
_, _ = intent.ForgetRoom(mxid)
}
}
}
ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
for _, portal := range portals {
portal.Delete()
leave(portal.MXID, portal.MainIntent())
}
for _, guild := range guilds {
guild.Delete()
leave(guild.MXID, ce.Bot)
}
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. You'll have to restart the bridge or relogin before rooms can be bridged again.")
go func() {
for _, portal := range portals {
portal.cleanup(false)
}
ce.Reply("Finished background cleanup of deleted portal rooms.")
}()
}

View File

@@ -1,318 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2023 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 (
"fmt"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/google/shlex"
"maunium.net/go/mautrix/bridge/commands"
)
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
var cmdCommands = &commands.FullHandler{
Func: wrapCommand(fnCommands),
Name: "commands",
Aliases: []string{"cmds", "cs"},
Help: commands.HelpMeta{
Section: HelpSectionDiscordBots,
Description: "View parameters of bot interaction commands on Discord",
Args: "search <_query_> OR help <_command_>",
},
RequiresPortal: true,
RequiresLogin: true,
}
var cmdExec = &commands.FullHandler{
Func: wrapCommand(fnExec),
Name: "exec",
Aliases: []string{"command", "cmd", "c", "exec", "e"},
Help: commands.HelpMeta{
Section: HelpSectionDiscordBots,
Description: "Run bot interaction commands on Discord",
Args: "<_command_> [_arg=value ..._]",
},
RequiresLogin: true,
RequiresPortal: true,
}
func (portal *Portal) getCommand(user *User, command string) (*discordgo.ApplicationCommand, error) {
portal.commandsLock.Lock()
defer portal.commandsLock.Unlock()
cmd, ok := portal.commands[command]
if !ok {
results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command)
if err != nil {
return nil, err
}
for _, result := range results {
if result.Name == command {
portal.commands[result.Name] = result
cmd = result
break
}
}
if cmd == nil {
return nil, nil
}
}
return cmd, nil
}
func getCommandOptionTypeName(optType discordgo.ApplicationCommandOptionType) string {
switch optType {
case discordgo.ApplicationCommandOptionSubCommand:
return "subcommand"
case discordgo.ApplicationCommandOptionSubCommandGroup:
return "subcommand group (unsupported)"
case discordgo.ApplicationCommandOptionString:
return "string"
case discordgo.ApplicationCommandOptionInteger:
return "integer"
case discordgo.ApplicationCommandOptionBoolean:
return "boolean"
case discordgo.ApplicationCommandOptionUser:
return "user (unsupported)"
case discordgo.ApplicationCommandOptionChannel:
return "channel (unsupported)"
case discordgo.ApplicationCommandOptionRole:
return "role (unsupported)"
case discordgo.ApplicationCommandOptionMentionable:
return "mentionable (unsupported)"
case discordgo.ApplicationCommandOptionNumber:
return "number"
case discordgo.ApplicationCommandOptionAttachment:
return "attachment (unsupported)"
default:
return fmt.Sprintf("unknown type %d", optType)
}
}
func parseCommandOptionValue(optType discordgo.ApplicationCommandOptionType, value string) (any, error) {
switch optType {
case discordgo.ApplicationCommandOptionSubCommandGroup:
return nil, fmt.Errorf("subcommand groups aren't supported")
case discordgo.ApplicationCommandOptionString:
return value, nil
case discordgo.ApplicationCommandOptionInteger:
return strconv.ParseInt(value, 10, 64)
case discordgo.ApplicationCommandOptionBoolean:
return strconv.ParseBool(value)
case discordgo.ApplicationCommandOptionUser:
return nil, fmt.Errorf("user options aren't supported")
case discordgo.ApplicationCommandOptionChannel:
return nil, fmt.Errorf("channel options aren't supported")
case discordgo.ApplicationCommandOptionRole:
return nil, fmt.Errorf("role options aren't supported")
case discordgo.ApplicationCommandOptionMentionable:
return nil, fmt.Errorf("mentionable options aren't supported")
case discordgo.ApplicationCommandOptionNumber:
return strconv.ParseFloat(value, 64)
case discordgo.ApplicationCommandOptionAttachment:
return nil, fmt.Errorf("attachment options aren't supported")
default:
return nil, fmt.Errorf("unknown option type %d", optType)
}
}
func indent(text, with string) string {
split := strings.Split(text, "\n")
for i, part := range split {
split[i] = with + part
}
return strings.Join(split, "\n")
}
func formatOption(opt *discordgo.ApplicationCommandOption) string {
argText := fmt.Sprintf("* `%s`: %s", opt.Name, getCommandOptionTypeName(opt.Type))
if strings.ToLower(opt.Description) != opt.Name {
argText += fmt.Sprintf(" - %s", opt.Description)
}
if opt.Required {
argText += " (required)"
}
if len(opt.Options) > 0 {
subopts := make([]string, len(opt.Options))
for i, subopt := range opt.Options {
subopts[i] = indent(formatOption(subopt), " ")
}
argText += "\n" + strings.Join(subopts, "\n")
}
return argText
}
func formatCommand(cmd *discordgo.ApplicationCommand) string {
baseText := fmt.Sprintf("$cmdprefix exec %s", cmd.Name)
if len(cmd.Options) > 0 {
args := make([]string, len(cmd.Options))
argPlaceholder := "[arg=value ...]"
for i, opt := range cmd.Options {
args[i] = formatOption(opt)
if opt.Required {
argPlaceholder = "<arg=value ...>"
}
}
baseText = fmt.Sprintf("`%s %s` - %s\n%s", baseText, argPlaceholder, cmd.Description, strings.Join(args, "\n"))
} else {
baseText = fmt.Sprintf("`%s` - %s", baseText, cmd.Description)
}
return baseText
}
func parseCommandOptions(opts []*discordgo.ApplicationCommandOption, subcommands []string, namedArgs map[string]string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
subcommandDone := false
for _, opt := range opts {
optRes := &discordgo.ApplicationCommandOptionInput{
Type: opt.Type,
Name: opt.Name,
}
if opt.Type == discordgo.ApplicationCommandOptionSubCommand {
if !subcommandDone && len(subcommands) > 0 && subcommands[0] == opt.Name {
subcommandDone = true
optRes.Options, err = parseCommandOptions(opt.Options, subcommands[1:], namedArgs)
if err != nil {
err = fmt.Errorf("error parsing subcommand %s: %v", opt.Name, err)
break
}
subcommands = subcommands[1:]
} else {
continue
}
} else if argVal, ok := namedArgs[opt.Name]; ok {
optRes.Value, err = parseCommandOptionValue(opt.Type, argVal)
if err != nil {
err = fmt.Errorf("error parsing parameter %s: %v", opt.Name, err)
break
}
} else if opt.Required {
switch opt.Type {
case discordgo.ApplicationCommandOptionSubCommandGroup, discordgo.ApplicationCommandOptionUser,
discordgo.ApplicationCommandOptionChannel, discordgo.ApplicationCommandOptionRole,
discordgo.ApplicationCommandOptionMentionable, discordgo.ApplicationCommandOptionAttachment:
err = fmt.Errorf("missing required parameter %s (which is not supported by the bridge)", opt.Name)
default:
err = fmt.Errorf("missing required parameter %s", opt.Name)
}
break
} else {
continue
}
res = append(res, optRes)
}
if len(subcommands) > 0 {
err = fmt.Errorf("unparsed subcommands left over (did you forget quoting for parameters with spaces?)")
}
return
}
func executeCommand(cmd *discordgo.ApplicationCommand, args []string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
namedArgs := map[string]string{}
n := 0
for _, arg := range args {
name, value, isNamed := strings.Cut(arg, "=")
if isNamed {
namedArgs[name] = value
} else {
args[n] = arg
n++
}
}
return parseCommandOptions(cmd.Options, args[:n], namedArgs)
}
func fnCommands(ce *WrappedCommandEvent) {
if len(ce.Args) < 2 {
ce.Reply("**Usage**: `$cmdprefix commands search <_query_>` OR `$cmdprefix commands help <_command_>`")
return
}
subcmd := strings.ToLower(ce.Args[0])
if subcmd == "search" {
results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1])
if err != nil {
ce.Reply("Error searching for commands: %v", err)
return
}
formatted := make([]string, len(results))
ce.Portal.commandsLock.Lock()
for i, result := range results {
ce.Portal.commands[result.Name] = result
formatted[i] = indent(formatCommand(result), " ")
formatted[i] = "*" + formatted[i][1:]
}
ce.Portal.commandsLock.Unlock()
ce.Reply("Found results:\n" + strings.Join(formatted, "\n"))
} else if subcmd == "help" {
command := strings.ToLower(ce.Args[1])
cmd, err := ce.Portal.getCommand(ce.User, command)
if err != nil {
ce.Reply("Error searching for commands: %v", err)
} else if cmd == nil {
ce.Reply("Command %q not found", command)
} else {
ce.Reply(formatCommand(cmd))
}
}
}
func fnExec(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage**: `$cmdprefix exec <command> [arg=value ...]`")
return
}
args, err := shlex.Split(ce.RawArgs)
if err != nil {
ce.Reply("Error parsing args with shlex: %v", err)
return
}
command := strings.ToLower(args[0])
cmd, err := ce.Portal.getCommand(ce.User, command)
if err != nil {
ce.Reply("Error searching for commands: %v", err)
} else if cmd == nil {
ce.Reply("Command %q not found", command)
} else if options, err := executeCommand(cmd, args[1:]); err != nil {
ce.Reply("Error parsing arguments: %v\n\n**Usage:** "+formatCommand(cmd), err)
} else {
nonce := generateNonce()
ce.User.pendingInteractionsLock.Lock()
ce.User.pendingInteractions[nonce] = ce
ce.User.pendingInteractionsLock.Unlock()
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce)
if err != nil {
ce.Reply("Error sending interaction: %v", err)
ce.User.pendingInteractionsLock.Lock()
delete(ce.User.pendingInteractions, nonce)
ce.User.pendingInteractionsLock.Unlock()
} else {
go func() {
time.Sleep(10 * time.Second)
ce.User.pendingInteractionsLock.Lock()
if _, stillWaiting := ce.User.pendingInteractions[nonce]; stillWaiting {
delete(ce.User.pendingInteractions, nonce)
ce.Reply("Timed out waiting for interaction success")
}
ce.User.pendingInteractionsLock.Unlock()
}()
}
}
}

View File

@@ -1,205 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 config
import (
"errors"
"fmt"
"strings"
"text/template"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
)
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
ChannelNameTemplate string `yaml:"channel_name_template"`
GuildNameTemplate string `yaml:"guild_name_template"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
RestrictedRooms bool `yaml:"restricted_rooms"`
AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"`
EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"`
MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
CustomEmojiReactions bool `yaml:"custom_emoji_reactions"`
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
FederateRooms bool `yaml:"federate_rooms"`
AnimatedSticker struct {
Target string `yaml:"target"`
Args struct {
Width int `yaml:"width"`
Height int `yaml:"height"`
FPS int `yaml:"fps"`
} `yaml:"args"`
} `yaml:"animated_sticker"`
DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"`
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
Backfill struct {
Limits struct {
Initial BackfillLimitPart `yaml:"initial"`
Missed BackfillLimitPart `yaml:"missed"`
} `yaml:"forward_limits"`
MaxGuildMembers int `yaml:"max_guild_members"`
} `yaml:"backfill"`
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
channelNameTemplate *template.Template `yaml:"-"`
guildNameTemplate *template.Template `yaml:"-"`
}
type BackfillLimitPart struct {
DM int `yaml:"dm"`
Channel int `yaml:"channel"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
return bc.MessageStatusEvents
}
func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
return bc.MessageErrorNotices
}
func boolToInt(val bool) int {
if val {
return 1
}
return 0
}
func (bc *BridgeConfig) Validate() error {
_, hasWildcard := bc.Permissions["*"]
_, hasExampleDomain := bc.Permissions["example.com"]
_, hasExampleUser := bc.Permissions["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
if len(bc.Permissions) <= exampleLen {
return errors.New("bridge.permissions not configured")
}
return nil
}
type umBridgeConfig BridgeConfig
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
return fmt.Errorf("username template is missing user ID placeholder")
}
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate)
if err != nil {
return err
}
bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate)
if err != nil {
return err
}
return nil
}
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) FormatUsername(userID string) string {
var buffer strings.Builder
_ = bc.usernameTemplate.Execute(&buffer, userID)
return buffer.String()
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, user)
return buffer.String()
}
type ChannelNameParams struct {
Name string
ParentName string
GuildName string
NSFW bool
Type discordgo.ChannelType
}
func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string {
var buffer strings.Builder
_ = bc.channelNameTemplate.Execute(&buffer, params)
return buffer.String()
}
type GuildNameParams struct {
Name string
}
func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string {
var buffer strings.Builder
_ = bc.guildNameTemplate.Execute(&buffer, params)
return buffer.String()
}

View File

@@ -1,124 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2023 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 config
import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/util"
up "maunium.net/go/mautrix/util/configupgrade"
)
func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
helper.Copy(up.Str|up.Null, "homeserver", "public_address")
helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channel_name_template")
helper.Copy(up.Str, "bridge", "guild_name_template")
if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
updatedPrivateChatPortalMeta := "default"
if legacyPrivateChatPortalMeta == "true" {
updatedPrivateChatPortalMeta = "always"
}
helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
} else {
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
}
helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit")
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "delivery_receipts")
helper.Copy(up.Bool, "bridge", "message_status_events")
helper.Copy(up.Bool, "bridge", "message_error_notices")
helper.Copy(up.Bool, "bridge", "restricted_rooms")
helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open")
helper.Copy(up.Bool, "bridge", "embed_fields_as_tables")
helper.Copy(up.Bool, "bridge", "mute_channels_on_create")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "custom_emoji_reactions")
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Str, "bridge", "animated_sticker", "target")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "backfill", "enabled")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := util.RandomString(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Map, "bridge", "permissions")
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
//helper.Copy(up.Bool, "bridge", "relay", "admin_only")
//helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
var SpacedBlocks = [][]string{
{"homeserver", "software"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"bridge"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
//{"bridge", "relay"},
{"logging"},
}

View File

@@ -1,170 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
var (
ErrNoCustomMXID = errors.New("no custom mxid set")
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
)
func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
_, homeserver, err := mxid.Parse()
if err != nil {
return nil, err
}
homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
if !found {
if homeserver == br.AS.HomeserverDomain {
homeserverURL = ""
} else if br.Config.Bridge.DoublePuppetAllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(homeserver)
if err != nil {
return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
}
homeserverURL = resp.Homeserver.BaseURL
br.Log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
} else {
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
}
}
return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL)
}
func (puppet *Puppet) clearCustomMXID() {
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
}
func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
if puppet.CustomMXID == "" {
return nil, ErrNoCustomMXID
}
client, err := puppet.bridge.newDoublePuppetClient(puppet.CustomMXID, puppet.AccessToken)
if err != nil {
return nil, err
}
ia := puppet.bridge.AS.NewIntentAPI("custom")
ia.Client = client
ia.Localpart, _, _ = puppet.CustomMXID.Parse()
ia.UserID = puppet.CustomMXID
ia.IsCustomPuppet = true
return ia, nil
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
if puppet.CustomMXID == "" {
puppet.clearCustomMXID()
return nil
}
intent, err := puppet.newCustomIntent()
if err != nil {
puppet.clearCustomMXID()
return err
}
resp, err := intent.Whoami()
if err != nil {
if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !puppet.tryRelogin(err, "initializing double puppeting")) {
puppet.clearCustomMXID()
return err
}
intent.AccessToken = puppet.AccessToken
} else if resp.UserID != puppet.CustomMXID {
puppet.clearCustomMXID()
return ErrMismatchingMXID
}
puppet.customIntent = intent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
return nil
}
func (puppet *Puppet) tryRelogin(cause error, action string) bool {
if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
return false
}
log := puppet.log.With().
AnErr("cause_error", cause).
Str("while_action", action).
Logger()
log.Debug().Msg("Trying to relogin")
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
if err != nil {
log.Error().Err(err).Msg("Failed to relogin")
return false
}
log.Info().Msg("Successfully relogined")
puppet.AccessToken = accessToken
puppet.Update()
return true
}
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse()
puppet.log.Debug().Str("user_id", mxid.String()).Msg("Logging into double puppet target with shared secret")
loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]
client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
if err != nil {
return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
}
req := mautrix.ReqLogin{
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
DeviceID: "Discord Bridge",
InitialDeviceDisplayName: "Discord Bridge",
}
if loginSecret == "appservice" {
client.AccessToken = puppet.bridge.AS.Registration.AppToken
req.Type = mautrix.AuthTypeAppservice
} else {
mac := hmac.New(sha512.New, []byte(loginSecret))
mac.Write([]byte(mxid))
req.Password = hex.EncodeToString(mac.Sum(nil))
req.Type = mautrix.AuthTypePassword
}
resp, err := client.Login(&req)
if err != nil {
return "", err
}
return resp.AccessToken, nil
}
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
prevCustomMXID := puppet.CustomMXID
puppet.CustomMXID = mxid
puppet.AccessToken = accessToken
err := puppet.StartCustomMXID(false)
if err != nil {
return err
}
if prevCustomMXID != "" {
delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
}
if puppet.CustomMXID != "" {
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID)
puppet.Update()
// TODO leave rooms with default puppet
return nil
}

View File

@@ -1,76 +0,0 @@
package database
import (
_ "embed"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
"go.mau.fi/mautrix-discord/database/upgrades"
)
type Database struct {
*dbutil.Database
User *UserQuery
Portal *PortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Thread *ThreadQuery
Reaction *ReactionQuery
Guild *GuildQuery
Role *RoleQuery
File *FileQuery
}
func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
db := &Database{Database: baseDB}
db.UpgradeTable = upgrades.Table
db.User = &UserQuery{
db: db,
log: log.Sub("User"),
}
db.Portal = &PortalQuery{
db: db,
log: log.Sub("Portal"),
}
db.Puppet = &PuppetQuery{
db: db,
log: log.Sub("Puppet"),
}
db.Message = &MessageQuery{
db: db,
log: log.Sub("Message"),
}
db.Thread = &ThreadQuery{
db: db,
log: log.Sub("Thread"),
}
db.Reaction = &ReactionQuery{
db: db,
log: log.Sub("Reaction"),
}
db.Guild = &GuildQuery{
db: db,
log: log.Sub("Guild"),
}
db.Role = &RoleQuery{
db: db,
log: log.Sub("Role"),
}
db.File = &FileQuery{
db: db,
log: log.Sub("File"),
}
return db
}
func strPtr(val string) *string {
if val == "" {
return nil
}
return &val
}

View File

@@ -1,139 +0,0 @@
package database
import (
"database/sql"
"encoding/json"
"errors"
"time"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type FileQuery struct {
db *Database
log log.Logger
}
// language=postgresql
const (
fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
fileInsert = `
INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
)
func (fq *FileQuery) New() *File {
return &File{
db: fq.db,
log: fq.log,
}
}
func (fq *FileQuery) Get(url string, encrypted bool) *File {
query := fileSelect + " WHERE url=$1 AND encrypted=$2"
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
}
func (fq *FileQuery) GetByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1"
return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
}
type File struct {
db *Database
log log.Logger
URL string
Encrypted bool
MXC id.ContentURI
ID string
EmojiName string
Size int
Width int
Height int
MimeType string
DecryptionInfo *attachment.EncryptedFile
Timestamp time.Time
}
func (f *File) Scan(row dbutil.Scannable) *File {
var fileID, emojiName, decryptionInfo sql.NullString
var width, height sql.NullInt32
var timestamp int64
var mxc string
err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, &timestamp)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
f.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
f.ID = fileID.String
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp)
f.Width = int(width.Int32)
f.Height = int(height.Int32)
f.MXC, err = id.ParseContentURI(mxc)
if err != nil {
f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err)
panic(err)
}
if decryptionInfo.Valid {
err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo)
if err != nil {
f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err)
panic(err)
}
}
return f
}
func positiveIntToNullInt32(val int) (ptr sql.NullInt32) {
if val > 0 {
ptr.Valid = true
ptr.Int32 = int32(val)
}
return
}
func (f *File) Insert(txn dbutil.Execable) {
if txn == nil {
txn = f.db
}
var decryptionInfoStr sql.NullString
if f.DecryptionInfo != nil {
decryptionInfo, err := json.Marshal(f.DecryptionInfo)
if err != nil {
f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err)
panic(err)
}
decryptionInfoStr.Valid = true
decryptionInfoStr.String = string(decryptionInfo)
}
_, err := txn.Exec(fileInsert,
f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
decryptionInfoStr, f.Timestamp.UnixMilli(),
)
if err != nil {
f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err)
panic(err)
}
}
func (f *File) Delete() {
_, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted)
if err != nil {
f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err)
panic(err)
}
}

View File

@@ -1,195 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"strings"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type GuildBridgingMode int
const (
// GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists.
GuildBridgeNothing GuildBridgingMode = iota
// GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals.
GuildBridgeIfPortalExists
// GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received.
GuildBridgeCreateOnMessage
// GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications.
GuildBridgeEverything
GuildBridgeInvalid GuildBridgingMode = -1
)
func ParseGuildBridgingMode(str string) GuildBridgingMode {
str = strings.ToLower(str)
str = strings.ReplaceAll(str, "-", "")
str = strings.ReplaceAll(str, "_", "")
switch str {
case "nothing", "0":
return GuildBridgeNothing
case "ifportalexists", "1":
return GuildBridgeIfPortalExists
case "createonmessage", "2":
return GuildBridgeCreateOnMessage
case "everything", "3":
return GuildBridgeEverything
default:
return GuildBridgeInvalid
}
}
func (gbm GuildBridgingMode) String() string {
switch gbm {
case GuildBridgeNothing:
return "nothing"
case GuildBridgeIfPortalExists:
return "if-portal-exists"
case GuildBridgeCreateOnMessage:
return "create-on-message"
case GuildBridgeEverything:
return "everything"
default:
return ""
}
}
func (gbm GuildBridgingMode) Description() string {
switch gbm {
case GuildBridgeNothing:
return "never bridge messages"
case GuildBridgeIfPortalExists:
return "bridge messages in existing portals"
case GuildBridgeCreateOnMessage:
return "bridge all messages and create portals on first message"
case GuildBridgeEverything:
return "bridge all messages and create portals proactively"
default:
return ""
}
}
type GuildQuery struct {
db *Database
log log.Logger
}
const (
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
)
func (gq *GuildQuery) New() *Guild {
return &Guild{
db: gq.db,
log: gq.log,
}
}
func (gq *GuildQuery) GetByID(dcid string) *Guild {
query := guildSelect + " WHERE dcid=$1"
return gq.New().Scan(gq.db.QueryRow(query, dcid))
}
func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild {
query := guildSelect + " WHERE mxid=$1"
return gq.New().Scan(gq.db.QueryRow(query, mxid))
}
func (gq *GuildQuery) GetAll() []*Guild {
rows, err := gq.db.Query(guildSelect)
if err != nil {
gq.log.Errorln("Failed to query guilds:", err)
return nil
}
var guilds []*Guild
for rows.Next() {
guild := gq.New().Scan(rows)
if guild != nil {
guilds = append(guilds, guild)
}
}
return guilds
}
type Guild struct {
db *Database
log log.Logger
ID string
MXID id.RoomID
PlainName string
Name string
NameSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
BridgingMode GuildBridgingMode
}
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
var mxid sql.NullString
var avatarURL string
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
g.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything {
panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID))
}
g.MXID = id.RoomID(mxid.String)
g.AvatarURL, _ = id.ParseContentURI(avatarURL)
return g
}
func (g *Guild) mxidPtr() *id.RoomID {
if g.MXID != "" {
return &g.MXID
}
return nil
}
func (g *Guild) Insert() {
query := `
INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode)
if err != nil {
g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
panic(err)
}
}
func (g *Guild) Update() {
query := `
UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8
WHERE dcid=$9
`
_, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID)
if err != nil {
g.log.Warnfln("Failed to update %s: %v", g.ID, err)
panic(err)
}
}
func (g *Guild) Delete() {
_, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID)
if err != nil {
g.log.Warnfln("Failed to delete %s: %v", g.ID, err)
panic(err)
}
}

View File

@@ -1,232 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type MessageQuery struct {
db *Database
log log.Logger
}
const (
messageSelect = "SELECT dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message"
)
func (mq *MessageQuery) New() *Message {
return &Message{
db: mq.db,
log: mq.log,
}
}
func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
if err != nil {
mq.log.Warnfln("Failed to query many messages: %v", err)
panic(err)
} else if rows == nil {
return nil
}
var messages []*Message
for rows.Next() {
messages = append(messages, mq.New().Scan(rows))
}
return messages
}
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id ASC"
return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id ASC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND timestamp<=$4 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli()))
}
func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND dc_edit_index=0 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID))
}
func (mq *MessageQuery) GetLast(key PortalKey) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_edit_index=0 ORDER BY timestamp DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver))
}
func (mq *MessageQuery) DeleteAll(key PortalKey) {
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
if err != nil {
mq.log.Warnfln("Failed to delete messages of %s: %v", key, err)
panic(err)
}
}
func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3"
row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid)
if row == nil {
return nil
}
return mq.New().Scan(row)
}
func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($%d, $%d, $%d, $1, $2, $%d, $%d, $%d, $%d)"
if mq.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 2+len(msgs)*7)
placeholders := make([]string, len(msgs))
params[0] = key.ChannelID
params[1] = key.Receiver
for i, msg := range msgs {
baseIndex := 2 + i*7
params[baseIndex] = msg.DiscordID
params[baseIndex+1] = msg.AttachmentID
params[baseIndex+2] = msg.EditIndex
params[baseIndex+3] = msg.SenderID
params[baseIndex+4] = msg.Timestamp.UnixMilli()
params[baseIndex+5] = msg.ThreadID
params[baseIndex+6] = msg.MXID
placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7)
}
_, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil {
mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err)
panic(err)
}
}
type Message struct {
db *Database
log log.Logger
DiscordID string
AttachmentID string
EditIndex int
Channel PortalKey
SenderID string
Timestamp time.Time
ThreadID string
MXID id.EventID
}
func (m *Message) DiscordProtoChannelID() string {
if m.ThreadID != "" {
return m.ThreadID
} else {
return m.Channel.ChannelID
}
}
func (m *Message) Scan(row dbutil.Scannable) *Message {
var ts int64
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.EditIndex, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &m.ThreadID, &m.MXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
m.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
if ts != 0 {
m.Timestamp = time.UnixMilli(ts)
}
return m
}
const messageInsertQuery = `
INSERT INTO message (
dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9)", "%s", 1)
type MessagePart struct {
AttachmentID string
MXID id.EventID
}
func (m *Message) MassInsertParts(msgs []MessagePart) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d)"
if m.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 7+len(msgs)*2)
placeholders := make([]string, len(msgs))
params[0] = m.DiscordID
params[1] = m.EditIndex
params[2] = m.Channel.ChannelID
params[3] = m.Channel.Receiver
params[4] = m.SenderID
params[5] = m.Timestamp.UnixMilli()
params[6] = m.ThreadID
for i, msg := range msgs {
params[7+i*2] = msg.AttachmentID
params[7+i*2+1] = msg.MXID
placeholders[i] = fmt.Sprintf(valueStringFormat, 7+i*2+1, 7+i*2+2)
}
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil {
m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err)
panic(err)
}
}
func (m *Message) Insert() {
_, err := m.db.Exec(messageInsertQuery,
m.DiscordID, m.AttachmentID, m.EditIndex, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
m.Timestamp.UnixMilli(), m.ThreadID, m.MXID)
if err != nil {
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
panic(err)
}
}
func (m *Message) Delete() {
query := "DELETE FROM message WHERE dcid=$1 AND dc_chan_id=$2 AND dc_chan_receiver=$3 AND dc_attachment_id=$4"
_, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)
if err != nil {
m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err)
panic(err)
}
}

View File

@@ -1,205 +0,0 @@
package database
import (
"database/sql"
"github.com/bwmarrin/discordgo"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
// language=postgresql
const (
portalSelect = `
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal
`
)
type PortalKey struct {
ChannelID string
Receiver string
}
func NewPortalKey(channelID, receiver string) PortalKey {
return PortalKey{
ChannelID: channelID,
Receiver: receiver,
}
}
func (key PortalKey) String() string {
if key.Receiver == "" {
return key.ChannelID
}
return key.ChannelID + "-" + key.Receiver
}
type PortalQuery struct {
db *Database
log log.Logger
}
func (pq *PortalQuery) New() *Portal {
return &Portal{
db: pq.db,
log: pq.log,
}
}
func (pq *PortalQuery) GetAll() []*Portal {
return pq.getAll(portalSelect)
}
func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal {
return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID)
}
func (pq *PortalQuery) GetByID(key PortalKey) *Portal {
return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver)
}
func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
}
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal {
query := portalSelect + " portal WHERE receiver=$1 AND type=$2;"
return pq.getAll(query, receiver, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {
rows, err := pq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
defer rows.Close()
var portals []*Portal
for rows.Next() {
portals = append(portals, pq.New().Scan(rows))
}
return portals
}
func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
return pq.New().Scan(pq.db.QueryRow(query, args...))
}
type Portal struct {
db *Database
log log.Logger
Key PortalKey
Type discordgo.ChannelType
OtherUserID string
ParentID string
GuildID string
MXID id.RoomID
PlainName string
Name string
NameSet bool
Topic string
TopicSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Encrypted bool
InSpace id.RoomID
FirstEventID id.EventID
RelayWebhookID string
RelayWebhookSecret string
}
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
var chanType int32
var avatarURL string
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil {
if err != sql.ErrNoRows {
p.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
p.MXID = id.RoomID(mxid.String)
p.OtherUserID = otherUserID.String
p.GuildID = guildID.String
p.ParentID = parentID.String
p.Type = discordgo.ChannelType(chanType)
p.FirstEventID = id.EventID(firstEventID.String)
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.RelayWebhookID = relayWebhookID.String
p.RelayWebhookSecret = relayWebhookSecret.String
return p
}
func (p *Portal) Insert() {
query := `
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
panic(err)
}
}
func (p *Portal) Update() {
query := `
UPDATE portal
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
plain_name=$6, name=$7, name_set=$8, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13,
encrypted=$14, in_space=$15, first_event_id=$16, relay_webhook_id=$17, relay_webhook_secret=$18
WHERE dcid=$19 AND receiver=$20
`
_, err := p.db.Exec(query,
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
p.Key.ChannelID, p.Key.Receiver)
if err != nil {
p.log.Warnfln("Failed to update %s: %v", p.Key, err)
panic(err)
}
}
func (p *Portal) Delete() {
query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2"
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver)
if err != nil {
p.log.Warnfln("Failed to delete %s: %v", p.Key, err)
panic(err)
}
}

View File

@@ -1,133 +0,0 @@
package database
import (
"database/sql"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
const (
puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
" custom_mxid, access_token, next_batch" +
" FROM puppet "
)
type PuppetQuery struct {
db *Database
log log.Logger
}
func (pq *PuppetQuery) New() *Puppet {
return &Puppet{
db: pq.db,
log: pq.log,
}
}
func (pq *PuppetQuery) Get(id string) *Puppet {
return pq.get(puppetSelect+" WHERE id=$1", id)
}
func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid)
}
func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet {
return pq.New().Scan(pq.db.QueryRow(query, args...))
}
func (pq *PuppetQuery) GetAll() []*Puppet {
return pq.getAll(puppetSelect)
}
func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet {
return pq.getAll(puppetSelect + " WHERE custom_mxid<>''")
}
func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet {
rows, err := pq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
defer rows.Close()
var puppets []*Puppet
for rows.Next() {
puppets = append(puppets, pq.New().Scan(rows))
}
return puppets
}
type Puppet struct {
db *Database
log log.Logger
ID string
Name string
NameSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
CustomMXID id.UserID
AccessToken string
NextBatch string
}
func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
var avatarURL string
var customMXID, accessToken, nextBatch sql.NullString
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&customMXID, &accessToken, &nextBatch)
if err != nil {
if err != sql.ErrNoRows {
p.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.CustomMXID = id.UserID(customMXID.String)
p.AccessToken = accessToken.String
p.NextBatch = nextBatch.String
return p
}
func (p *Puppet) Insert() {
query := `
INSERT INTO puppet (id, name, name_set, avatar, avatar_url, avatar_set, custom_mxid, access_token, next_batch)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
strPtr(string(p.CustomMXID)), strPtr(p.AccessToken), strPtr(p.NextBatch))
if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
panic(err)
}
}
func (p *Puppet) Update() {
query := `
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5,
custom_mxid=$6, access_token=$7, next_batch=$8
WHERE id=$9
`
_, err := p.db.Exec(query, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
strPtr(string(p.CustomMXID)), strPtr(p.AccessToken), strPtr(p.NextBatch),
p.ID)
if err != nil {
p.log.Warnfln("Failed to update %s: %v", p.ID, err)
panic(err)
}
}

View File

@@ -1,125 +0,0 @@
package database
import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type ReactionQuery struct {
db *Database
log log.Logger
}
const (
reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction"
)
func (rq *ReactionQuery) New() *Reaction {
return &Reaction{
db: rq.db,
log: rq.log,
}
}
func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction {
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3"
return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID)
}
func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction {
rows, err := rq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
var reactions []*Reaction
for rows.Next() {
reactions = append(reactions, rq.New().Scan(rows))
}
return reactions
}
func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction {
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3 AND dc_sender=$4 AND dc_emoji_name=$5"
return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName)
}
func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction {
query := reactionSelect + " WHERE mxid=$1"
return rq.get(query, mxid)
}
func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction {
row := rq.db.QueryRow(query, args...)
if row == nil {
return nil
}
return rq.New().Scan(row)
}
type Reaction struct {
db *Database
log log.Logger
Channel PortalKey
MessageID string
Sender string
EmojiName string
ThreadID string
MXID id.EventID
FirstAttachmentID string
}
func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
r.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
return r
}
func (r *Reaction) DiscordProtoChannelID() string {
if r.ThreadID != "" {
return r.ThreadID
} else {
return r.Channel.ChannelID
}
}
func (r *Reaction) Insert() {
query := `
INSERT INTO reaction (dc_msg_id, dc_first_attachment_id, dc_sender, dc_emoji_name, dc_chan_id, dc_chan_receiver, dc_thread_id, mxid)
VALUES($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID)
if err != nil {
r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err)
panic(err)
}
}
func (r *Reaction) Delete() {
query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3"
_, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName)
if err != nil {
r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err)
panic(err)
}
}

View File

@@ -1,114 +0,0 @@
package database
import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
"github.com/bwmarrin/discordgo"
)
type RoleQuery struct {
db *Database
log log.Logger
}
// language=postgresql
const (
roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role"
roleUpsert = `
INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (dc_guild_id, dcid) DO UPDATE
SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed,
hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions
`
roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2"
)
func (rq *RoleQuery) New() *Role {
return &Role{
db: rq.db,
log: rq.log,
}
}
func (rq *RoleQuery) GetByID(guildID, dcid string) *Role {
query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2"
return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid))
}
func (rq *RoleQuery) DeleteByID(guildID, dcid string) {
_, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid)
if err != nil {
rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err)
panic(err)
}
}
func (rq *RoleQuery) GetAll(guildID string) []*Role {
rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID)
if err != nil {
rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err)
return nil
}
var roles []*Role
for rows.Next() {
role := rq.New().Scan(rows)
if role != nil {
roles = append(roles, role)
}
}
return roles
}
type Role struct {
db *Database
log log.Logger
GuildID string
discordgo.Role
}
func (r *Role) Scan(row dbutil.Scannable) *Role {
var icon sql.NullString
err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
r.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
r.Icon = icon.String
return r
}
func (r *Role) Upsert(txn dbutil.Execable) {
if txn == nil {
txn = r.db
}
_, err := txn.Exec(roleUpsert, r.GuildID, r.ID, r.Name, strPtr(r.Icon), r.Mentionable, r.Managed, r.Hoist, r.Color, r.Position, r.Permissions)
if err != nil {
r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err)
panic(err)
}
}
func (r *Role) Delete(txn dbutil.Execable) {
if txn == nil {
txn = r.db
}
_, err := txn.Exec(roleDelete, r.GuildID, r.Icon)
if err != nil {
r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err)
panic(err)
}
}

View File

@@ -1,112 +0,0 @@
package database
import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type ThreadQuery struct {
db *Database
log log.Logger
}
const (
threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread"
)
func (tq *ThreadQuery) New() *Thread {
return &Thread{
db: tq.db,
log: tq.log,
}
}
func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread {
query := threadSelect + " WHERE dcid=$1"
row := tq.db.QueryRow(query, discordID)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread {
query := threadSelect + " WHERE root_msg_mxid=$1"
row := tq.db.QueryRow(query, mxid)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread {
query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1"
row := tq.db.QueryRow(query, mxid)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
type Thread struct {
db *Database
log log.Logger
ID string
ParentID string
RootDiscordID string
RootMXID id.EventID
CreationNoticeMXID id.EventID
}
func (t *Thread) Scan(row dbutil.Scannable) *Thread {
err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
t.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
return t
}
func (t *Thread) Insert() {
query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)"
_, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID)
if err != nil {
t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}
func (t *Thread) Update() {
query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1"
_, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID)
if err != nil {
t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}
func (t *Thread) Delete() {
query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2"
_, err := t.db.Exec(query, t.ID, t.ParentID)
if err != nil {
t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}

View File

@@ -1,167 +0,0 @@
-- v0 -> v15: Latest revision
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
mxid TEXT UNIQUE,
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
bridging_mode INTEGER NOT NULL
);
CREATE TABLE portal (
dcid TEXT,
receiver TEXT,
other_user_id TEXT,
type INTEGER NOT NULL,
dc_guild_id TEXT,
dc_parent_id TEXT,
-- This is not accessed by the bridge, it's only used for the portal parent foreign key.
-- Only guild channels have parents, but only DMs have a receiver field.
dc_parent_receiver TEXT NOT NULL DEFAULT '',
mxid TEXT UNIQUE,
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
encrypted BOOLEAN NOT NULL,
in_space TEXT NOT NULL,
first_event_id TEXT NOT NULL,
relay_webhook_id TEXT,
relay_webhook_secret TEXT,
PRIMARY KEY (dcid, receiver),
CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE,
CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
);
CREATE TABLE thread (
dcid TEXT PRIMARY KEY,
parent_chan_id TEXT NOT NULL,
root_msg_dcid TEXT NOT NULL,
root_msg_mxid TEXT NOT NULL,
creation_notice_mxid TEXT NOT NULL,
-- This is also not accessed by the bridge.
receiver TEXT NOT NULL DEFAULT '',
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE puppet (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
custom_mxid TEXT,
access_token TEXT,
next_batch TEXT
);
CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
dcid TEXT UNIQUE,
discord_token TEXT,
management_room TEXT,
space_room TEXT,
dm_space_room TEXT,
read_state_version INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE user_portal (
discord_id TEXT,
user_mxid TEXT,
type TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY (discord_id, user_mxid),
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
);
CREATE TABLE message (
dcid TEXT,
dc_attachment_id TEXT,
dc_edit_index INTEGER,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
CREATE TABLE reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL,
dc_first_attachment_id TEXT NOT NULL,
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
CREATE TABLE role (
dc_guild_id TEXT,
dcid TEXT,
name TEXT NOT NULL,
icon TEXT,
mentionable BOOLEAN NOT NULL,
managed BOOLEAN NOT NULL,
hoist BOOLEAN NOT NULL,
color INTEGER NOT NULL,
position INTEGER NOT NULL,
permissions BIGINT NOT NULL,
PRIMARY KEY (dc_guild_id, dcid),
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
);
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);

View File

@@ -1,53 +0,0 @@
-- v2: Rename columns in message-related tables
ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id;
ALTER TABLE portal RENAME COLUMN channel_id TO dcid;
ALTER TABLE "user" RENAME COLUMN id TO dcid;
ALTER TABLE puppet DROP COLUMN enable_presence;
ALTER TABLE puppet DROP COLUMN enable_receipts;
DROP TABLE message;
DROP TABLE reaction;
DROP TABLE attachment;
CREATE TABLE message (
dcid TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
CREATE TABLE reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
CREATE TABLE attachment (
dcid TEXT,
dc_msg_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver),
CONSTRAINT attachment_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
UPDATE portal SET receiver='' WHERE type<>1;

View File

@@ -1,73 +0,0 @@
-- v3: Store portal parent metadata for spaces
DROP TABLE guild;
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
mxid TEXT UNIQUE,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
auto_bridge_channels BOOLEAN NOT NULL
);
CREATE TABLE user_portal (
discord_id TEXT,
user_mxid TEXT,
type TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY (discord_id, user_mxid),
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
);
ALTER TABLE portal ADD COLUMN dc_guild_id TEXT;
ALTER TABLE portal ADD COLUMN dc_parent_id TEXT;
ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT '';
ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE;
ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE;
DELETE FROM portal WHERE type IS NULL;
-- only: postgres
ALTER TABLE portal ALTER COLUMN type SET NOT NULL;
ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT '';
ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
-- only: postgres for next 5 lines
ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT;
ALTER TABLE puppet RENAME COLUMN display_name TO name;
ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
-- only: postgres for next 2 lines
ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT;
ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT;
ALTER TABLE "user" ADD COLUMN space_room TEXT;
ALTER TABLE "user" ADD COLUMN dm_space_room TEXT;
ALTER TABLE "user" RENAME COLUMN token TO discord_token;
UPDATE message SET timestamp=timestamp*1000;
CREATE TABLE thread (
dcid TEXT PRIMARY KEY,
parent_chan_id TEXT NOT NULL,
root_msg_dcid TEXT NOT NULL,
root_msg_mxid TEXT NOT NULL,
-- This is also not accessed by the bridge.
receiver TEXT NOT NULL DEFAULT '',
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
);
ALTER TABLE message ADD COLUMN dc_thread_id TEXT;
ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT;
ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT;

View File

@@ -1,20 +0,0 @@
-- v4: Fix storing attachments
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey;
ALTER TABLE message DROP CONSTRAINT message_pkey;
ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT '';
ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0;
ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT;
ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT;
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
DROP TABLE attachment;
ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT '';
ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT;
ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0;
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);

View File

@@ -1,45 +0,0 @@
-- v4: Fix storing attachments
CREATE TABLE new_message (
dcid TEXT,
dc_attachment_id TEXT,
dc_edit_index INTEGER,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_thread_id TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message;
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
DROP TABLE attachment;
DROP TABLE message;
ALTER TABLE new_message RENAME TO message;
CREATE TABLE new_reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT,
dc_first_attachment_id TEXT NOT NULL,
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
INSERT INTO new_reaction (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction;
DROP TABLE reaction;
ALTER TABLE new_reaction RENAME TO reaction;

View File

@@ -1,8 +0,0 @@
-- v5: Fix foreign key broken in v4
-- only: postgres
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver)
ON DELETE CASCADE;

View File

@@ -1,2 +0,0 @@
-- v6: Store user read state version
ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0;

View File

@@ -1,19 +0,0 @@
-- v7: Store role info
CREATE TABLE role (
dc_guild_id TEXT,
dcid TEXT,
name TEXT NOT NULL,
icon TEXT,
mentionable BOOLEAN NOT NULL,
managed BOOLEAN NOT NULL,
hoist BOOLEAN NOT NULL,
color INTEGER NOT NULL,
position INTEGER NOT NULL,
permissions BIGINT NOT NULL,
PRIMARY KEY (dc_guild_id, dcid),
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
);

View File

@@ -1,9 +0,0 @@
-- v8: Store plain name of channels and guilds
ALTER TABLE guild ADD COLUMN plain_name TEXT;
ALTER TABLE portal ADD COLUMN plain_name TEXT;
UPDATE guild SET plain_name=name;
UPDATE portal SET plain_name=name;
UPDATE portal SET plain_name='' WHERE type=1;
-- only: postgres for next 2 lines
ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL;
ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL;

View File

@@ -1,9 +0,0 @@
-- v9: Store more info for proper thread support
ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT '';
UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL;
UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL;
-- only: postgres for next 3 lines
ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT;
ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL;
ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL;

View File

@@ -1,2 +0,0 @@
-- v10: Remove double puppet ghosts added while there was a bug in the bridge
DELETE FROM puppet WHERE id='';

View File

@@ -1,18 +0,0 @@
-- v11: Cache files copied from Discord to Matrix
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
id TEXT,
mxc TEXT NOT NULL,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);

View File

@@ -1,4 +0,0 @@
-- v12: Cache mime type for reuploaded files
ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT '';
-- only: postgres
ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT;

View File

@@ -1,4 +0,0 @@
-- v13: Merge tables used for cached custom emojis and attachments
ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc);
ALTER TABLE discord_file ADD COLUMN emoji_name TEXT;
DROP TABLE emoji;

View File

@@ -1,24 +0,0 @@
-- v13: Merge tables used for cached custom emojis and attachments
CREATE TABLE new_discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);
INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
DROP TABLE discord_file;
ALTER TABLE new_discord_file RENAME TO discord_file;

View File

@@ -1,7 +0,0 @@
-- v14: Add more modes of bridging guilds
ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0;
UPDATE guild SET bridging_mode=2 WHERE mxid<>'';
UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true;
ALTER TABLE guild DROP COLUMN auto_bridge_channels;
-- only: postgres
ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT;

View File

@@ -1,3 +0,0 @@
-- v15: Store relay webhook URL for portals
ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT;
ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT;

View File

@@ -1,102 +0,0 @@
package database
import (
"database/sql"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type UserQuery struct {
db *Database
log log.Logger
}
func (uq *UserQuery) New() *User {
return &User{
db: uq.db,
log: uq.log,
}
}
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1`
return uq.New().Scan(uq.db.QueryRow(query, userID))
}
func (uq *UserQuery) GetByID(id string) *User {
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1`
return uq.New().Scan(uq.db.QueryRow(query, id))
}
func (uq *UserQuery) GetAllWithToken() []*User {
query := `
SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version
FROM "user" WHERE discord_token IS NOT NULL
`
rows, err := uq.db.Query(query)
if err != nil || rows == nil {
return nil
}
var users []*User
for rows.Next() {
user := uq.New().Scan(rows)
if user != nil {
users = append(users, user)
}
}
return users
}
type User struct {
db *Database
log log.Logger
MXID id.UserID
DiscordID string
DiscordToken string
ManagementRoom id.RoomID
SpaceRoom id.RoomID
DMSpaceRoom id.RoomID
ReadStateVersion int
}
func (u *User) Scan(row dbutil.Scannable) *User {
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion)
if err != nil {
if err != sql.ErrNoRows {
u.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
u.DiscordID = discordID.String
u.DiscordToken = discordToken.String
u.ManagementRoom = id.RoomID(managementRoom.String)
u.SpaceRoom = id.RoomID(spaceRoom.String)
u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String)
return u
}
func (u *User) Insert() {
query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion)
if err != nil {
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
panic(err)
}
}
func (u *User) Update() {
query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7`
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID)
if err != nil {
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
panic(err)
}
}

View File

@@ -1,121 +0,0 @@
package database
import (
"database/sql"
"errors"
"time"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
)
const (
UserPortalTypeDM = "dm"
UserPortalTypeGuild = "guild"
UserPortalTypeThread = "thread"
)
type UserPortal struct {
DiscordID string
Type string
Timestamp time.Time
InSpace bool
}
func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
var ts int64
err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace)
if err != nil {
l.Errorln("Error scanning user portal:", err)
panic(err)
}
up.Timestamp = time.UnixMilli(ts)
return &up
}
func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
var ups []UserPortal
for rows.Next() {
up := UserPortal{}.Scan(u.log, rows)
if up != nil {
ups = append(ups, *up)
}
}
return ups
}
func (u *User) GetPortals() []UserPortal {
rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
if err != nil {
u.log.Errorln("Failed to get portals:", err)
panic(err)
}
return u.scanUserPortals(rows)
}
func (u *User) IsInSpace(discordID string) (isIn bool) {
query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
return
}
func (u *User) IsInPortal(discordID string) (isIn bool) {
query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
return
}
func (u *User) MarkInPortal(portal UserPortal) {
query := `
INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (discord_id, user_mxid) DO UPDATE
SET timestamp=excluded.timestamp, in_space=excluded.in_space
`
_, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace)
if err != nil {
u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err)
panic(err)
}
}
func (u *User) MarkNotInPortal(discordID string) {
query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
_, err := u.db.Exec(query, u.MXID, discordID)
if err != nil {
u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
}
func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) {
query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers)
if err != nil {
u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err)
panic(err)
}
return
}
func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal {
query := `
DELETE FROM user_portal
WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild')
RETURNING discord_id, type, timestamp, in_space
`
rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli())
if err != nil {
u.log.Errorln("Failed to prune user guild list:", err)
panic(err)
}
return u.scanUserPortals(rows)
}

View File

@@ -1,52 +0,0 @@
package main
import (
"errors"
"github.com/bwmarrin/discordgo"
)
func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
switch channel.Type {
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
// allowed
case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
// DMs are always bridgeable, no need for permission checks
return true
default:
// everything else is not allowed
return false
}
log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger()
member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
if errors.Is(err, discordgo.ErrStateNotFound) {
log.Debug().Msg("Fetching own membership in guild to check roles")
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else {
err = user.Session.State.MemberAdd(member)
if err != nil {
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
}
}
} else if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
}
err = user.Session.State.ChannelAdd(channel)
if err != nil {
log.Warn().Err(err).Msg("Failed to add channel to cache")
}
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true
}
log.Debug().
Int64("permissions", perms).
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
Msg("Computed permissions in channel")
return perms&discordgo.PermissionViewChannel > 0
}

View File

@@ -15,7 +15,7 @@ function fixperms {
}
if [[ ! -f /data/config.yaml ]]; then
cp /opt/mautrix-discord/example-config.yaml /data/config.yaml
/usr/bin/mautrix-discord -c /data/config.yaml -e
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."

View File

@@ -1,312 +0,0 @@
# Homeserver details.
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com
# Publicly accessible base URL for media, used for avatars in relay mode.
# If not set, the connection address above will be used.
public_address: null
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29334
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29334
# Database config.
database:
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
type: postgres
# The database URI.
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
# https://github.com/mattn/go-sqlite3#connection-string
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
uri: postgres://user:password@host/database?sslmode=disable
# Maximum number of connections. Mostly relevant for Postgres.
max_open_conns: 20
max_idle_conns: 2
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
# Parsed with https://pkg.go.dev/time#ParseDuration
max_conn_idle_time: null
max_conn_lifetime: null
# The unique ID of this appservice.
id: discord
# Appservice bot details.
bot:
# Username of the appservice bot.
username: discordbot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
displayname: Discord bridge bot
avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
ephemeral_events: true
# Should incoming events be handled asynchronously?
# This may be necessary for large public instances with lots of messages going through.
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
async_transactions: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Bridge config
bridge:
# Localpart template of MXIDs for Discord users.
# {{.}} is replaced with the internal ID of the Discord user.
username_template: discord_{{.}}
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# Available variables:
# .ID - Internal user ID
# .Username - User's displayname on Discord
# .Discriminator - The 4 numbers after the name on Discord
# .Bot - Whether the user is a bot
# .System - Whether the user is an official system user
displayname_template: '{{.Username}}#{{.Discriminator}}{{if .Bot}} (bot){{end}}'
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
# Available variables:
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
# .ParentName - Parent channel name (used for categories).
# .GuildName - Guild name.
# .NSFW - Whether the channel is marked as NSFW.
# .Type - Channel type (see values at https://github.com/bwmarrin/discordgo/blob/v0.25.0/structs.go#L251-L267)
channel_name_template: '{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}'
# Displayname template for Discord guilds (bridged as spaces).
# Available variables:
# .Name - Guild name
guild_name_template: '{{.Name}}'
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
portal_message_buffer: 128
# Number of private channel portals to create on bridge startup.
# Other portals will be created when receiving messages.
startup_private_channel_create_limit: 5
# Should the bridge send a read receipt from the bridge bot when a message has been sent to Discord?
delivery_receipts: false
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
message_status_events: false
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
message_error_notices: true
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
# This can avoid unnecessary invite events in guild rooms when members are synced in.
restricted_rooms: true
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
autojoin_thread_on_open: true
# Should inline fields in Discord embeds be bridged as HTML tables to Matrix?
# Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI.
embed_fields_as_tables: true
# Should guild channels be muted when the portal is created? This only meant for single-user instances,
# it won't mute it for all users if there are multiple Matrix users in the same Discord guild.
mute_channels_on_create: false
# Should the bridge update the m.direct account data event when double puppeting is enabled.
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
# This field will automatically be changed back to false after it, except if the config file is not writable.
resend_bridge_info: false
# Should incoming custom emoji reactions be bridged as mxc:// URIs?
# If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available.
custom_emoji_reactions: true
# Should the bridge attempt to completely delete portal rooms when a channel is deleted on Discord?
# If true, the bridge will try to kick Matrix users from the room. Otherwise, the bridge only makes ghosts leave.
delete_portal_on_channel_delete: false
# Should the bridge delete all portal rooms when you leave a guild on Discord?
# This only applies if the guild has no other Matrix users on this bridge instance.
delete_guild_on_leave: true
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (lottie JSON)
# png - converts to non-animated png (fastest)
# gif - converts to animated gif
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
target: webp
# Arguments for converter. All converters take width and height.
args:
width: 320
height: 320
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, double puppeting will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
login_shared_secret_map:
example.com: foobar
# The prefix for commands. Only required in non-management rooms.
command_prefix: '!discord'
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a Discord bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
welcome_unconnected: "Use `help` for help or `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# Settings for backfilling messages.
backfill:
# Limits for forward backfilling.
forward_limits:
# Initial backfill (when creating portal). 0 means backfill is disabled.
# A special unlimited value is not supported, you must set a limit. Initial backfill will
# fetch all messages first before backfilling anything, so high limits can take a lot of time.
initial:
dm: 0
channel: 0
# Missed message backfill (on startup).
# 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
# When using unlimited backfill (-1), messages are backfilled as they are fetched.
# With limits, all messages up to the limit are fetched first and backfilled afterwards.
missed:
dm: 0
channel: 0
# Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
# This can be used as a rough heuristic to disable backfilling in channels that are too active.
# Currently only applies to missed message backfill.
max_guild_members: -1
# End-to-bridge encryption support options.
#
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
appservice: false
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# What level of device verification should be required from users?
#
# Valid levels:
# unverified - Send keys to all device in the room.
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
# Note that creating user signatures from the bridge bot is not currently possible.
# verified - Require manual per-device verification
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
verification_levels:
# Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
receive: unverified
# Minimum level that the bridge should accept for incoming Matrix messages.
send: unverified
# Minimum level that the bridge should require for accepting key requests.
share: cross-signed-tofu
# Options for Megolm room key rotation. These options allow you to
# configure the m.room.encryption event content. See:
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
# more information about that event.
rotation:
# Enable custom Megolm room key rotation settings. Note that these
# settings will only apply to rooms created after this option is
# set.
enable_custom: false
# The maximum number of milliseconds a session should be used
# before changing it. The Matrix spec recommends 604800000 (a week)
# as the default.
milliseconds: 604800000
# The maximum number of messages that should be sent with a given a
# session before changing it. The Matrix spec recommends 100 as the
# default.
messages: 100
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Permissions for using the bridge.
# Permitted values:
# relay - Talk through the relaybot (if enabled), no access otherwise
# user - Access to use the bridge to chat with a Discord account.
# admin - User level and some additional administration tools
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": relay
"example.com": user
"@admin:example.com": admin
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug
writers:
- type: stdout
format: pretty-colored
- type: file
format: json
filename: ./logs/mautrix-discord.log
max_size: 100
max_backups: 10
compress: true

View File

@@ -1,57 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEscapeDiscordMarkdown(t *testing.T) {
type escapeTest struct {
name string
input string
expected string
}
tests := []escapeTest{
{"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."},
{"Backslash", `foo\bar`, `foo\\bar`},
{"Underscore", `foo_bar`, `foo\_bar`},
{"Asterisk", `foo*bar`, `foo\*bar`},
{"Tilde", `foo~bar`, `foo\~bar`},
{"Backtick", "foo`bar", "foo\\`bar"},
{"Forward tick", `foo´bar`, `foo´bar`},
{"Pipe", `foo|bar`, `foo\|bar`},
{"Less than", `foo<bar`, `foo\<bar`},
{"Greater than", `foo>bar`, `foo>bar`},
{"Multiple things", `\_*~|`, `\\\_\*\~\|`},
{"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`},
{"Multiple URLs", `hello_world https://example.com/foo_bar *testing* https://a_b_c/*def*`, `hello\_world https://example.com/foo_bar \*testing\* https://a_b_c/*def*`},
{"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"},
{"URL ends with less than", `https://example.com<foo_bar`, `https://example.com<foo\_bar`},
{"Short URL", `https://_`, `https://_`},
{"Insecure URL", `http://example.com/foo_bar`, `http://example.com/foo_bar`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, escapeDiscordMarkdown(test.input))
})
}
}

52
go.mod
View File

@@ -1,40 +1,44 @@
module go.mau.fi/mautrix-discord
go 1.19
go 1.24.0
toolchain go1.25.4
require (
github.com/bwmarrin/discordgo v0.27.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.8
github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.29.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.2
github.com/yuin/goldmark v1.5.4
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.15.1
github.com/rs/zerolog v1.34.0
github.com/yuin/goldmark v1.7.16
go.mau.fi/util v0.9.5
maunium.net/go/mautrix v0.26.2
)
require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/crypto v0.8.0 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.7.0 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0

114
go.sum
View File

@@ -1,81 +1,83 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961 h1:eSGaliexlehYBeP4YQW8dQpV9XWWgfR1qH8kfHgrDcY=
github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0 h1:cKnAGjgYtCO4DLQePwsx1bBbX2imPSggm8da4t2AzBQ=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.10.8 h1:3fdt97i/cwSU83+E0hZTC/Xpc9mTZxc6UWSCRcSbxiE=
github.com/lib/pq v1.10.8/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4=
go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.15.1 h1:pmCtMjYRpd83+2UL+KTRFYQo5to0373yulimvLK+1k0=
maunium.net/go/mautrix v0.15.1/go.mod h1:icQIrvz2NldkRLTuzSGzmaeuMUmw+fzO7UVycPeauN8=
maunium.net/go/mautrix v0.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g=
maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo=

View File

@@ -1,329 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"errors"
"fmt"
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/bwmarrin/discordgo"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
)
type Guild struct {
*database.Guild
bridge *DiscordBridge
log log.Logger
roomCreateLock sync.Mutex
}
func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild {
if dbGuild == nil {
if id == "" || !createIfNotExist {
return nil
}
dbGuild = br.DB.Guild.New()
dbGuild.ID = id
dbGuild.Insert()
}
guild := br.NewGuild(dbGuild)
br.guildsByID[guild.ID] = guild
if guild.MXID != "" {
br.guildsByMXID[guild.MXID] = guild
}
return guild
}
func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
portal, ok := br.guildsByMXID[mxid]
if !ok {
return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false)
}
return portal
}
func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
guild, ok := br.guildsByID[id]
if !ok {
return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist)
}
return guild
}
func (br *DiscordBridge) GetAllGuilds() []*Guild {
return br.dbGuildsToGuilds(br.DB.Guild.GetAll())
}
func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
output := make([]*Guild, len(dbGuilds))
for index, dbGuild := range dbGuilds {
if dbGuild == nil {
continue
}
guild, ok := br.guildsByID[dbGuild.ID]
if !ok {
guild = br.loadGuild(dbGuild, "", false)
}
output[index] = guild
}
return output
}
func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild {
guild := &Guild{
Guild: dbGuild,
bridge: br,
log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)),
}
return guild
}
func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) {
bridgeInfo := event.BridgeEventContent{
BridgeBot: guild.bridge.Bot.UserID,
Creator: guild.bridge.Bot.UserID,
Protocol: event.BridgeInfoSection{
ID: "discordgo",
DisplayName: "Discord",
AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
ExternalURL: "https://discord.com/",
},
Channel: event.BridgeInfoSection{
ID: guild.ID,
DisplayName: guild.Name,
AvatarURL: guild.AvatarURL.CUString(),
},
}
bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID)
return bridgeInfoStateKey, bridgeInfo
}
func (guild *Guild) UpdateBridgeInfo() {
if len(guild.MXID) == 0 {
guild.log.Debugln("Not updating bridge info: no Matrix room created")
return
}
guild.log.Debugln("Updating bridge info...")
stateKey, content := guild.getBridgeInfo()
_, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content)
if err != nil {
guild.log.Warnln("Failed to update m.bridge:", err)
}
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
_, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content)
if err != nil {
guild.log.Warnln("Failed to update uk.half-shot.bridge:", err)
}
}
func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
guild.roomCreateLock.Lock()
defer guild.roomCreateLock.Unlock()
if guild.MXID != "" {
return nil
}
guild.log.Infoln("Creating Matrix room for guild")
guild.UpdateInfo(user, meta)
bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo()
initialState := []*event.Event{{
Type: event.StateBridge,
Content: event.Content{Parsed: bridgeInfo},
StateKey: &bridgeInfoStateKey,
}, {
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
Type: event.StateHalfShotBridge,
Content: event.Content{Parsed: bridgeInfo},
StateKey: &bridgeInfoStateKey,
}}
if !guild.AvatarURL.IsEmpty() {
initialState = append(initialState, &event.Event{
Type: event.StateRoomAvatar,
Content: event.Content{Parsed: &event.RoomAvatarEventContent{
URL: guild.AvatarURL,
}},
})
}
creationContent := map[string]interface{}{
"type": event.RoomTypeSpace,
}
if !guild.bridge.Config.Bridge.FederateRooms {
creationContent["m.federate"] = false
}
resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
Visibility: "private",
Name: guild.Name,
Preset: "private_chat",
InitialState: initialState,
CreationContent: creationContent,
})
if err != nil {
guild.log.Warnln("Failed to create room:", err)
return err
}
guild.MXID = resp.RoomID
guild.NameSet = true
guild.AvatarSet = !guild.AvatarURL.IsEmpty()
guild.Update()
guild.bridge.guildsLock.Lock()
guild.bridge.guildsByMXID[guild.MXID] = guild
guild.bridge.guildsLock.Unlock()
guild.log.Infoln("Matrix room created:", guild.MXID)
user.ensureInvited(nil, guild.MXID, false)
return nil
}
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
if meta.Unavailable {
guild.log.Debugfln("Ignoring unavailable guild update")
return meta
}
changed := false
changed = guild.UpdateName(meta) || changed
changed = guild.UpdateAvatar(meta.Icon) || changed
if changed {
guild.UpdateBridgeInfo()
guild.Update()
}
return meta
}
func (guild *Guild) UpdateName(meta *discordgo.Guild) bool {
name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{
Name: meta.Name,
})
if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") {
return false
}
guild.log.Debugfln("Updating name %q -> %q", guild.Name, name)
guild.Name = name
guild.PlainName = meta.Name
guild.NameSet = false
if guild.MXID != "" {
_, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name)
if err != nil {
guild.log.Warnln("Failed to update room name: %s", err)
} else {
guild.NameSet = true
}
}
return true
}
func (guild *Guild) UpdateAvatar(iconID string) bool {
if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") {
return false
}
guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID)
guild.AvatarSet = false
guild.Avatar = iconID
guild.AvatarURL = id.ContentURI{}
if guild.Avatar != "" {
var err error
guild.AvatarURL, err = uploadAvatar(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID))
if err != nil {
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", guild.Avatar, err)
return true
}
}
if guild.MXID != "" {
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
if err != nil {
guild.log.Warnln("Failed to update room avatar:", err)
} else {
guild.AvatarSet = true
}
}
return true
}
func (guild *Guild) cleanup() {
if guild.MXID == "" {
return
}
intent := guild.bridge.Bot
if guild.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] {
err := intent.BeeperDeleteRoom(guild.MXID)
if err == nil || errors.Is(err, mautrix.MNotFound) {
return
}
guild.log.Warnfln("Failed to delete %s using hungryserv yeet endpoint, falling back to normal behavior: %v", guild.MXID, err)
}
guild.bridge.cleanupRoom(intent, guild.MXID, false, guild.log)
}
func (guild *Guild) RemoveMXID() {
guild.bridge.guildsLock.Lock()
defer guild.bridge.guildsLock.Unlock()
if guild.MXID == "" {
return
}
delete(guild.bridge.guildsByMXID, guild.MXID)
guild.MXID = ""
guild.AvatarSet = false
guild.NameSet = false
guild.BridgingMode = database.GuildBridgeNothing
guild.Update()
}
func (guild *Guild) Delete() {
guild.Guild.Delete()
guild.bridge.guildsLock.Lock()
delete(guild.bridge.guildsByID, guild.ID)
if guild.MXID != "" {
delete(guild.bridge.guildsByMXID, guild.MXID)
}
guild.bridge.guildsLock.Unlock()
}

211
main.go
View File

@@ -1,211 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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"
"fmt"
"runtime"
"strings"
"sync"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
//go:embed example-config.yaml
var ExampleConfig string
type DiscordBridge struct {
bridge.Bridge
Config *config.Config
DB *database.Database
provisioning *ProvisioningAPI
usersByMXID map[id.UserID]*User
usersByID map[string]*User
usersLock sync.Mutex
managementRooms map[id.RoomID]*User
managementRoomsLock sync.Mutex
portalsByMXID map[id.RoomID]*Portal
portalsByID map[database.PortalKey]*Portal
portalsLock sync.Mutex
threadsByID map[string]*Thread
threadsByRootMXID map[id.EventID]*Thread
threadsByCreationNoticeMXID map[id.EventID]*Thread
threadsLock sync.Mutex
guildsByMXID map[id.RoomID]*Guild
guildsByID map[string]*Guild
guildsLock sync.Mutex
puppets map[string]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
attachmentTransfers *util.SyncMap[attachmentKey, *util.ReturnableOnce[*database.File]]
}
func (br *DiscordBridge) GetExampleConfig() string {
return ExampleConfig
}
func (br *DiscordBridge) GetConfigPtr() interface{} {
br.Config = &config.Config{
BaseConfig: &br.Bridge.Config,
}
br.Config.BaseConfig.Bridge = &br.Config.Bridge
return br.Config
}
func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
matrixHTMLParser.PillConverter = br.pillConverter
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
// TODO move this to mautrix-go?
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
files := strings.Split(file, "/")
file = files[len(files)-1]
name := runtime.FuncForPC(pc).Name()
fns := strings.Split(name, ".")
name = fns[len(fns)-1]
return fmt.Sprintf("%s:%d:%s()", file, line, name)
}
}
func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
go br.startUsers()
}
func (br *DiscordBridge) Stop() {
for _, user := range br.usersByMXID {
if user.Session == nil {
continue
}
br.Log.Debugln("Disconnecting", user.MXID)
user.Session.Close()
}
}
func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
p := br.GetPortalByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
p := br.GetUserByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
_, isGhost := br.ParsePuppetMXID(mxid)
return isGhost
}
func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
p := br.GetPuppetByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
//TODO implement
}
func main() {
br := &DiscordBridge{
usersByMXID: make(map[id.UserID]*User),
usersByID: make(map[string]*User),
managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal),
portalsByID: make(map[database.PortalKey]*Portal),
threadsByID: make(map[string]*Thread),
threadsByRootMXID: make(map[id.EventID]*Thread),
threadsByCreationNoticeMXID: make(map[id.EventID]*Thread),
guildsByID: make(map[string]*Guild),
guildsByMXID: make(map[id.RoomID]*Guild),
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.3.0",
ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
ConfigUpgrader: &configupgrade.StructUpgrader{
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
Blocks: config.SpacedBlocks,
Base: ExampleConfig,
},
Child: br,
}
br.InitVersion(Tag, Commit, BuildTime)
br.Main()
}

150
pkg/connector/backfill.go Normal file
View File

@@ -0,0 +1,150 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"slices"
"strconv"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
var (
_ bridgev2.BackfillingNetworkAPI = (*DiscordClient)(nil)
)
func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if !dc.IsLoggedIn() {
return nil, bridgev2.ErrNotLoggedIn
}
channelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
log := zerolog.Ctx(ctx).With().
Str("action", "fetch messages").
Str("channel_id", channelID).
Int("desired_count", fetchParams.Count).
Bool("forward", fetchParams.Forward).Logger()
ctx = log.WithContext(ctx)
var beforeID string
var afterID string
if fetchParams.AnchorMessage != nil {
anchorID := discordid.ParseMessageID(fetchParams.AnchorMessage.ID)
if fetchParams.Forward {
afterID = anchorID
} else {
beforeID = anchorID
}
}
// ChannelMessages returns messages ordered from newest to oldest.
count := min(fetchParams.Count, 100)
log.Debug().Msg("Fetching channel history for backfill")
msgs, err := dc.Session.ChannelMessages(channelID, count, beforeID, afterID, "")
if err != nil {
return nil, err
}
// Update our user cache with all of the users present in the response. This
// indirectly makes `GetUserInfo` on `DiscordClient` return the information
// we've fetched above.
cachedDiscordUserIDs := dc.userCache.UpdateWithMessages(msgs)
{
log := zerolog.Ctx(ctx).With().
Str("action", "update ghosts via fetched messages").
Logger()
ctx := log.WithContext(ctx)
// Update/create all of the ghosts for the users involved. This lets us
// set a correct per-message profile on each message, even for users
// that we've never seen until now.
for _, discordUserID := range cachedDiscordUserIDs {
ghost, err := dc.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID))
if err != nil {
log.Err(err).Str("ghost_id", discordUserID).
Msg("Failed to get ghost associated with message")
continue
}
ghost.UpdateInfoIfNecessary(ctx, dc.UserLogin, bridgev2.RemoteEventMessage)
}
}
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
// NOTE: For now, we aren't backfilling reactions. This is because:
//
// - Discord does not provide enough historical reaction data in the
// response from the message history endpoint to construct valid
// BackfillReactions.
// - Fetching the reaction data would be prohibitively expensive for
// messages with many reactions. Messages in large guilds can have
// tens of thousands of reactions.
// - Indicating aggregated child events[1] from BackfillMessage doesn't
// seem possible due to how portal backfilling batching currently
// works.
//
// [1]: https://spec.matrix.org/v1.16/client-server-api/#reference-relations
//
// It might be worth fetching the reaction data anyways if we observe
// a small overall number of reactions.
sender := dc.makeEventSender(msg.Author)
// Use the ghost's intent, falling back to the bridge's.
ghost, err := dc.connector.Bridge.GetGhostByID(ctx, sender.Sender)
if err != nil {
log.Err(err).Msg("Failed to look up ghost while converting backfilled message")
}
var intent bridgev2.MatrixAPI
if ghost == nil {
intent = fetchParams.Portal.Bridge.Bot
} else {
intent = ghost.Intent
}
converted = append(converted, &bridgev2.BackfillMessage{
ID: discordid.MakeMessageID(msg.ID),
ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, dc.Session, msg),
Sender: sender,
Timestamp: ts,
StreamOrder: streamOrder,
})
}
// FetchMessagesResponse expects messages to always be ordered from oldest to newest.
slices.Reverse(converted)
log.Debug().Int("converted_count", len(converted)).Msg("Finished fetching and converting, returning backfill response")
return &bridgev2.FetchMessagesResponse{
Messages: converted,
Forward: fetchParams.Forward,
// This might not actually be true if the channel's total number of messages is itself a multiple
// of `count`, but that's probably okay.
HasMore: len(msgs) == count,
}, nil
}

View File

@@ -0,0 +1,145 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
)
var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
Provisioning: bridgev2.ProvisioningCapabilities{
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{},
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{},
},
}
func (dc *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return DiscordGeneralCaps
}
func (wa *DiscordConnector) GetBridgeInfoVersion() (info, caps int) {
return 1, 1
}
/*func supportedIfFFmpeg() event.CapabilitySupportLevel {
if ffmpeg.Supported() {
return event.CapLevelPartialSupport
}
return event.CapLevelRejected
}*/
func capID() string {
base := "fi.mau.discord.capabilities.2025_11_20"
if ffmpeg.Supported() {
return base + "+ffmpeg"
}
return base
}
// TODO: This limit is increased depending on user subscription status (Discord Nitro).
const MaxTextLength = 2000
// TODO: This limit is increased depending on user subscription status (Discord Nitro).
// TODO: Verify this figure (10 MiB).
const MaxFileSize = 10485760
var discordCaps = &event.RoomFeatures{
ID: capID(),
Reply: event.CapLevelFullySupported,
Reaction: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
Delete: event.CapLevelFullySupported,
Formatting: event.FormattingFeatureMap{
event.FmtBold: event.CapLevelFullySupported,
event.FmtItalic: event.CapLevelFullySupported,
event.FmtStrikethrough: event.CapLevelFullySupported,
event.FmtInlineCode: event.CapLevelFullySupported,
event.FmtCodeBlock: event.CapLevelFullySupported,
event.FmtSyntaxHighlighting: event.CapLevelFullySupported,
event.FmtBlockquote: event.CapLevelFullySupported,
event.FmtInlineLink: event.CapLevelFullySupported,
event.FmtUserLink: event.CapLevelUnsupported, // TODO: Support.
event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support.
event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support.
event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support.
event.FmtUnorderedList: event.CapLevelFullySupported,
event.FmtOrderedList: event.CapLevelFullySupported,
event.FmtListStart: event.CapLevelFullySupported,
event.FmtListJumpValue: event.CapLevelUnsupported,
event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support.
},
File: event.FileFeatureMap{
event.MsgImage: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"image/jpeg": event.CapLevelFullySupported,
"image/png": event.CapLevelFullySupported,
"image/gif": event.CapLevelFullySupported,
"image/webp": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
MaxSize: MaxFileSize,
},
event.MsgVideo: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"video/mp4": event.CapLevelFullySupported,
"video/webm": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
MaxSize: MaxFileSize,
},
event.MsgAudio: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"audio/mpeg": event.CapLevelFullySupported,
"audio/webm": event.CapLevelFullySupported,
"audio/wav": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
MaxSize: MaxFileSize,
},
event.MsgFile: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"*/*": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
MaxSize: MaxFileSize,
},
event.CapMsgGIF: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"image/gif": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
MaxSize: MaxFileSize,
},
// TODO: Support voice messages.
},
LocationMessage: event.CapLevelUnsupported,
MaxTextLength: MaxTextLength,
// TODO: Support threads.
}
func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
return discordCaps
}

188
pkg/connector/chatinfo.go Normal file
View File

@@ -0,0 +1,188 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space.
func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) {
selfEvtSender := d.selfEventSender()
return &bridgev2.ChatInfo{
Name: &guild.Name,
Topic: nil,
Members: &bridgev2.ChatMemberList{
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfEvtSender.Sender: {EventSender: selfEvtSender},
},
// As recommended by the spec, prohibit normal events by setting
// events_default to a suitably high number.
PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
},
Avatar: d.makeAvatarForGuild(guild),
Type: ptr.Ptr(database.RoomTypeSpace),
}, nil
}
func channelIsPrivate(ch *discordgo.Channel) bool {
return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
}
func (d *DiscordClient) makeAvatarForChannel(ctx context.Context, ch *discordgo.Channel) *bridgev2.Avatar {
// TODO make this configurable (ala workspace_avatar_in_rooms)
if !channelIsPrivate(ch) {
guild, err := d.Session.State.Guild(ch.GuildID)
if err != nil || guild == nil {
zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
return nil
}
return d.makeAvatarForGuild(guild)
}
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(ch.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
return d.simpleDownload(ctx, url, "channel/gdm icon")
},
Remove: ch.Icon == "",
}
}
func (d *DiscordClient) getPrivateChannelMemberList(ch *discordgo.Channel) bridgev2.ChatMemberList {
var members bridgev2.ChatMemberList
members.IsFull = true
members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
if len(ch.Recipients) > 0 {
selfEventSender := d.selfEventSender()
// Private channels' array of participants doesn't include ourselves,
// so inject ourselves as a member.
members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
for _, recipient := range ch.Recipients {
sender := d.makeEventSender(recipient)
members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
}
members.TotalMemberCount = len(ch.Recipients)
}
return members
}
// getChannelChatInfo computes [bridgev2.ChatInfo] for a guild channel or private (DM or group DM) channel.
func (d *DiscordClient) getChannelChatInfo(ctx context.Context, ch *discordgo.Channel) (*bridgev2.ChatInfo, error) {
var roomType database.RoomType
switch ch.Type {
case discordgo.ChannelTypeGuildCategory:
roomType = database.RoomTypeSpace
case discordgo.ChannelTypeDM:
roomType = database.RoomTypeDM
case discordgo.ChannelTypeGroupDM:
roomType = database.RoomTypeGroupDM
default:
roomType = database.RoomTypeDefault
}
var parentPortalID *networkid.PortalID
if ch.Type == discordgo.ChannelTypeGuildCategory || (ch.ParentID == "" && ch.GuildID != "") {
// Categories and uncategorized guild channels always have the guild as their parent.
parentPortalID = ptr.Ptr(discordid.MakeGuildPortalIDWithID(ch.GuildID))
} else if ch.ParentID != "" {
// Categorized guild channels.
parentPortalID = ptr.Ptr(discordid.MakeChannelPortalIDWithID(ch.ParentID))
}
var memberList bridgev2.ChatMemberList
if channelIsPrivate(ch) {
memberList = d.getPrivateChannelMemberList(ch)
} else {
// TODO we're _always_ sending partial member lists for guilds; we can probably
// do better than that
selfEventSender := d.selfEventSender()
memberList = bridgev2.ChatMemberList{
IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfEventSender.Sender: {EventSender: selfEventSender},
},
}
}
return &bridgev2.ChatInfo{
Name: &ch.Name,
Topic: &ch.Topic,
Avatar: d.makeAvatarForChannel(ctx, ch),
Members: &memberList,
Type: &roomType,
ParentID: parentPortalID,
CanBackfill: true,
ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
meta := portal.Metadata.(*discordid.PortalMetadata)
if meta.GuildID != ch.GuildID {
meta.GuildID = ch.GuildID
changed = true
}
return
},
}, nil
}
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
guildID := discordid.ParseGuildPortalID(portal.ID)
if guildID != "" {
// Portal is a space representing a Discord guild.
guild, err := d.Session.State.Guild(guildID)
if err != nil {
return nil, fmt.Errorf("couldn't get guild: %w", err)
}
return d.getGuildSpaceInfo(ctx, guild)
} else {
// Portal is to a channel of some kind (private or guild).
channelID := discordid.ParseChannelPortalID(portal.ID)
ch, err := d.Session.State.Channel(channelID)
if err != nil {
return nil, fmt.Errorf("couldn't get channel: %w", err)
}
return d.getChannelChatInfo(ctx, ch)
}
}

437
pkg/connector/client.go Normal file
View File

@@ -0,0 +1,437 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"errors"
"fmt"
"io"
"maps"
"net/http"
"slices"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordClient struct {
connector *DiscordConnector
UserLogin *bridgev2.UserLogin
Session *discordgo.Session
client *http.Client
hasBegunSyncing bool
markedOpened map[string]time.Time
markedOpenedLock sync.Mutex
userCache *UserCache
}
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
meta := login.Metadata.(*discordid.UserLoginMetadata)
session, err := NewDiscordSession(ctx, meta.Token)
if err != nil {
return err
}
cl := DiscordClient{
connector: d,
UserLogin: login,
Session: session,
client: d.Bridge.GetHTTPClientSettings().Compile(),
userCache: NewUserCache(session),
}
login.Client = &cl
return nil
}
var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
func (d *DiscordClient) Connect(ctx context.Context) {
log := zerolog.Ctx(ctx)
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
if meta.HeartbeatSession.IsExpired() {
log.Info().Msg("Heartbeat session expired, creating a new one")
meta.HeartbeatSession = discordgo.NewHeartbeatSession()
}
meta.HeartbeatSession.BumpLastUsed()
d.Session.HeartbeatSession = meta.HeartbeatSession
d.markedOpened = make(map[string]time.Time)
log.Debug().Msg("Connecting to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnecting,
})
if err := d.connect(ctx); err != nil {
log.Err(err).Msg("Couldn't connect to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: "discord-connect-error",
Message: err.Error(),
})
}
}
func (cl *DiscordClient) handleDiscordEventSync(event any) {
go cl.handleDiscordEvent(event)
}
func (cl *DiscordClient) connect(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Info().Msg("Opening session")
cl.Session.EventHandler = cl.handleDiscordEventSync
err := cl.Session.Open()
for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 {
log.Err(err).Int("attempts", attempts).Msg("Immediately disconnected while trying to open session, trying again in 5 seconds")
time.Sleep(5 * time.Second)
err = cl.Session.Open()
}
if err != nil {
log.Err(err).Msg("Failed to connect to Discord")
return err
}
// Ensure that we actually have a user.
if !cl.IsLoggedIn() {
return fmt.Errorf("unknown identity even after connecting to Discord")
}
user := cl.Session.State.User
log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
// Populate the user cache with the users from the READY payload.
log.Debug().Int("n_users", len(cl.Session.State.Ready.Users)).Msg("Inserting users from READY into cache")
cl.userCache.UpdateWithReady(&cl.Session.State.Ready)
cl.BeginSyncing(ctx)
return nil
}
func (d *DiscordClient) Disconnect() {
d.UserLogin.Log.Info().Msg("Disconnecting session")
d.Session.Close()
}
func (d *DiscordClient) IsLoggedIn() bool {
return d.Session != nil && d.Session.State != nil && d.Session.State.User != nil && d.Session.State.User.ID != ""
}
func (d *DiscordClient) LogoutRemote(ctx context.Context) {
// FIXME(skip): Implement.
d.Disconnect()
}
func (cl *DiscordClient) BeginSyncing(ctx context.Context) {
if cl.hasBegunSyncing {
cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
return
}
cl.hasBegunSyncing = true
log := cl.UserLogin.Log
user := cl.Session.State.User
// FIXME(skip): Avatar.
cl.UserLogin.RemoteProfile = status.RemoteProfile{
Email: user.Email,
Phone: user.Phone,
Name: user.String(),
}
if err := cl.UserLogin.Save(ctx); err != nil {
log.Err(err).Msg("Couldn't save UserLogin after connecting")
}
go cl.syncPrivateChannels(ctx)
go cl.syncGuilds(ctx)
}
func (d *DiscordClient) syncPrivateChannels(ctx context.Context) {
dms := slices.Clone(d.Session.State.PrivateChannels)
// Only sync the top n private channels with recent activity.
slices.SortFunc(dms, func(a, b *discordgo.Channel) int {
ats, _ := discordgo.SnowflakeTimestamp(a.LastMessageID)
bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID)
return bts.Compare(ats)
})
// TODO(skip): This is startup_private_channel_create_limit. Support this in the config.
maxDms := 10
if maxDms > len(dms) {
maxDms = len(dms)
}
for _, dm := range dms[:maxDms] {
zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity")
d.syncChannel(ctx, dm)
}
}
func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Channel) bool {
log := zerolog.Ctx(ctx).With().
Str("channel_id", ch.ID).
Int("channel_type", int(ch.Type)).
Str("action", "determine guild channel visbility").Logger()
sess := d.Session
myDiscordUserID := d.Session.State.User.ID
// To calculate guild channel visibility we need to know our effective permission
// bitmask, which can only be truly determined when we know which roles we have
// in the guild.
//
// To this end, make sure we have detailed information about ourselves in the
// cache ("state").
_, err := sess.State.Member(ch.GuildID, myDiscordUserID)
if errors.Is(err, discordgo.ErrStateNotFound) {
log.Debug().Msg("Fetching own membership in guild to check roles")
member, err := sess.GuildMember(ch.GuildID, myDiscordUserID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else {
err = sess.State.MemberAdd(member)
if err != nil {
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
}
}
} else if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
}
err = sess.State.ChannelAdd(ch)
if err != nil {
log.Warn().Err(err).Msg("Failed to add channel to cache")
}
perms, err := sess.State.UserChannelPermissions(myDiscordUserID, ch.ID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true
}
canView := perms&discordgo.PermissionViewChannel > 0
log.Debug().
Int64("permissions", perms).
Bool("channel_visible", canView).
Msg("Computed visibility of guild channel")
return canView
}
func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey {
// TODO: Support configuring `split_portals`.
return networkid.PortalKey{
ID: discordid.MakeGuildPortalIDWithID(guildID),
Receiver: d.UserLogin.ID,
}
}
func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar {
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(guild.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
return d.simpleDownload(ctx, url, "guild icon")
},
Remove: guild.Icon == "",
}
}
func (d *DiscordClient) syncGuildSpace(_ context.Context, guild *discordgo.Guild) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordGuildResync{
Client: d,
guild: guild,
portalKey: d.guildPortalKeyFromID(guild.ID),
})
}
// bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
// presence in the returned set does not imply anything about the corresponding
// portals and rooms.
func (d *DiscordClient) bridgedGuildIDs() map[string]struct{} {
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
bridgingGuildIDs := map[string]struct{}{}
// guilds that were bridged via the provisioning api
for guildID, bridged := range meta.BridgedGuildIDs {
if bridged {
bridgingGuildIDs[guildID] = struct{}{}
}
}
// guilds that were declared in the configuration file
for _, guildID := range d.connector.Config.Guilds.BridgingGuildIDs {
bridgingGuildIDs[guildID] = struct{}{}
}
return bridgingGuildIDs
}
func (d *DiscordClient) syncGuilds(ctx context.Context) {
guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
for _, guildID := range guildIDs {
log := zerolog.Ctx(ctx).With().
Str("guild_id", guildID).
Str("action", "sync guild").
Logger()
err := d.syncGuild(log.WithContext(ctx), guildID)
if err != nil {
log.Err(err).Msg("Couldn't bridge guild during sync")
}
}
}
// deleteGuildPortalSpace queues a remote event that deletes a guild space
// (including children).
func (d *DiscordClient) deleteGuildPortalSpace(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx)
log.Info().Msg("Unbridging guild by deleting the entire space")
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete,
PortalKey: d.guildPortalKeyFromID(guildID),
},
OnlyForMe: true,
Children: true,
})
}
func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error {
log := zerolog.Ctx(ctx).With().
Str("guild_id", guildID).
Str("action", "bridge guild").
Logger()
ctx = log.WithContext(ctx)
guild, err := d.Session.State.Guild(guildID)
if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil {
log.Err(err).Msg("Couldn't find guild, user isn't a member?")
// TODO likely left/kicked/banned from guild; nuke the portals
return errors.New("couldn't find guild in state")
}
d.syncGuildSpace(ctx, guild)
for _, guildCh := range guild.Channels {
if guildCh.Type != discordgo.ChannelTypeGuildText && guildCh.Type != discordgo.ChannelTypeGuildCategory {
// TODO also bridge news channels
log.Trace().
Str("channel_id", guildCh.ID).
Int("channel_type", int(guildCh.Type)).
Msg("Not bridging guild channel due to type")
continue
}
if !d.canSeeGuildChannel(ctx, guildCh) {
log.Trace().
Str("channel_id", guildCh.ID).
Int("channel_type", int(guildCh.Type)).
Msg("Not bridging guild channel that the user doesn't have permission to view")
continue
}
d.syncChannel(ctx, guildCh)
}
d.subscribeGuild(ctx, guildID)
return nil
}
func (d *DiscordClient) subscribeGuild(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Subscribing to guild")
err := d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guildID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild, proceeding")
}
}
func (d *DiscordClient) simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to prepare request: %w", err)
}
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", thing, err)
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read %s data: %w", thing, err)
}
return data, nil
}
func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: userID == d.Session.State.User.ID,
SenderLogin: discordid.MakeUserLoginID(userID),
Sender: discordid.MakeUserID(userID),
}
}
func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
return d.makeEventSenderWithID(d.Session.State.User.ID)
}
func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
if user == nil {
panic("DiscordClient makeEventSender was passed a nil user")
}
return d.makeEventSenderWithID(user.ID)
}
func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
Client: d,
channel: ch,
portalKey: discordid.MakeChannelPortalKey(ch, d.UserLogin.ID, true),
})
}

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// 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
@@ -14,22 +14,27 @@
// 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 config
package connector
import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
_ "embed"
up "go.mau.fi/util/configupgrade"
)
//go:embed example-config.yaml
var ExampleConfig string
type Config struct {
*bridgeconfig.BaseConfig `yaml:",inline"`
Bridge BridgeConfig `yaml:"bridge"`
Guilds struct {
BridgingGuildIDs []string `yaml:"bridging_guild_ids"`
} `yaml:"guilds"`
}
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
return hasSecret
func upgradeConfig(helper up.Helper) {
helper.Copy(up.List, "guilds", "bridging_guild_ids")
}
func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) {
return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig)
}

View File

@@ -0,0 +1,79 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/pkg/discordid"
"go.mau.fi/mautrix-discord/pkg/msgconv"
)
type DiscordConnector struct {
Bridge *bridgev2.Bridge
Config Config
MsgConv *msgconv.MessageConverter
}
var (
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
_ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
)
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
d.Bridge = bridge
d.MsgConv = msgconv.NewMessageConverter(bridge)
}
func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
d.MsgConv.MaxFileSize = maxSize
}
func (d *DiscordConnector) Start(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Setting up provisioning API")
err := d.setUpProvisioningAPIs()
if err != nil {
log.Err(err).Msg("Failed to set up provisioning API, proceeding")
// Don't treat this error as fatal.
}
return nil
}
func (d *DiscordConnector) GetName() bridgev2.BridgeName {
return bridgev2.BridgeName{
DisplayName: "Discord",
NetworkURL: "https://discord.com",
NetworkIcon: "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC",
NetworkID: "discord",
BeeperBridgeType: "discordgo",
DefaultPort: 29334,
}
}
func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(discordid.GenerateNonce())
}

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// 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
@@ -14,19 +14,21 @@
// 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 upgrades
package connector
import (
"embed"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/util/dbutil"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.RegisterFS(rawUpgrades)
func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes {
return database.MetaTypes{
Portal: func() any {
return &discordid.PortalMetadata{}
},
UserLogin: func() any {
return &discordid.UserLoginMetadata{}
},
}
}

View File

@@ -0,0 +1,75 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordChatResync struct {
Client *DiscordClient
channel *discordgo.Channel
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
)
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
return c
}
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}
func (d *DiscordChatResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
if latestBridged == nil {
zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
return false, nil
}
return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
}

View File

@@ -0,0 +1,61 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type DiscordGuildResync struct {
Client *DiscordClient
guild *discordgo.Guild
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordGuildResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordGuildResync)(nil)
)
func (d *DiscordGuildResync) AddLogContext(c zerolog.Context) zerolog.Context {
return c.Str("guild_id", d.guild.ID).Str("guild_name", d.guild.Name)
}
func (d *DiscordGuildResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordGuildResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordGuildResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordGuildResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}

View File

@@ -0,0 +1,6 @@
# Configuration options related to Discord guilds (also known as "servers").
guilds:
# UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure
# during bridge development. If no guild IDs are specified, then no guilds
# are bridged at all.
bridging_guild_ids: []

View File

@@ -0,0 +1,340 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"runtime/debug"
"slices"
"strconv"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordEventMeta struct {
Type bridgev2.RemoteEventType
PortalKey networkid.PortalKey
LogContext func(c zerolog.Context) zerolog.Context
}
func (em *DiscordEventMeta) AddLogContext(c zerolog.Context) zerolog.Context {
if em.LogContext == nil {
return c
}
c = em.LogContext(c)
return c
}
func (em *DiscordEventMeta) GetType() bridgev2.RemoteEventType {
return em.Type
}
func (em *DiscordEventMeta) GetPortalKey() networkid.PortalKey {
return em.PortalKey
}
type DiscordMessage struct {
*DiscordEventMeta
Data *discordgo.Message
Client *DiscordClient
}
func (m *DiscordMessage) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
log := zerolog.Ctx(ctx).With().
Str("action", "convert discord edit").Logger()
ctx = log.WithContext(ctx)
// FIXME don't redundantly reupload attachments
convertedEdit := m.Client.connector.MsgConv.ToMatrix(
ctx,
portal,
intent,
m.Client.UserLogin,
m.Client.Session,
m.Data,
)
// TODO this is really gross and relies on how we assign incrementing numeric
// part ids. to return a semantically correct `ConvertedEdit` we should ditch
// this system
slices.SortStableFunc(existing, func(a *database.Message, b *database.Message) int {
ai, _ := strconv.Atoi(string(a.PartID))
bi, _ := strconv.Atoi(string(b.PartID))
return ai - bi
})
if len(convertedEdit.Parts) != len(existing) {
// FIXME support # of parts changing; triggerable by removing individual
// attachments, etc.
//
// at the very least we can make this better by handling attachments,
// which are always(?) at the end
log.Warn().Int("n_parts_existing", len(existing)).Int("n_parts_after_edit", len(convertedEdit.Parts)).
Msg("Ignoring message edit that changed number of parts")
return nil, bridgev2.ErrIgnoringRemoteEvent
}
parts := make([]*bridgev2.ConvertedEditPart, 0, len(existing))
for pi, part := range convertedEdit.Parts {
parts = append(parts, part.ToEditPart(existing[pi]))
}
return &bridgev2.ConvertedEdit{
ModifiedParts: parts,
}, nil
}
var (
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
_ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
)
func (m *DiscordMessage) GetTargetMessage() networkid.MessageID {
return discordid.MakeMessageID(m.Data.ID)
}
func (m *DiscordMessage) GetTransactionID() networkid.TransactionID {
if m.Data.Nonce == "" {
return ""
}
return networkid.TransactionID(m.Data.Nonce)
}
func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil
}
func (m *DiscordMessage) GetID() networkid.MessageID {
return discordid.MakeMessageID(m.Data.ID)
}
func (m *DiscordMessage) GetSender() bridgev2.EventSender {
if m.Data.Author == nil {
// Message deletions don't have a sender associated with them.
return bridgev2.EventSender{}
}
return m.Client.makeEventSender(m.Data.Author)
}
func (d *DiscordClient) wrapDiscordMessage(msg *discordgo.Message, typ bridgev2.RemoteEventType) DiscordMessage {
return DiscordMessage{
DiscordEventMeta: &DiscordEventMeta{
Type: typ,
PortalKey: networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(msg.ChannelID),
Receiver: d.UserLogin.ID,
},
},
Data: msg,
Client: d,
}
}
type DiscordReaction struct {
*DiscordEventMeta
Reaction *discordgo.MessageReaction
Client *DiscordClient
}
func (r *DiscordReaction) GetSender() bridgev2.EventSender {
return r.Client.makeEventSenderWithID(r.Reaction.UserID)
}
func (r *DiscordReaction) GetTargetMessage() networkid.MessageID {
return discordid.MakeMessageID(r.Reaction.MessageID)
}
func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID {
return discordid.MakeEmojiID(r.Reaction.Emoji.Name)
}
var (
_ bridgev2.RemoteReaction = (*DiscordReaction)(nil)
_ bridgev2.RemoteReactionRemove = (*DiscordReaction)(nil)
_ bridgev2.RemoteReactionWithExtraContent = (*DiscordReaction)(nil)
)
func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) {
// name is either a grapheme cluster consisting of a Unicode emoji, or the
// name of a custom emoji.
name := r.Reaction.Emoji.Name
return name, discordid.MakeEmojiID(name)
}
func (r *DiscordReaction) GetReactionExtraContent() map[string]any {
extra := make(map[string]any)
reaction := r.Reaction
emoji := reaction.Emoji
if emoji.ID != "" {
// The emoji is a custom emoji.
extra["fi.mau.discord.reaction"] = map[string]any{
"id": emoji.ID,
"name": emoji.Name,
// FIXME Handle custom emoji.
// "mxc": reaction,
}
wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name)
extra["com.beeper.reaction.shortcode"] = wrappedShortcode
}
return extra
}
func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction, beingAdded bool) DiscordReaction {
evtType := bridgev2.RemoteEventReaction
if !beingAdded {
evtType = bridgev2.RemoteEventReactionRemove
}
return DiscordReaction{
DiscordEventMeta: &DiscordEventMeta{
Type: evtType,
PortalKey: networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(reaction.ChannelID),
Receiver: d.UserLogin.ID,
},
},
Reaction: reaction,
Client: d,
}
}
func (d *DiscordClient) handleDiscordTyping(ctx context.Context, typing *discordgo.TypingStart) {
if typing.UserID == d.Session.State.User.ID {
return
}
log := zerolog.Ctx(ctx)
portalKey := networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(typing.ChannelID),
Receiver: d.UserLogin.ID,
}
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
log.Err(err).Msg("Failed to query for existing portal")
return
}
if portal == nil || portal.MXID == "" {
return
}
// Make sure we have this user's info in case we haven't seen them at all yet.
_ = d.userCache.Resolve(ctx, typing.UserID)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Typing{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventTyping,
PortalKey: portalKey,
Sender: d.makeEventSenderWithID(typing.UserID),
},
Timeout: 12 * time.Second,
Type: bridgev2.TypingTypeText,
})
}
func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
defer func() {
err := recover()
if err != nil {
d.UserLogin.Log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Any(zerolog.ErrorFieldName, err).
Msg("Panic in Discord event handler")
}
}()
log := d.UserLogin.Log.With().Str("action", "handle discord event").
Type("event_type", rawEvt).
Logger()
ctx := log.WithContext(d.UserLogin.Bridge.BackgroundCtx)
// NOTE: discordgo seemingly dispatches both the proper unmarshalled type
// (e.g. `*discordgo.TypingStart`) _as well as_ a "raw" *discordgo.Event
// (e.g. `*discordgo.Event` with `Type` of `TYPING_START`) for every gateway
// event
switch evt := rawEvt.(type) {
case *discordgo.Ready:
log.Info().Msg("Received READY dispatch from discordgo")
d.userCache.UpdateWithReady(evt)
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected,
})
case *discordgo.TypingStart:
d.handleDiscordTyping(ctx, evt)
case *discordgo.Resumed:
log.Info().Msg("Received RESUMED dispatch from discordgo")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected,
})
case *discordgo.MessageCreate:
if evt.Author == nil {
log.Trace().Int("message_type", int(evt.Message.Type)).
Str("guild_id", evt.GuildID).
Str("message_id", evt.ID).
Str("channel_id", evt.ChannelID).
Msg("Dropping message that lacks an author")
return
}
d.userCache.UpdateWithMessage(evt.Message)
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessage)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.MessageUpdate:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventEdit)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.UserUpdate:
d.userCache.UpdateWithUserUpdate(evt)
case *discordgo.MessageDelete:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessageRemove)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
// TODO *discordgo.MessageDeleteBulk
case *discordgo.MessageReactionAdd:
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.MessageReactionRemove:
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, false)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
// TODO case *discordgo.MessageReactionRemoveAll:
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
case *discordgo.PresenceUpdate:
return
case *discordgo.GuildDelete:
if evt.Unavailable {
log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
// For now, leave the portals alone if the guild only went away due to an outage.
return
}
d.deleteGuildPortalSpace(ctx, evt.ID)
}
}

View File

@@ -0,0 +1,247 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/util/variationselector"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
var (
_ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil)
)
func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
if d.Session == nil {
return nil, bridgev2.ErrNotLoggedIn
}
portal := msg.Portal
guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(portal.ID)
sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg)
if err != nil {
return nil, err
}
var options []discordgo.RequestOption
// TODO: When supporting threads (and not a bot user), send a thread referer.
options = append(options, discordgo.WithChannelReferer(guildID, channelID))
sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParseChannelPortalID(msg.Portal.ID), sendReq, options...)
if err != nil {
return nil, err
}
sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID)
return &bridgev2.MatrixMessageResponse{
DB: &database.Message{
ID: discordid.MakeMessageID(sentMsg.ID),
SenderID: discordid.MakeUserID(sentMsg.Author.ID),
Timestamp: sentMsgTimestamp,
},
}, nil
}
func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger()
ctx = log.WithContext(ctx)
content, _ := d.connector.MsgConv.ConvertMatrixMessageContent(
ctx,
msg.Portal,
msg.Content,
// Disregard link previews for now. Discord generally allows you to
// remove individual link previews from a message though.
[]string{},
)
_, err := d.Session.ChannelMessageEdit(
discordid.ParseChannelPortalID(msg.Portal.ID),
discordid.ParseMessageID(msg.EditTarget.ID),
content,
)
if err != nil {
return fmt.Errorf("failed to send message edit to discord: %w", err)
}
return nil
}
func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
key := variationselector.FullyQualify(reaction.Content.RelatesTo.Key)
// TODO: Handle custom emoji.
return bridgev2.MatrixReactionPreResponse{
SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID),
EmojiID: discordid.MakeEmojiID(key),
}, nil
}
func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
portal := reaction.Portal
meta := portal.Metadata.(*discordid.PortalMetadata)
err := d.Session.MessageReactionAddUser(
meta.GuildID,
discordid.ParseChannelPortalID(portal.ID),
discordid.ParseMessageID(reaction.TargetMessage.ID),
discordid.ParseEmojiID(reaction.PreHandleResp.EmojiID),
)
return nil, err
}
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
removing := removal.TargetReaction
emojiID := removing.EmojiID
channelID := discordid.ParseChannelPortalID(removing.Room.ID)
guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID))
return err
}
func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
channelID := discordid.ParseChannelPortalID(removal.Portal.ID)
messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
return d.Session.ChannelMessageDelete(channelID, messageID)
}
func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
// TODO: Support threads.
log := msg.Portal.Log.With().
Str("event_id", string(msg.EventID)).
Str("action", "matrix read receipt").Logger()
var targetMessageID string
// Figure out the ID of the Discord message that we'll mark as read. If the
// receipt didn't exactly correspond with a message, try finding one close
// by to use as the target.
if msg.ExactMessage != nil {
targetMessageID = discordid.ParseMessageID(msg.ExactMessage.ID)
log = log.With().
Str("message_id", targetMessageID).
Logger()
} else {
closestMessage, err := d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo)
if err != nil {
log.Err(err).Msg("Failed to find closest message part")
return err
} else if closestMessage != nil {
// The read receipt didn't specify an exact message but we were able to
// find one close by.
targetMessageID = discordid.ParseMessageID(closestMessage.ID)
log = log.With().
Str("closest_message_id", targetMessageID).
Str("closest_event_id", closestMessage.MXID.String()).
Logger()
log.Debug().
Msg("Read receipt target event not found, using closest message")
} else {
log.Debug().Msg("Dropping read receipt: no messages found")
return nil
}
}
// TODO: Support threads.
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID))
if err != nil {
log.Err(err).Msg("Failed to send read receipt to Discord")
return err
} else if resp.Token != nil {
log.Debug().
Str("unexpected_resp_token", *resp.Token).
Msg("Marked message as read on Discord (and got unexpected non-nil token)")
} else {
log.Debug().Msg("Marked message as read on Discord")
}
return nil
}
func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error {
if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" {
// Only private channels need this logic.
return nil
}
d.markedOpenedLock.Lock()
defer d.markedOpenedLock.Unlock()
channelID := discordid.ParseChannelPortalID(portal.ID)
log := zerolog.Ctx(ctx).With().
Str("channel_id", channelID).Logger()
lastMarkedOpenedTs := d.markedOpened[channelID]
if lastMarkedOpenedTs.IsZero() {
d.markedOpened[channelID] = time.Now()
err := d.Session.MarkViewing(channelID)
if err != nil {
log.Error().Err(err).Msg("Failed to mark user as viewing channel")
return err
}
log.Trace().Msg("Marked channel as being viewed")
} else {
log.Trace().Str("channel_id", channelID).
Msg("Already marked channel as viewed, not doing so")
}
return nil
}
func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
log := zerolog.Ctx(ctx)
// Don't mind if this fails.
_ = d.viewingChannel(ctx, msg.Portal)
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
// TODO: Support threads properly when sending the referer.
err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID))
if err != nil {
log.Warn().Err(err).Msg("Failed to mark user as typing")
return err
}
log.Debug().Msg("Marked user as typing")
return nil
}

64
pkg/connector/login.go Normal file
View File

@@ -0,0 +1,64 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"maunium.net/go/mautrix/bridgev2"
)
const LoginStepIDComplete = "fi.mau.discord.login.complete"
func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{
{
ID: LoginFlowIDBrowser,
Name: "Browser",
Description: "Log in to your Discord account in a web browser.",
},
{
ID: LoginFlowIDRemoteAuth,
Name: "QR Code",
Description: "Scan a QR code with the Discord mobile app to log in.",
},
{
ID: LoginFlowIDToken,
Name: "Token",
Description: "Provide a Discord user token to connect with.",
},
}
}
func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
login := DiscordGenericLogin{
connector: d,
User: user,
}
switch flowID {
case LoginFlowIDToken:
return &DiscordTokenLogin{DiscordGenericLogin: &login}, nil
case LoginFlowIDRemoteAuth:
return &DiscordRemoteAuthLogin{DiscordGenericLogin: &login}, nil
case LoginFlowIDBrowser:
return &DiscordBrowserLogin{DiscordGenericLogin: &login}, nil
default:
return nil, fmt.Errorf("unknown discord login flow id")
}
}

View File

@@ -0,0 +1,97 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
)
const LoginFlowIDBrowser = "token"
type DiscordBrowserLogin struct {
*DiscordGenericLogin
}
var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil)
const ExtractDiscordTokenJS = `
new Promise((resolve) => {
let mautrixDiscordTokenCheckInterval
const iframe = document.createElement('iframe')
document.head.append(iframe)
mautrixDiscordTokenCheckInterval = setInterval(() => {
const token = iframe.contentWindow.localStorage.token
if (token) {
resolve({ token: token.slice(1, -1) })
clearInterval(mautrixDiscordTokenCheckInterval)
}
}, 200)
})
`
func (dl *DiscordBrowserLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeCookies,
StepID: "fi.mau.discord.cookies",
Instructions: "Log in with Discord.",
CookiesParams: &bridgev2.LoginCookiesParams{
URL: "https://discord.com/login",
UserAgent: "",
Fields: []bridgev2.LoginCookieField{{
ID: "token",
Required: true,
Sources: []bridgev2.LoginCookieFieldSource{{
Type: bridgev2.LoginCookieTypeSpecial,
Name: "fi.mau.discord.token",
}},
}},
ExtractJS: ExtractDiscordTokenJS,
},
}, nil
}
func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
log := zerolog.Ctx(ctx)
token := cookies["token"]
if token == "" {
log.Error().Msg("Received empty token")
return nil, fmt.Errorf("received empty token")
}
log.Debug().Msg("Logging in with submitted cookie")
ul, err := dl.FinalizeCreatingLogin(ctx, token)
if err != nil {
return nil, fmt.Errorf("couldn't log in via browser: %w", err)
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepIDComplete,
Instructions: dl.CompleteInstructions(),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}

View File

@@ -0,0 +1,100 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// DiscordGenericLogin is embedded within each struct that implements
// bridgev2.LoginProcess in order to encapsulate the common behavior that needs
// to occur after procuring a valid user token. Namely, creating a gateway
// connection to Discord and an associated UserLogin to wrap things up.
//
// It also implements a baseline Cancel method that closes the gateway
// connection.
type DiscordGenericLogin struct {
User *bridgev2.User
connector *DiscordConnector
Session *discordgo.Session
// The Discord user we've authenticated as. This is only non-nil if
// a call to FinalizeCreatingLogin has succeeded.
DiscordUser *discordgo.User
}
func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
log := zerolog.Ctx(ctx).With().Str("action", "finalize login").Logger()
// TODO we don't need an entire discordgo session for this as we're just
// interested in /users/@me
log.Info().Msg("Creating initial session with provided token")
session, err := NewDiscordSession(ctx, token)
if err != nil {
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
dl.Session = session
log.Info().Msg("Requesting @me with provided token")
self, err := session.User("@me")
if err != nil {
return nil, fmt.Errorf("couldn't request self user (bad credentials?): %w", err)
}
dl.DiscordUser = self
log.Info().Msg("Fetched @me")
ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
ID: discordid.MakeUserLoginID(self.ID),
Metadata: &discordid.UserLoginMetadata{
Token: token,
HeartbeatSession: session.HeartbeatSession,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
})
if err != nil {
dl.Cancel()
return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
}
(ul.Client.(*DiscordClient)).Connect(ctx)
return ul, nil
}
func (dl *DiscordGenericLogin) CompleteInstructions() string {
return fmt.Sprintf("Logged in as %s", dl.DiscordUser.Username)
}
func (dl *DiscordGenericLogin) Cancel() {
if dl.Session != nil {
dl.User.Log.Debug().Msg("Login cancelled, closing session")
err := dl.Session.Close()
if err != nil {
dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation")
}
}
}

View File

@@ -0,0 +1,141 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"go.mau.fi/mautrix-discord/pkg/remoteauth"
)
const LoginFlowIDRemoteAuth = "qr"
type DiscordRemoteAuthLogin struct {
*DiscordGenericLogin
hasClosed bool
remoteAuthClient *remoteauth.Client
qrChan chan string
doneChan chan struct{}
}
var _ bridgev2.LoginProcessDisplayAndWait = (*DiscordRemoteAuthLogin)(nil)
func (dl *DiscordRemoteAuthLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Creating new remoteauth client")
client, err := remoteauth.New()
if err != nil {
return nil, fmt.Errorf("couldn't create Discord remoteauth client: %w", err)
}
dl.remoteAuthClient = client
dl.qrChan = make(chan string)
dl.doneChan = make(chan struct{})
log.Info().Msg("Starting the QR code login process")
err = client.Dial(ctx, dl.qrChan, dl.doneChan)
if err != nil {
log.Err(err).Msg("Couldn't connect to Discord remoteauth websocket")
close(dl.qrChan)
close(dl.doneChan)
return nil, fmt.Errorf("couldn't connect to Discord remoteauth websocket: %w", err)
}
log.Info().Msg("Waiting for QR code to be ready")
select {
case qrCode := <-dl.qrChan:
log.Info().Int("qr_code_data_len", len(qrCode)).Msg("Received QR code, creating login step")
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: "fi.mau.discord.qr",
Instructions: "On your phone, find “Scan QR Code” in Discords settings.",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeQR,
Data: qrCode,
},
}, nil
case <-ctx.Done():
log.Debug().Msg("Cancelled while waiting for QR code")
return nil, nil
}
}
// Wait implements bridgev2.LoginProcessDisplayAndWait.
func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if dl.doneChan == nil {
panic("can't wait for discord remoteauth without a doneChan")
}
log := zerolog.Ctx(ctx)
log.Debug().Msg("Waiting for remoteauth")
select {
case <-dl.doneChan:
user, err := dl.remoteAuthClient.Result()
if err != nil {
log.Err(err).Msg("Discord remoteauth failed")
return nil, fmt.Errorf("discord remoteauth failed: %w", err)
}
log.Debug().Msg("Discord remoteauth succeeded")
return dl.finalizeSuccessfulLogin(ctx, user)
case <-ctx.Done():
log.Debug().Msg("Cancelled while waiting for remoteauth to complete")
return nil, nil
}
}
func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) {
ul, err := dl.FinalizeCreatingLogin(ctx, user.Token)
if err != nil {
return nil, fmt.Errorf("couldn't log in via remoteauth: %w", err)
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepIDComplete,
Instructions: dl.CompleteInstructions(),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}
func (dl *DiscordRemoteAuthLogin) Cancel() {
// Tolerate multiple attempts to cancel.
if dl.hasClosed {
return
}
dl.hasClosed = true
dl.User.Log.Debug().Msg("Discord remoteauth cancelled")
dl.DiscordGenericLogin.Cancel()
// remoteauth.Client doesn't seem to expose a cancellation method.
close(dl.doneChan)
close(dl.qrChan)
}

View File

@@ -0,0 +1,72 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"maunium.net/go/mautrix/bridgev2"
)
const LoginFlowIDToken = "DEBUG_USERINPUT_token"
type DiscordTokenLogin struct {
*DiscordGenericLogin
}
var _ bridgev2.LoginProcessUserInput = (*DiscordTokenLogin)(nil)
func (dl *DiscordTokenLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: "fi.mau.discord.enter_token",
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{
{
Type: bridgev2.LoginInputFieldTypePassword,
ID: "token",
Name: "Discord user account token",
// Cribbed from https://regex101.com/r/1GMR0y/1.
Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`,
},
},
},
}, nil
}
func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
token := input["token"]
if token == "" {
return nil, fmt.Errorf("no token provided")
}
ul, err := dl.FinalizeCreatingLogin(ctx, token)
if err != nil {
return nil, fmt.Errorf("couldn't login from token: %w", err)
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepIDComplete,
Instructions: dl.CompleteInstructions(),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}

View File

@@ -0,0 +1,291 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"errors"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
const (
ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
ErrCodeGuildBridgeFailed = "M_UNKNOWN"
ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
)
type ProvisioningAPI struct {
log zerolog.Logger
connector *DiscordConnector
prov bridgev2.IProvisioningAPI
}
func (d *DiscordConnector) setUpProvisioningAPIs() error {
c, ok := d.Bridge.Matrix.(bridgev2.MatrixConnectorWithProvisioning)
if !ok {
return errors.New("matrix connector doesn't support provisioning; not setting up")
}
prov := c.GetProvisioning()
r := prov.GetRouter()
if r == nil {
return errors.New("matrix connector's provisioning api didn't return a router")
}
log := d.Bridge.Log.With().Str("component", "provisioning").Logger()
p := &ProvisioningAPI{
connector: d,
log: log,
prov: prov,
}
// NOTE: aim to provide backwards compatibility with v1 provisioning APIs
r.HandleFunc("GET /v1/guilds", p.makeHandler(p.guildsList))
r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild))
r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild))
return nil
}
type guildEntry struct {
ID string `json:"id"`
Name string `json:"name"`
// TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
AvatarURL string `json:"avatar_url"`
// new in v2:
Bridged bool `json:"bridged"`
Available bool `json:"available"`
// legacy fields from v1:
MXID string `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
}
type respGuildsList struct {
Guilds []guildEntry `json:"guilds"`
}
func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := p.prov.GetUser(r)
logins := user.GetUserLogins()
if len(logins) < 1 {
mautrix.RespError{
ErrCode: ErrCodeNotConnected,
Err: "user has no logins",
}.Write(w)
return
}
login := logins[0]
client := login.Client.(*DiscordClient)
handler(w, r, login, client)
}
}
func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
ctx := r.Context()
p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
bridgedGuildIDs := client.bridgedGuildIDs()
var resp respGuildsList
resp.Guilds = []guildEntry{}
for _, guild := range client.Session.State.Guilds {
portalKey := client.guildPortalKeyFromID(guild.ID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
p.log.Err(err).
Str("guild_id", guild.ID).
Msg("Failed to get guild portal for provisioning list")
}
_, beingBridged := bridgedGuildIDs[guild.ID]
mxid := ""
if portal != nil && portal.MXID != "" {
mxid = portal.MXID.String()
} else if beingBridged {
// Beeper Desktop expects the space to exist by the time it receives
// our HTTP response. If it doesn't, then the space won't appear
// until the app is reloaded, and the toggle in the user interface
// won't respond to the user's click.
//
// Pre-bridgev2, we synchronously bridged guilds. However, this
// might take a while for guilds with many channels.
//
// To solve this, generate a deterministic room ID to use as the
// MXID so that it recognizes the guild as bridged, even if the
// portals haven't been created just yet. This lets us
// asynchronously bridge guilds while keeping the UI responsive.
mxid = p.connector.Bridge.Matrix.GenerateDeterministicRoomID(portalKey).String()
}
resp.Guilds = append(resp.Guilds, guildEntry{
// For now, have the ID exactly correspond to the portal ID. This
// practically means that the ID will begin with an asterisk (the
// guild portal ID sigil).
//
// Otherwise, Beeper Desktop will show a duplicate space for every
// guild, as it recognizes the guild returned from this HTTP
// endpoint and the actual space itself as separate "entities".
// (Despite this, they point to identical rooms.)
ID: string(discordid.MakeGuildPortalIDWithID(guild.ID)),
Name: guild.Name,
AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
Bridged: beingBridged,
Available: !guild.Unavailable,
// v1 (legacy) backwards compat:
MXID: mxid,
AutoBridge: beingBridged,
BridgingMode: "everything",
})
}
exhttp.WriteJSONResponse(w, 200, resp)
}
// normalizeGuildID removes the guild portal sigil from a guild ID if it's
// there.
//
// This helps facilitate code that would like to accept portal keys
// corresponding to guilds as well as plain Discord guild IDs.
func normalizeGuildID(guildID string) string {
return strings.TrimPrefix(guildID, discordid.GuildPortalKeySigil)
}
func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
}
p.log.Info().
Str("login_id", discordid.ParseUserLoginID(login.ID)).
Str("guild_id", guildID).
Msg("requested to bridge guild via provisioning api")
meta := login.Metadata.(*discordid.UserLoginMetadata)
if meta.BridgedGuildIDs == nil {
meta.BridgedGuildIDs = map[string]bool{}
}
_, alreadyBridged := meta.BridgedGuildIDs[guildID]
meta.BridgedGuildIDs[guildID] = true
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild bridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
go client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID)
responseStatus := 201
if alreadyBridged {
responseStatus = 200
}
exhttp.WriteJSONResponse(w, responseStatus, nil)
}
func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
}
p.log.Info().
Str("login_id", discordid.ParseUserLoginID(login.ID)).
Str("guild_id", guildID).
Msg("requested to unbridge guild via provisioning api")
meta := login.Metadata.(*discordid.UserLoginMetadata)
if meta.BridgedGuildIDs != nil {
delete(meta.BridgedGuildIDs, guildID)
}
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild unbridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
ctx := login.Log.With().
Str("component", "provisioning").
Str("action", "unbridge guild").
Str("guild_id", guildID).
Logger().
WithContext(context.Background())
portalKey := client.guildPortalKeyFromID(guildID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
p.log.Err(err).Msg("Failed to get guild portal")
mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w)
return
}
if portal == nil || portal.MXID == "" {
mautrix.RespError{
ErrCode: ErrCodeGuildNotBridged,
Err: "guild is not bridged",
}.Write(w)
return
}
children, err := p.connector.Bridge.GetChildPortals(ctx, portalKey)
if err != nil {
p.log.Err(err).Msg("Failed to get child portals")
mautrix.MUnknown.WithMessage("failed to get children: %v", err).Write(w)
return
}
portalsToDelete := append(children, portal)
bridgev2.DeleteManyPortals(ctx, portalsToDelete, func(portal *bridgev2.Portal, del bool, err error) {
p.log.Err(err).
Stringer("portal_mxid", portal.MXID).
Bool("delete_room", del).
Msg("Failed during portal cleanup")
})
p.log.Info().Int("children", len(children)).Msg("Finished unbridging")
exhttp.WriteJSONResponse(w, 200, map[string]any{
"success": true,
"deleted_portals": len(children) + 1,
})
}

49
pkg/connector/session.go Normal file
View File

@@ -0,0 +1,49 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
)
func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, error) {
log := zerolog.Ctx(ctx)
session, err := discordgo.New(token)
if err != nil {
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
// Don't bother tracking things we don't care/support right now. Presences
// are especially expensive to track as they occur extremely frequently.
session.State.TrackPresences = false
session.State.TrackVoice = false
// Set up logging.
session.LogLevel = discordgo.LogInformational
session.Logger = func(msgL, caller int, format string, a ...any) {
// FIXME(skip): Hook up zerolog properly.
log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
}
return session, nil
}

172
pkg/connector/usercache.go Normal file
View File

@@ -0,0 +1,172 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"errors"
"maps"
"net/http"
"slices"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// NOTE: Not simply using `exsync.Map` because we want the lock to be held
// during HTTP requests.
type UserCache struct {
session *discordgo.Session
cache map[string]*discordgo.User
lock sync.Mutex
}
func NewUserCache(session *discordgo.Session) *UserCache {
return &UserCache{
session: session,
cache: make(map[string]*discordgo.User),
}
}
func (uc *UserCache) UpdateWithReady(ready *discordgo.Ready) {
if ready == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range ready.Users {
uc.cache[user.ID] = user
}
}
// UpdateWithMessage updates the user cache with the users involved in a single
// message (author, mentioned, mentioned author, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessage(msg *discordgo.Message) []string {
if msg == nil {
return []string{}
}
// For now just forward to HandleMessages until a need for a specialized
// path makes itself known.
return uc.UpdateWithMessages([]*discordgo.Message{msg})
}
// UpdateWithMessages updates the user cache with the total set of users involved
// with multiple messages (authors, mentioned users, mentioned authors, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessages(msgs []*discordgo.Message) []string {
if len(msgs) == 0 {
return []string{}
}
collectedUsers := map[string]*discordgo.User{}
for _, msg := range msgs {
collectedUsers[msg.Author.ID] = msg.Author
referenced := msg.ReferencedMessage
if referenced != nil && referenced.Author != nil {
collectedUsers[referenced.Author.ID] = referenced.Author
}
for _, mentioned := range msg.Mentions {
collectedUsers[mentioned.ID] = mentioned
}
// Message snapshots lack `author` entirely and seemingly have an empty
// `mentions` array, even when the original message actually mentions
// someone.
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range collectedUsers {
uc.cache[user.ID] = user
}
return slices.Collect(maps.Keys(collectedUsers))
}
func (uc *UserCache) UpdateWithUserUpdate(update *discordgo.UserUpdate) {
if update == nil || update.User == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
uc.cache[update.ID] = update.User
}
// Resolve looks up a user in the cache, requesting the user from the Discord
// HTTP API if not present.
//
// If the user cannot be found, then its nonexistence is cached. This is to
// avoid excessive requests when e.g. backfilling messages from a user that has
// since been deleted since connecting. If some other error occurs, the cache
// isn't touched and nil is returned.
//
// Otherwise, the cache is updated as you'd expect.
func (uc *UserCache) Resolve(ctx context.Context, userID string) *discordgo.User {
if userID == discordid.DeletedGuildUserID {
return &discordid.DeletedGuildUser
}
// Hopefully this isn't too contentious?
uc.lock.Lock()
defer uc.lock.Unlock()
cachedUser, present := uc.cache[userID]
if cachedUser != nil {
return cachedUser
} else if present {
// If a `nil` is present in the map, then we already know that the user
// doesn't exist.
return nil
}
log := zerolog.Ctx(ctx).With().
Str("action", "resolve user").
Str("user_id", userID).Logger()
log.Trace().Msg("Fetching user")
user, err := uc.session.User(userID)
var restError *discordgo.RESTError
if errors.As(err, &restError) && restError.Response.StatusCode == http.StatusNotFound {
log.Info().Msg("Tried to resolve a user that doesn't exist, caching nonexistence")
uc.cache[userID] = nil
return nil
} else if err != nil {
log.Err(err).Msg("Failed to resolve user")
return nil
}
uc.cache[userID] = user
return user
}

72
pkg/connector/userinfo.go Normal file
View File

@@ -0,0 +1,72 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
// We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map
// directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison.
return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID)
}
func (d *DiscordClient) makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
url := u.AvatarURL("256")
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(url),
Get: func(ctx context.Context) ([]byte, error) {
return d.simpleDownload(ctx, url, "user avatar")
},
}
}
func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
log := zerolog.Ctx(ctx)
if ghost.ID == "" {
log.Warn().Msg("Tried to get user info for ghost with no ID")
return nil, nil
}
discordUserID := discordid.ParseUserID(ghost.ID)
discordUser := d.userCache.Resolve(ctx, discordUserID)
if discordUser == nil {
log.Error().Str("discord_user_id", discordUserID).
Msg("Failed to resolve user")
return nil, nil
}
return &bridgev2.UserInfo{
// FIXME clear this for webhooks (stash in ghost metadata)
Identifiers: []string{fmt.Sprintf("discord:%s", discordUser.String())},
Name: ptr.Ptr(discordUser.DisplayName()),
Avatar: d.makeUserAvatar(discordUser),
IsBot: &discordUser.Bot,
}, nil
}

53
pkg/discordid/dbmeta.go Normal file
View File

@@ -0,0 +1,53 @@
// 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 discordid
import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/database"
)
type PortalMetadata struct {
// The ID of the Discord guild that the channel corresponding to this portal
// belongs to.
//
// For private channels (DMs and group DMs), this will be the zero value
// (an empty string).
GuildID string `json:"guild_id"`
}
type UserLoginMetadata struct {
Token string `json:"token"`
HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"`
BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"`
}
var _ database.MetaMerger = (*UserLoginMetadata)(nil)
func (ulm *UserLoginMetadata) CopyFrom(incoming any) {
incomingMeta, ok := incoming.(*UserLoginMetadata)
if !ok || incomingMeta == nil {
return
}
if incomingMeta.Token != "" {
ulm.Token = incomingMeta.Token
}
ulm.HeartbeatSession = discordgo.NewHeartbeatSession()
// Retain the BridgedGuildIDs from the existing login.
}

155
pkg/discordid/id.go Normal file
View File

@@ -0,0 +1,155 @@
// 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 discordid
import (
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/networkid"
)
// DeletedGuildUserID is a magic user ID that is used in place of an actual user
// ID once they have deleted their account. This only applies in non-private
// (i.e. guild) contexts, such as guild channel message authors and mentions.
//
// Note that this user ID can also appear in message content as part of user
// mention markup ("<@456226577798135808>").
const DeletedGuildUserID = "456226577798135808"
// DeletedGuildUser is the user returned from the Discord API as a stand-in for
// users who have since deleted their account. As the name suggests, this only
// applies to fetched entities within guilds.
var DeletedGuildUser = discordgo.User{
ID: DeletedGuildUserID,
Username: "Deleted User",
Discriminator: "0000",
}
const DiscordEpochMillis = 1420070400000
// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
func GenerateNonce() string {
snowflake := (time.Now().UnixMilli() - DiscordEpochMillis) << 22
return strconv.FormatInt(snowflake, 10)
}
func MakeUserID(userID string) networkid.UserID {
return networkid.UserID(userID)
}
func ParseUserID(userID networkid.UserID) string {
return string(userID)
}
func MakeUserLoginID(userID string) networkid.UserLoginID {
return networkid.UserLoginID(userID)
}
func ParseUserLoginID(id networkid.UserLoginID) string {
return string(id)
}
// UserLoginIDToUserID converts a UserLoginID to a UserID. In Discord, both
// are the same underlying snowflake.
func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
return networkid.UserID(id)
}
func MakeChannelPortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(ch.ID)
if wantReceiver {
key.Receiver = userLoginID
}
return
}
func MakeChannelPortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(channelID)
return
}
func MakeChannelPortalIDWithID(channelID string) networkid.PortalID {
return networkid.PortalID(channelID)
}
func ParseChannelPortalID(portalID networkid.PortalID) string {
return string(portalID)
}
func MakeMessageID(messageID string) networkid.MessageID {
return networkid.MessageID(messageID)
}
func ParseMessageID(messageID networkid.MessageID) string {
return string(messageID)
}
func MakeEmojiID(emojiName string) networkid.EmojiID {
return networkid.EmojiID(emojiName)
}
func ParseEmojiID(emojiID networkid.EmojiID) string {
return string(emojiID)
}
func MakeAvatarID(avatar string) networkid.AvatarID {
return networkid.AvatarID(avatar)
}
// The string prepended to [networkid.PortalKey]s identifying spaces that
// bridge Discord guilds.
//
// Every Discord guild created before August 2017 contained a channel
// having _the same ID as the guild itself_. This channel also functioned as
// the "default channel" in that incoming members would view this channel by
// default. It was also impossible to delete.
//
// After this date, these "default channels" became deletable, and fresh guilds
// were no longer created with a channel that exactly corresponded to the guild
// ID.
//
// To accommodate Discord guilds created before this API change that have also
// never deleted the default channel, we need a way to distinguish between the
// guild and the default channel. Otherwise, we wouldn't be able to bridge both
// the channel portal as well as the guild space; their keys would conflict.
//
// "*" was chosen as the asterisk character is used to filter by guilds in
// the quick switcher (in Discord's first-party clients).
//
// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
const GuildPortalKeySigil = "*"
func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
return networkid.PortalID(GuildPortalKeySigil + guildID)
}
// ParseGuildPortalID converts a [network.PortalID] pointing to a guild space
// back into the guild's ID on Discord.
//
// If the portal ID does not point to a guild, then an empty string is returned.
func ParseGuildPortalID(portalID networkid.PortalID) string {
opaque := string(portalID)
if strings.HasPrefix(opaque, GuildPortalKeySigil) {
guildID := opaque[1:]
return guildID
}
return ""
}

147
pkg/msgconv/attachments.go Normal file
View File

@@ -0,0 +1,147 @@
// 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 msgconv
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type ReuploadedAttachment struct {
MXC id.ContentURIString
File *event.EncryptedFileInfo
Size int
FileName string
MimeType string
}
func (d *MessageConverter) ReuploadUnknownMedia(
ctx context.Context,
url string,
allowEncryption bool,
) (*ReuploadedAttachment, error) {
return d.ReuploadMedia(ctx, url, "", "", -1, allowEncryption)
}
func mib(size int64) float64 {
return float64(size) / 1024 / 1024
}
func (d *MessageConverter) ReuploadMedia(
ctx context.Context,
downloadURL string,
mimeType string,
fileName string,
estimatedSize int,
allowEncryption bool,
) (*ReuploadedAttachment, error) {
if fileName == "" {
parsedURL, err := url.Parse(downloadURL)
if err != nil {
return nil, fmt.Errorf("couldn't parse URL to detect file name: %w", err)
}
fileName = path.Base(parsedURL.Path)
}
sess := ctx.Value(contextKeyDiscordClient).(*discordgo.Session)
httpClient := sess.Client
intent := ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI)
var roomID id.RoomID
if allowEncryption {
roomID = ctx.Value(contextKeyPortal).(*bridgev2.Portal).MXID
}
req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
if sess.IsUser {
for key, value := range discordgo.DroidDownloadHeaders {
req.Header.Set(key, value)
}
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
logEvt := zerolog.Ctx(ctx).Error().
Str("media_url", downloadURL).
Int("status_code", resp.StatusCode)
if json.Valid(errBody) {
logEvt.RawJSON("error_json", errBody)
} else {
logEvt.Bytes("error_body", errBody)
}
logEvt.Msg("Media download failed")
return nil, fmt.Errorf("%w: unexpected status code %d", bridgev2.ErrMediaDownloadFailed, resp.StatusCode)
} else if resp.ContentLength > d.MaxFileSize {
return nil, fmt.Errorf("%w (%.2f MiB > %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(resp.ContentLength), mib(d.MaxFileSize))
}
requireFile := mimeType == ""
var size int64
mxc, file, err := intent.UploadMediaStream(ctx, roomID, int64(estimatedSize), requireFile, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
var mbe *http.MaxBytesError
size, err = io.Copy(file, http.MaxBytesReader(nil, resp.Body, d.MaxFileSize))
if err != nil {
if errors.As(err, &mbe) {
return nil, fmt.Errorf("%w (over %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(d.MaxFileSize))
}
return nil, err
}
if mimeType == "" {
mimeBuf := make([]byte, 512)
n, err := file.(*os.File).ReadAt(mimeBuf, 0)
if err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("couldn't read file for mime detection: %w", err)
}
mimeType = http.DetectContentType(mimeBuf[:n])
}
return &bridgev2.FileStreamResult{
FileName: fileName,
MimeType: mimeType,
}, nil
})
if err != nil {
return nil, err
}
return &ReuploadedAttachment{
Size: int(size),
MXC: mxc,
File: file,
FileName: fileName,
MimeType: mimeType,
}, nil
}

97
pkg/msgconv/embed.go Normal file
View File

@@ -0,0 +1,97 @@
// 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 msgconv
import (
"regexp"
"github.com/bwmarrin/discordgo"
)
type BridgeEmbedType int
const (
EmbedUnknown BridgeEmbedType = iota
EmbedRich
EmbedLinkPreview
EmbedVideo
)
const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
// Discord links start with http:// or https://, contain at least two characters afterwards,
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
//
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
// Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
// so this is a hacky way to detect those.
return embed.Video != nil && embed.Video.ProxyURL == ""
}
// isPlainGifMessage returns whether a Discord message consists entirely of a
// link to a GIF-like animated image. A single embed must also be present on the
// message.
//
// This helps replicate Discord first-party client behavior, where the link is
// hidden when these same conditions are fulfilled.
func isPlainGifMessage(msg *discordgo.Message) bool {
if len(msg.Embeds) != 1 {
return false
}
embed := msg.Embeds[0]
isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == ""
contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
return contentIsOnlyURL && (isGifVideo || isGifImage)
}
// getEmbedType determines how a Discord embed should be bridged to Matrix by
// returning a BridgeEmbedType.
func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
switch embed.Type {
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
return EmbedLinkPreview
case discordgo.EmbedTypeVideo:
if isActuallyLinkPreview(embed) {
return EmbedLinkPreview
}
return EmbedVideo
case discordgo.EmbedTypeGifv:
return EmbedVideo
case discordgo.EmbedTypeImage:
if msg != nil && isPlainGifMessage(msg) {
return EmbedVideo
} else if embed.Image == nil && embed.Thumbnail != nil {
return EmbedLinkPreview
}
return EmbedRich
case discordgo.EmbedTypeRich:
return EmbedRich
default:
return EmbedUnknown
}
}
var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`)
func isReplyEmbed(embed *discordgo.MessageEmbed) bool {
return hackyReplyPattern.MatchString(embed.Description)
}

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2023 Tulir Asokan
// 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
@@ -14,24 +14,21 @@
// 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
package msgconv
import (
"fmt"
"regexp"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/variationselector"
)
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
@@ -58,7 +55,7 @@ func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
parser.NewSetextHeadingParser(), parser.NewATXHeadingParser(), parser.NewThematicBreakParser(),
parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(),
}
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
@@ -74,7 +71,16 @@ var discordRendererWithInlineLinks = goldmark.New(
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
// renderDiscordMarkdownOnlyHTML converts Discord-flavored Markdown text to HTML.
//
// After conversion, if the text is surrounded by a single outermost paragraph
// tag, it is unwrapped.
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
return format.UnwrapSingleParagraph(mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, text, allowInlineLinks))
}
// renderDiscordMarkdownOnlyHTMLNoUnwrap converts Discord-flavored Markdown text to HTML.
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder
@@ -88,73 +94,13 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err))
}
return format.UnwrapSingleParagraph(buf.String())
return buf.String()
}
const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
func appendIfNotContains(arr []string, newItem string) []string {
for _, item := range arr {
if item == newItem {
return arr
}
}
return append(arr, newItem)
}
func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
if len(mxid) == 0 {
return displayname
}
if mxid[0] == '#' {
alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
if err != nil {
return displayname
}
mxid = alias.RoomID.String()
}
if mxid[0] == '!' {
portal := br.GetPortalByMXID(id.RoomID(mxid))
if portal != nil {
if eventID == "" {
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
return fmt.Sprintf("<#%s>", portal.Key.ChannelID)
//if currentPortal.GuildID == portal.GuildID {
//} else if portal.GuildID != "" {
// return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name)
//} else {
// // TODO is mentioning private channels possible at all?
//}
} else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
guildID := portal.GuildID
if guildID == "" {
guildID = "@me"
}
return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID)
}
}
} else if mxid[0] == '@' {
mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
if ok {
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
return fmt.Sprintf("<@%s>", parsedID)
}
mentionedUser := br.GetUserByMXID(id.UserID(mxid))
if mentionedUser != nil && mentionedUser.DiscordID != "" {
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
}
}
return displayname
}
// Discord links start with http:// or https://, contain at least two characters afterwards,
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
//
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
var discordLinkRegex = regexp.MustCompile(`https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`)
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews"
var discordMarkdownEscaper = strings.NewReplacer(
`\`, `\\`,
@@ -164,6 +110,7 @@ var discordMarkdownEscaper = strings.NewReplacer(
"`", "\\`",
`|`, `\|`,
`<`, `\<`,
`#`, `\#`,
)
func escapeDiscordMarkdown(s string) string {
@@ -207,20 +154,20 @@ var matrixHTMLParser = &format.HTMLParser{
}
return fmt.Sprintf("||%s||", text)
},
}
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext()
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else {
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
}
LinkConverter: func(text, href string, ctx format.Context) string {
linkPreviews := ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey].([]string)
allowPreview := linkPreviews == nil || slices.Contains(linkPreviews, href)
if text == href {
if !allowPreview {
return fmt.Sprintf("<%s>", text)
}
return text
} else if !discordLinkRegexFull.MatchString(href) {
return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href))
} else if !allowPreview {
return fmt.Sprintf("[%s](<%s>)", escapeDiscordMarkdown(text), href)
} else {
return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
}
},
}

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2023 Tulir Asokan
// 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
@@ -14,7 +14,7 @@
// 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
package msgconv
import (
"fmt"

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// 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
@@ -14,9 +14,10 @@
// 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
package msgconv
import (
"context"
"fmt"
"math"
"regexp"
@@ -30,13 +31,15 @@ import (
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type astDiscordTag struct {
ast.BaseInline
portal *Portal
portal *bridgev2.Portal
id int64
}
@@ -146,7 +149,7 @@ func (s *discordTagParser) Trigger() []byte {
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
portal := pc.Get(parserContextPortal).(*Portal)
portal := pc.Get(parserContextPortal).(*bridgev2.Portal)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
@@ -192,7 +195,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
case strings.HasPrefix(tagName, ":"):
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
case strings.HasPrefix(tagName, "a:"):
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag}
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
default:
return nil
}
@@ -260,39 +263,50 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
if !entering {
return
}
ctx := context.TODO()
switch node := n.(type) {
case *astDiscordUserMention:
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%[1]s">%[1]s</a>`, user.MXID)
} else if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name)
var mxid id.UserID
var name string
if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, discordid.MakeUserID(strconv.FormatInt(node.id, 10))); ghost != nil {
mxid = ghost.Intent.GetMXID()
name = ghost.Name
}
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), name)
return
case *astDiscordRoleMention:
role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
if role != nil {
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return
}
// FIXME(skip): Implement.
// role := node.portal.Bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
// if role != nil {
_, _ = fmt.Fprintf(w, `<strong>@unknown-role</strong>`)
// _, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return
// }
case *astDiscordChannelMention:
portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
ChannelID: strconv.FormatInt(node.id, 10),
Receiver: "",
})
if portal != nil {
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(
strconv.FormatInt(node.id, 10),
)); portal != nil {
if portal.MXID != "" {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s?via=%s">%s</a>`, portal.MXID, portal.bridge.AS.HomeserverDomain, portal.Name)
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, portal.MXID.URI(portal.Bridge.Matrix.ServerName()).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
return
}
case *astDiscordCustomEmoji:
reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
if !reactionMXC.IsEmpty() {
_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
return
}
// FIXME(skip): Implement.
_, _ = fmt.Fprintf(w, `(emoji)`)
// reactionMXC := node.portal.Bridge.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
// if !reactionMXC.IsEmpty() {
// attrs := "data-mx-emoticon"
// if node.animated {
// attrs += " data-mau-animated-emoji"
// }
// _, _ = fmt.Fprintf(w, `<img %[3]s src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name, attrs)
// return
// }
case *astDiscordTimestamp:
ts := time.Unix(node.timestamp, 0).UTC()
var formatted string
@@ -305,7 +319,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
fullRFC := ts.Format(fullDatetimeFormat)
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted)
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s" data-discord-style="%c"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, node.style, formatted)
}
stringifiable, ok := n.(fmt.Stringer)
if ok {

727
pkg/msgconv/from-discord.go Normal file
View File

@@ -0,0 +1,727 @@
// 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 msgconv
import (
"context"
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/exmaps"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type contextKey int
const (
contextKeyPortal contextKey = iota
contextKeyIntent
contextKeyUserLogin
contextKeyDiscordClient
)
// ToMatrix bridges a Discord message to Matrix.
//
// This method expects ghost information to be up-to-date.
func (mc *MessageConverter) ToMatrix(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
source *bridgev2.UserLogin,
session *discordgo.Session,
msg *discordgo.Message,
) *bridgev2.ConvertedMessage {
ctx = context.WithValue(ctx, contextKeyUserLogin, source)
ctx = context.WithValue(ctx, contextKeyIntent, intent)
ctx = context.WithValue(ctx, contextKeyPortal, portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
predictedLength := len(msg.Attachments) + len(msg.StickerItems)
if msg.Content != "" {
predictedLength++
}
parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength)
if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil {
parts = append(parts, textPart)
}
ctx = zerolog.Ctx(ctx).With().
Str("action", "convert discord message to matrix").
Str("message_id", msg.ID).
Logger().WithContext(ctx)
log := zerolog.Ctx(ctx)
handledIDs := make(exmaps.Set[string])
for _, att := range msg.Attachments {
if !handledIDs.Add(att.ID) {
continue
}
log := log.With().Str("attachment_id", att.ID).Logger()
if part := mc.renderDiscordAttachment(log.WithContext(ctx), att); part != nil {
parts = append(parts, part)
}
}
for _, sticker := range msg.StickerItems {
if !handledIDs.Add(sticker.ID) {
continue
}
log := log.With().Str("sticker_id", sticker.ID).Logger()
if part := mc.renderDiscordSticker(log.WithContext(ctx), sticker); part != nil {
parts = append(parts, part)
}
}
for i, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if getEmbedType(msg, embed) != EmbedVideo {
continue
}
// Discord deduplicates embeds by URL. It makes things easier for us too.
if !handledIDs.Add(embed.URL) {
continue
}
log := log.With().
Str("computed_embed_type", "video").
Str("embed_type", string(embed.Type)).
Int("embed_index", i).
Logger()
part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), embed)
if part != nil {
parts = append(parts, part)
}
}
if len(parts) == 0 && msg.Thread != nil {
parts = append(parts, &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
}})
}
// TODO(skip): Add extra metadata.
// for _, part := range parts {
// puppet.addWebhookMeta(part, msg)
// puppet.addMemberMeta(part, msg)
// }
sender := discordid.MakeUserID(msg.Author.ID)
pmp, err := portal.PerMessageProfileForSender(ctx, sender)
if err != nil {
log.Err(err).Msg("Failed to make per-message profile")
}
// Assign incrementing part IDs.
for i, part := range parts {
part.ID = networkid.PartID(strconv.Itoa(i))
// Beeper clients support backfilling backwards (scrolling up to load
// more messages). Adding per-message profiles to every part helps them
// present the right message authorship information even when a
// membership event isn't present.
part.Content.BeeperPerMessageProfile = &pmp
}
converted := &bridgev2.ConvertedMessage{Parts: parts}
// TODO This is sorta gross; it might be worth bundling these parameters
// into a struct.
mc.tryAddingReplyToConvertedMessage(
ctx,
converted,
portal,
source,
msg,
)
return converted
}
const forwardTemplateHTML = `<blockquote>
<p>↷ Forwarded</p>
%s
<p>%s</p>
</blockquote>`
const msgInteractionTemplateHTML = `<blockquote>
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
</blockquote>`
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
ctx context.Context,
converted *bridgev2.ConvertedMessage,
portal *bridgev2.Portal,
source *bridgev2.UserLogin,
msg *discordgo.Message,
) {
ref := msg.MessageReference
if ref == nil {
return
}
// TODO: Support threads.
log := zerolog.Ctx(ctx).With().
Str("referenced_channel_id", ref.ChannelID).
Str("referenced_guild_id", ref.GuildID).
Str("referenced_message_id", ref.MessageID).Logger()
// The portal containing the message that was replied to.
targetPortal := portal
if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) {
var err error
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(ref.ChannelID))
if err != nil {
log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
return
}
if targetPortal == nil {
return
}
}
messageID := discordid.MakeMessageID(ref.MessageID)
repliedToMatrixMsg, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, messageID)
if err != nil {
log.Err(err).Msg("Failed to query database for first message part; proceeding")
return
}
if repliedToMatrixMsg == nil {
log.Debug().Msg("Couldn't find a first message part for reply target; proceeding")
return
}
converted.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: repliedToMatrixMsg.ID,
PartID: &repliedToMatrixMsg.PartID,
}
converted.ReplyToRoom = targetPortal.PortalKey
converted.ReplyToUser = repliedToMatrixMsg.SenderID
}
func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart {
log := zerolog.Ctx(ctx)
switch msg.Type {
case discordgo.MessageTypeCall:
return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "started a call",
}}
case discordgo.MessageTypeGuildMemberJoin:
return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "joined the server",
}}
}
var htmlParts []string
if msg.Interaction != nil {
ghost, err := mc.Bridge.GetGhostByID(ctx, discordid.MakeUserID(msg.Interaction.User.ID))
// TODO(skip): Try doing ghost.UpdateInfoIfNecessary.
if err == nil {
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name))
} else {
log.Err(err).Msg("Couldn't get ghost by ID while bridging interaction")
}
}
if msg.Content != "" && !isPlainGifMessage(msg) {
// Bridge basic text messages.
htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(portal, msg.Content, true))
} else if msg.MessageReference != nil &&
msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
len(msg.MessageSnapshots) > 0 &&
msg.MessageSnapshots[0].Message != nil {
// Bridge forwarded messages.
htmlParts = append(htmlParts, mc.forwardedMessageHTMLPart(ctx, portal, source, msg))
}
previews := make([]*event.BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
continue
}
with := log.With().
Str("embed_type", string(embed.Type)).
Int("embed_index", i)
switch getEmbedType(msg, embed) {
case EmbedRich:
log := with.Str("computed_embed_type", "rich").Logger()
htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), embed))
case EmbedLinkPreview:
log := with.Str("computed_embed_type", "link preview").Logger()
previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), embed))
case EmbedVideo:
// Video embeds are handled as separate messages via renderDiscordVideoEmbed.
default:
log := with.Logger()
log.Warn().Msg("Unknown embed type in message")
}
}
if len(msg.Components) > 0 {
htmlParts = append(htmlParts, msgComponentTemplateHTML)
}
if len(htmlParts) == 0 {
return nil
}
fullHTML := strings.Join(htmlParts, "\n")
if !msg.MentionEveryone {
fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
}
content := format.HTMLToContent(fullHTML)
extraContent := map[string]any{
"com.beeper.linkpreviews": previews,
}
return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent}
}
func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string {
log := zerolog.Ctx(ctx)
forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true)
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakeChannelPortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil {
// We've bridged the message that was forwarded, so we can link to it directly.
origLink = fmt.Sprintf(
`<a href="%s">#%s • %s</a>`,
forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name,
msgTSText,
)
} else if err != nil {
log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message")
} else if forwardedFromPortal.MXID != "" {
// We don't have the message but we have the portal, so link to that.
origLink = fmt.Sprintf(
`<a href="%s">#%s</a> • %s`,
forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name,
msgTSText,
)
} else if forwardedFromPortal.Name != "" {
// We only have the name of the portal.
origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText)
}
} else if err != nil {
log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message")
}
return fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink)
}
func mediaFailedMessage(err error) *event.MessageEventContent {
return &event.MessageEventContent{
Body: fmt.Sprintf("Failed to bridge media: %v", err),
MsgType: event.MsgNotice,
}
}
func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart {
var proxyURL string
if embed.Video != nil {
proxyURL = embed.Video.ProxyURL
} else if embed.Thumbnail != nil {
proxyURL = embed.Thumbnail.ProxyURL
} else {
zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
MsgType: event.MsgNotice,
},
}
}
reupload, err := mc.ReuploadUnknownMedia(ctx, proxyURL, true)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: mediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: embed.URL,
URL: reupload.MXC,
File: reupload.File,
Info: &event.FileInfo{
MimeType: reupload.MimeType,
Size: reupload.Size,
},
}
if embed.Video != nil {
content.MsgType = event.MsgVideo
content.Info.Width = embed.Video.Width
content.Info.Height = embed.Video.Height
} else {
content.MsgType = event.MsgImage
content.Info.Width = embed.Thumbnail.Width
content.Info.Height = embed.Thumbnail.Height
}
extra := map[string]any{}
if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
extra["info"] = map[string]any{
"fi.mau.discord.gifv": true,
"fi.mau.gif": true,
"fi.mau.loop": true,
"fi.mau.autoplay": true,
"fi.mau.hide_controls": true,
"fi.mau.no_audio": true,
}
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart {
var mime string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
default:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
Str("sticker_id", sticker.ID).
Msg("Unknown sticker format")
}
// TODO(skip): Support direct media.
reupload, err := mc.ReuploadMedia(ctx, sticker.URL(), mime, "", -1, true)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy sticker to Matrix")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: mediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: sticker.Name, // TODO(skip): Find description from somewhere?
Info: &event.FileInfo{
MimeType: reupload.MimeType,
Size: reupload.Size,
},
}
content.URL, content.File = reupload.MXC, reupload.File
cleanupConvertedStickerInfo(content)
return &bridgev2.ConvertedMessagePart{
Type: event.EventSticker,
Content: content,
}
}
const DiscordStickerSize = 160
func cleanupConvertedStickerInfo(content *event.MessageEventContent) {
if content.Info == nil {
return
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
} else if content.Info.Width < content.Info.Height {
content.Info.Width /= content.Info.Height / DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
}
}
const (
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>`
embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
embedHTMLAuthorLink = `<a href="%s">%s</a>`
embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
embedHTMLFieldName = `<th>%s</th>`
embedHTMLFieldValue = `<td>%s</td>`
embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt="">&nbsp;<span>%s</span>%s</sub></p>`
embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
embedHTMLDate = `<time datetime="%s">%s</time>`
embedFooterDateSeparator = ``
)
func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, embed *discordgo.MessageEmbed) string {
log := zerolog.Ctx(ctx)
var htmlParts []string
if embed.Author != nil {
var authorHTML string
authorNameHTML := html.EscapeString(embed.Author.Name)
if embed.Author.URL != "" {
authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
}
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
if embed.Author.ProxyIconURL != "" {
reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Author.ProxyIconURL, false)
if err != nil {
log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
} else {
authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, reupload.MXC, authorNameHTML)
}
}
htmlParts = append(htmlParts, authorHTML)
}
portal := ctx.Value(contextKeyPortal).(*bridgev2.Portal)
if embed.Title != "" {
var titleHTML string
baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false)
if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else {
titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
}
htmlParts = append(htmlParts, titleHTML)
}
if embed.Description != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(portal, embed.Description, true)))
}
for i := 0; i < len(embed.Fields); i++ {
item := embed.Fields[i]
// TODO(skip): Port EmbedFieldsAsTables.
if false {
splitItems := []*discordgo.MessageEmbedField{item}
if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
}
}
headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems {
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true))
}
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline),
mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false),
mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true),
))
}
}
if embed.Image != nil {
reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Image.ProxyURL, false)
if err != nil {
log.Warn().Err(err).Msg("Failed to reupload image in embed")
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, reupload.MXC))
}
}
var embedDateHTML string
if embed.Timestamp != "" {
formattedTime := embed.Timestamp
parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
} else {
formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
}
embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
}
if embed.Footer != nil {
var footerHTML string
var datePart string
if embedDateHTML != "" {
datePart = embedFooterDateSeparator + embedDateHTML
}
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
if embed.Footer.ProxyIconURL != "" {
reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Footer.ProxyIconURL, false)
if err != nil {
log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
} else {
footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, reupload.MXC, html.EscapeString(embed.Footer.Text), datePart)
}
}
htmlParts = append(htmlParts, footerHTML)
} else if embed.Timestamp != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
}
if len(htmlParts) == 0 {
return ""
}
compiledHTML := strings.Join(htmlParts, "")
if embed.Color != 0 {
compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
} else {
compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
}
return compiledHTML
}
func (mc *MessageConverter) renderDiscordLinkEmbedImage(
ctx context.Context, url string, width, height int, preview *event.BeeperLinkPreview,
) {
reupload, err := mc.ReuploadUnknownMedia(ctx, url, true)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring")
return
}
if width != 0 || height != 0 {
preview.ImageWidth = event.IntOrString(width)
preview.ImageHeight = event.IntOrString(height)
}
preview.ImageSize = event.IntOrString(reupload.Size)
preview.ImageType = reupload.MimeType
preview.ImageURL, preview.ImageEncryption = reupload.MXC, reupload.File
}
func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview {
var preview event.BeeperLinkPreview
preview.MatchedURL = embed.URL
preview.Title = embed.Title
preview.Description = embed.Description
if embed.Image != nil {
mc.renderDiscordLinkEmbedImage(ctx, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
mc.renderDiscordLinkEmbedImage(ctx, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
}
return &preview
}
func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart {
// TODO(skip): Support direct media.
reupload, err := mc.ReuploadMedia(ctx, att.URL, att.ContentType, att.Filename, att.Size, true)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: mediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: reupload.FileName,
Info: &event.FileInfo{
Width: att.Width,
Height: att.Height,
MimeType: reupload.MimeType,
Size: reupload.Size,
},
}
var extra = make(map[string]any)
if strings.HasPrefix(att.Filename, "SPOILER_") {
extra["page.codeberg.everypizza.msc4193.spoiler"] = true
}
if att.Description != "" {
content.Body = att.Description
content.FileName = reupload.FileName
}
switch strings.ToLower(strings.Split(content.Info.MimeType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
if att.Waveform != nil {
// Bridge a voice message.
// TODO convert waveform
extra["org.matrix.msc1767.audio"] = map[string]any{
"duration": int(att.DurationSeconds * 1000),
}
extra["org.matrix.msc3245.voice"] = map[string]any{}
}
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
default:
content.MsgType = event.MsgFile
}
content.URL, content.File = reupload.MXC, reupload.File
content.Info.Size = reupload.Size
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = att.Width
content.Info.Height = att.Height
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}

198
pkg/msgconv/from-matrix.go Normal file
View File

@@ -0,0 +1,198 @@
// 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 msgconv
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/util/variationselector"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil {
return nil
}
linkPreviews, ok := raw["com.beeper.linkpreviews"].([]any)
if !ok {
return nil
}
allowedLinkPreviews := make([]string, 0, len(linkPreviews))
for _, preview := range linkPreviews {
previewMap, ok := preview.(map[string]any)
if !ok {
continue
}
matchedURL, _ := previewMap["matched_url"].(string)
if matchedURL != "" {
allowedLinkPreviews = append(allowedLinkPreviews, matchedURL)
}
}
return allowedLinkPreviews
}
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
}
// ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate
// for bridging the message to Discord.
func (mc *MessageConverter) ToDiscord(
ctx context.Context,
session *discordgo.Session,
msg *bridgev2.MatrixMessage,
) (*discordgo.MessageSend, error) {
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
var req discordgo.MessageSend
if msg.InputTransactionID != "" {
req.Nonce = string(msg.InputTransactionID)
} else {
req.Nonce = discordid.GenerateNonce()
}
log := zerolog.Ctx(ctx)
if msg.ReplyTo != nil {
req.Reference = &discordgo.MessageReference{
ChannelID: discordid.ParseChannelPortalID(msg.ReplyTo.Room.ID),
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
}
}
portal := msg.Portal
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParseChannelPortalID(portal.ID)
content := msg.Content
convertMatrix := func() {
req.Content, req.AllowedMentions = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
if content.MsgType == event.MsgEmote {
req.Content = fmt.Sprintf("_%s_", req.Content)
}
}
switch content.MsgType {
case event.MsgText, event.MsgEmote, event.MsgNotice:
convertMatrix()
case event.MsgAudio, event.MsgFile, event.MsgVideo:
mediaData, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
if err != nil {
log.Err(err).Msg("Failed to download Matrix attachment for bridging")
return nil, bridgev2.ErrMediaDownloadFailed
}
filename := content.Body
if content.FileName != "" && content.FileName != content.Body {
filename = content.FileName
convertMatrix()
}
if msg.Event.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true {
filename = "SPOILER_" + filename
}
// TODO: Support attachments for relay/webhook. (A branch was removed here.)
att := &discordgo.MessageAttachment{
ID: "0",
Filename: filename,
}
upload_id := mc.NextDiscordUploadID()
log.Debug().Str("upload_id", upload_id).Msg("Preparing attachment")
prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
Files: []*discordgo.FilePrepare{{
Size: len(mediaData),
Name: att.Filename,
ID: mc.NextDiscordUploadID(),
}},
// TODO: Support threads.
}, discordgo.WithChannelReferer(guildID, channelID))
if err != nil {
log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload")
return nil, bridgev2.ErrMediaReuploadFailed
}
prepared := prep.Attachments[0]
att.UploadedFilename = prepared.UploadFilename
err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData)
if err != nil {
log.Err(err).Msg("Failed to reupload Discord attachment after preparing")
return nil, bridgev2.ErrMediaReuploadFailed
}
req.Attachments = append(req.Attachments, att)
}
// TODO: Handle (silent) replies and allowed mentions.
return &req, nil
}
func (mc *MessageConverter) ConvertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext(ctx)
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil {
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
}
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else {
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
}
}

48
pkg/msgconv/msgconv.go Normal file
View File

@@ -0,0 +1,48 @@
// 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 msgconv
import (
"math/rand"
"strconv"
"sync/atomic"
"maunium.net/go/mautrix/bridgev2"
)
type MessageConverter struct {
Bridge *bridgev2.Bridge
nextDiscordUploadID atomic.Int32
MaxFileSize int64
}
func NewMessageConverter(bridge *bridgev2.Bridge) *MessageConverter {
mc := &MessageConverter{
Bridge: bridge,
MaxFileSize: 50 * 1024 * 1024,
}
mc.nextDiscordUploadID.Store(rand.Int31n(100))
return mc
}
func (mc *MessageConverter) NextDiscordUploadID() string {
val := mc.nextDiscordUploadID.Add(2)
return strconv.Itoa(int(val))
}

View File

@@ -103,6 +103,7 @@ func (h *serverHello) process(client *Client) error {
ticker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
go func() {
defer ticker.Stop()
//lint:ignore S1000 -
for {
select {
// case <-client.ctx.Done():
@@ -126,7 +127,7 @@ func (h *serverHello) process(client *Client) error {
<-time.After(duration)
client.Lock()
client.err = fmt.Errorf("Timed out after %s", duration)
client.err = fmt.Errorf("timed out after %s", duration)
client.close()
client.Unlock()
}()

2034
portal.go

File diff suppressed because it is too large Load Diff

View File

@@ -1,541 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2023 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 (
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
)
type ConvertedMessage struct {
AttachmentID string
Type event.Type
Content *event.MessageEventContent
Extra map[string]any
}
func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
return &event.MessageEventContent{
Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
MsgType: event.MsgNotice,
}
}
const DiscordStickerSize = 160
func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
if typeName == "sticker" && content.Info.MimeType == "application/json" {
meta.Converter = portal.bridge.convertLottie
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
if err != nil {
portal.log.Errorfln("Error copying attachment %s to Matrix: %v", id, err)
return portal.createMediaFailedMessage(err)
}
if typeName == "sticker" && content.Info.MimeType == "application/json" {
content.Info.MimeType = dbFile.MimeType
}
content.Info.Size = dbFile.Size
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = dbFile.Width
content.Info.Height = dbFile.Height
}
if content.Info.Width == 0 && content.Info.Height == 0 && typeName == "sticker" {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
if dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
if typeName == "sticker" && (content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize) {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
} else if content.Info.Width < content.Info.Height {
content.Info.Width /= content.Info.Height / DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
}
return content
}
func (portal *Portal) convertDiscordSticker(intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
var mime string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
default:
portal.log.Warnfln("Unknown sticker format %d in %s", sticker.FormatType, sticker.ID)
}
return &ConvertedMessage{
AttachmentID: sticker.ID,
Type: event.EventSticker,
Content: portal.convertDiscordFile("sticker", intent, sticker.ID, sticker.URL(), &event.MessageEventContent{
Body: sticker.Name, // TODO find description from somewhere?
Info: &event.FileInfo{
MimeType: mime,
},
}),
}
}
func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
content := &event.MessageEventContent{
Body: att.Filename,
Info: &event.FileInfo{
Height: att.Height,
MimeType: att.ContentType,
Width: att.Width,
// This gets overwritten later after the file is uploaded to the homeserver
Size: att.Size,
},
}
if att.Description != "" {
content.Body = att.Description
content.FileName = att.Filename
}
var extra map[string]any
switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
if att.Waveform != nil {
// TODO convert waveform
extra = map[string]any{
"org.matrix.1767.audio": map[string]any{
"duration": int(att.DurationSeconds * 1000),
},
"org.matrix.msc3245.voice": map[string]any{},
}
}
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
default:
content.MsgType = event.MsgFile
}
content = portal.convertDiscordFile("attachment", intent, att.ID, att.URL, content)
return &ConvertedMessage{
AttachmentID: att.ID,
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
attachmentID := fmt.Sprintf("video_%s", embed.URL)
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta)
if err != nil {
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: portal.createMediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
MsgType: event.MsgVideo,
Body: embed.URL,
Info: &event.FileInfo{
Width: embed.Video.Width,
Height: embed.Video.Height,
MimeType: dbFile.MimeType,
Size: dbFile.Size,
},
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = dbFile.Width
content.Info.Height = dbFile.Height
}
if dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
extra := map[string]any{}
if embed.Type == discordgo.EmbedTypeGifv {
extra["info"] = map[string]any{
"fi.mau.discord.gifv": true,
"fi.mau.loop": true,
"fi.mau.autoplay": true,
"fi.mau.hide_controls": true,
"fi.mau.no_audio": true,
}
}
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
predictedLength := len(msg.Attachments) + len(msg.StickerItems)
if msg.Content != "" {
predictedLength++
}
parts := make([]*ConvertedMessage, 0, predictedLength)
if textPart := portal.convertDiscordTextMessage(intent, msg); textPart != nil {
parts = append(parts, textPart)
}
handledIDs := make(map[string]struct{})
for _, att := range msg.Attachments {
if _, handled := handledIDs[att.ID]; handled {
continue
}
handledIDs[att.ID] = struct{}{}
if part := portal.convertDiscordAttachment(intent, att); part != nil {
parts = append(parts, part)
}
}
for _, sticker := range msg.StickerItems {
if _, handled := handledIDs[sticker.ID]; handled {
continue
}
handledIDs[sticker.ID] = struct{}{}
if part := portal.convertDiscordSticker(intent, sticker); part != nil {
parts = append(parts, part)
}
}
for _, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if getEmbedType(embed) != EmbedVideo {
continue
}
// Discord deduplicates embeds by URL. It makes things easier for us too.
if _, handled := handledIDs[embed.URL]; handled {
continue
}
handledIDs[embed.URL] = struct{}{}
part := portal.convertDiscordVideoEmbed(intent, embed)
if part != nil {
parts = append(parts, part)
}
}
return parts
}
const (
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>`
embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
embedHTMLAuthorLink = `<a href="%s">%s</a>`
embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
embedHTMLFieldName = `<th>%s</th>`
embedHTMLFieldValue = `<td>%s</td>`
embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt="">&nbsp;<span>%s</span>%s</sub></p>`
embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
embedHTMLDate = `<time datetime="%s">%s</time>`
embedFooterDateSeparator = ``
)
func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
var htmlParts []string
if embed.Author != nil {
var authorHTML string
authorNameHTML := html.EscapeString(embed.Author.Name)
if embed.Author.URL != "" {
authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
}
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
if embed.Author.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err)
} else {
authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
}
}
htmlParts = append(htmlParts, authorHTML)
}
if embed.Title != "" {
var titleHTML string
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else {
titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
}
htmlParts = append(htmlParts, titleHTML)
}
if embed.Description != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
}
for i := 0; i < len(embed.Fields); i++ {
item := embed.Fields[i]
if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
splitItems := []*discordgo.MessageEmbedField{item}
if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
}
}
headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems {
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
}
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline),
portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
))
}
}
if embed.Image != nil {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err)
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
}
}
var embedDateHTML string
if embed.Timestamp != "" {
formattedTime := embed.Timestamp
parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
if err != nil {
portal.log.Warnfln("Failed to parse timestamp in embed #%d of message %s: %v", index+1, msgID, err)
} else {
formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
}
embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
}
if embed.Footer != nil {
var footerHTML string
var datePart string
if embedDateHTML != "" {
datePart = embedFooterDateSeparator + embedDateHTML
}
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
if embed.Footer.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err)
} else {
footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
}
}
htmlParts = append(htmlParts, footerHTML)
} else if embed.Timestamp != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
}
if len(htmlParts) == 0 {
return ""
}
compiledHTML := strings.Join(htmlParts, "")
if embed.Color != 0 {
compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
} else {
compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
}
return compiledHTML
}
type BeeperLinkPreview struct {
mautrix.RespPreviewURL
MatchedURL string `json:"matched_url"`
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
}
func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to copy image in URL preview: %v", err)
} else {
if width != 0 || height != 0 {
preview.ImageWidth = width
preview.ImageHeight = height
} else {
preview.ImageWidth = dbFile.Width
preview.ImageHeight = dbFile.Height
}
preview.ImageSize = dbFile.Size
preview.ImageType = dbFile.MimeType
if dbFile.Encrypted {
preview.ImageEncryption = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
preview.ImageURL = dbFile.MXC.CUString()
}
}
}
func (portal *Portal) convertDiscordLinkEmbedToBeeper(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
var preview BeeperLinkPreview
preview.MatchedURL = embed.URL
preview.Title = embed.Title
preview.Description = embed.Description
if embed.Image != nil {
portal.convertDiscordLinkEmbedImage(intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
portal.convertDiscordLinkEmbedImage(intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
}
return &preview
}
const msgInteractionTemplateHTML = `<blockquote>
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
</blockquote>`
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
type BridgeEmbedType int
const (
EmbedUnknown BridgeEmbedType = iota
EmbedRich
EmbedLinkPreview
EmbedVideo
)
func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
// Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
// so this is a hacky way to detect those.
return embed.Video != nil && embed.Video.ProxyURL == ""
}
func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
switch embed.Type {
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
return EmbedLinkPreview
case discordgo.EmbedTypeVideo:
if isActuallyLinkPreview(embed) {
return EmbedLinkPreview
}
return EmbedVideo
case discordgo.EmbedTypeGifv:
return EmbedVideo
case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage:
return EmbedRich
default:
return EmbedUnknown
}
}
func isPlainGifMessage(msg *discordgo.Message) bool {
return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv
}
func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
if msg.Type == discordgo.MessageTypeCall {
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "started a call",
}}
} else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "joined the server",
}}
}
var htmlParts []string
if msg.Interaction != nil {
puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
puppet.UpdateInfo(nil, msg.Interaction.User)
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
}
if msg.Content != "" && !isPlainGifMessage(msg) {
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false))
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
switch getEmbedType(embed) {
case EmbedRich:
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i))
case EmbedLinkPreview:
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed))
case EmbedVideo:
// Ignore video embeds, they're handled as separate messages
default:
portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID)
}
}
if len(msg.Components) > 0 {
htmlParts = append(htmlParts, msgComponentTemplateHTML)
}
if len(htmlParts) == 0 {
return nil
}
fullHTML := strings.Join(htmlParts, "\n")
if !msg.MentionEveryone {
fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
}
content := format.HTMLToContent(fullHTML)
extraContent := map[string]any{
"com.beeper.linkpreviews": previews,
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

View File

@@ -1,544 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth"
)
const (
SecWebSocketProtocol = "com.gitlab.beeper.discord"
)
const (
ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
ErrCodeGuildBridgeFailed = "M_UNKNOWN"
ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
)
type ProvisioningAPI struct {
bridge *DiscordBridge
log log.Logger
}
func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
p := &ProvisioningAPI{
bridge: br,
log: br.Log.Sub("Provisioning"),
}
prefix := br.Config.Bridge.Provisioning.Prefix
p.log.Debugln("Enabling provisioning API at", prefix)
r := br.AS.Router.PathPrefix(prefix).Subrouter()
r.Use(p.authMiddleware)
r.HandleFunc("/v1/disconnect", p.disconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/ping", p.ping).Methods(http.MethodGet)
r.HandleFunc("/v1/login/qr", p.qrLogin).Methods(http.MethodGet)
r.HandleFunc("/v1/login/token", p.tokenLogin).Methods(http.MethodPost)
r.HandleFunc("/v1/logout", p.logout).Methods(http.MethodPost)
r.HandleFunc("/v1/reconnect", p.reconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/guilds", p.guildsList).Methods(http.MethodGet)
r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost)
r.HandleFunc("/v1/guilds/{guildID}", p.guildsUnbridge).Methods(http.MethodDelete)
return p
}
func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
// Response structs
type Response struct {
Success bool `json:"success"`
Status string `json:"status"`
}
type Error struct {
Success bool `json:"success"`
Error string `json:"error"`
ErrCode string `json:"errcode"`
}
// Wrapped http.ResponseWriter to capture the status code
type responseWrap struct {
http.ResponseWriter
statusCode int
}
var _ http.Hijacker = (*responseWrap)(nil)
func (rw *responseWrap) WriteHeader(statusCode int) {
rw.ResponseWriter.WriteHeader(statusCode)
rw.statusCode = statusCode
}
func (rw *responseWrap) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := rw.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("response does not implement http.Hijacker")
}
return hijacker.Hijack()
}
// Middleware
func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
// Special case the login endpoint to use the discord qrcode auth
if auth == "" && strings.HasSuffix(r.URL.Path, "/login") {
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
for _, part := range authParts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, SecWebSocketProtocol+"-") {
auth = part[len(SecWebSocketProtocol+"-"):]
break
}
}
} else if strings.HasPrefix(auth, "Bearer ") {
auth = auth[len("Bearer "):]
}
if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret {
jsonResponse(w, http.StatusUnauthorized, map[string]interface{}{
"error": "Invalid auth token",
"errcode": mautrix.MUnknownToken.ErrCode,
})
return
}
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
start := time.Now()
wWrap := &responseWrap{w, 200}
h.ServeHTTP(wWrap, r.WithContext(context.WithValue(r.Context(), "user", user)))
duration := time.Now().Sub(start).Seconds()
p.log.Infofln("%s %s from %s took %.2f seconds and returned status %d", r.Method, r.URL.Path, user.MXID, duration, wWrap.statusCode)
})
}
// websocket upgrader
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{SecWebSocketProtocol},
}
// Handlers
func (p *ProvisioningAPI) disconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if !user.Connected() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're not connected to discord",
ErrCode: ErrCodeNotConnected,
})
return
}
if err := user.Disconnect(); err != nil {
p.log.Errorfln("Failed to disconnect %s: %v", user.MXID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to disconnect from discord",
ErrCode: ErrCodeDisconnectFailed,
})
} else {
jsonResponse(w, http.StatusOK, Response{
Success: true,
Status: "Disconnected from Discord",
})
}
}
type respPing struct {
Discord struct {
ID string `json:"id,omitempty"`
LoggedIn bool `json:"logged_in"`
Connected bool `json:"connected"`
Conn struct {
LastHeartbeatAck int64 `json:"last_heartbeat_ack,omitempty"`
LastHeartbeatSent int64 `json:"last_heartbeat_sent,omitempty"`
} `json:"conn"`
}
MXID id.UserID `json:"mxid"`
ManagementRoom id.RoomID `json:"management_room"`
}
func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
resp := respPing{
MXID: user.MXID,
ManagementRoom: user.ManagementRoom,
}
resp.Discord.LoggedIn = user.IsLoggedIn()
resp.Discord.Connected = user.Connected()
resp.Discord.ID = user.DiscordID
if user.Session != nil {
resp.Discord.Conn.LastHeartbeatAck = user.Session.LastHeartbeatAck.UnixMilli()
resp.Discord.Conn.LastHeartbeatSent = user.Session.LastHeartbeatSent.UnixMilli()
}
jsonResponse(w, http.StatusOK, resp)
}
func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
var msg string
if user.DiscordID != "" {
msg = "Logged out successfully."
} else {
msg = "User wasn't logged in."
}
user.Logout(false)
jsonResponse(w, http.StatusOK, Response{true, msg})
}
func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
p.log.Errorln("Failed to upgrade connection to websocket:", err)
return
}
log := p.log.Sub("QRLogin").Sub(user.MXID.String())
defer func() {
err := c.Close()
if err != nil {
log.Debugln("Error closing websocket:", err)
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err := c.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
c.SetCloseHandler(func(code int, text string) error {
log.Debugfln("Login websocket closed (%d), cancelling login", code)
cancel()
return nil
})
if user.IsLoggedIn() {
_ = c.WriteJSON(Error{
Error: "You're already logged into Discord",
ErrCode: ErrCodeAlreadyLoggedIn,
})
return
}
client, err := remoteauth.New()
if err != nil {
log.Errorln("Failed to prepare login:", err)
_ = c.WriteJSON(Error{
Error: "Failed to prepare login",
ErrCode: ErrCodeLoginPrepareFailed,
})
return
}
qrChan := make(chan string)
doneChan := make(chan struct{})
log.Debugln("Started login via provisioning API")
err = client.Dial(ctx, qrChan, doneChan)
if err != nil {
log.Errorln("Failed to connect to Discord login websocket:", err)
close(qrChan)
close(doneChan)
_ = c.WriteJSON(Error{
Error: "Failed to connect to Discord login websocket",
ErrCode: ErrCodeLoginConnectionFailed,
})
return
}
for {
select {
case qrCode, ok := <-qrChan:
if !ok {
continue
}
err = c.WriteJSON(map[string]interface{}{
"code": qrCode,
"timeout": 120, // TODO: move this to the library or something
})
if err != nil {
log.Errorln("Failed to write QR code to websocket:", err)
}
case <-doneChan:
var discordUser remoteauth.User
discordUser, err = client.Result()
if err != nil {
log.Errorln("Discord login websocket returned error:", err)
_ = c.WriteJSON(Error{
Error: "Failed to log in",
ErrCode: ErrCodeLoginFailed,
})
return
}
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
if err = user.Login(discordUser.Token); err != nil {
log.Errorln("Failed to connect after logging in:", err)
_ = c.WriteJSON(Error{
Error: "Failed to connect to Discord after logging in",
ErrCode: ErrCodePostLoginConnFailed,
})
return
}
err = c.WriteJSON(respLogin{
Success: true,
ID: user.DiscordID,
Username: discordUser.Username,
Discriminator: discordUser.Discriminator,
})
if err != nil {
log.Errorln("Failed to write login success to websocket:", err)
}
return
case <-ctx.Done():
return
}
}
}
type respLogin struct {
Success bool `json:"success"`
ID string `json:"id"`
Username string `json:"username"`
Discriminator string `json:"discriminator"`
}
type reqTokenLogin struct {
Token string `json:"token"`
}
func (p *ProvisioningAPI) tokenLogin(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
log := p.log.Sub("TokenLogin").Sub(user.MXID.String())
if user.IsLoggedIn() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're already logged into Discord",
ErrCode: ErrCodeAlreadyLoggedIn,
})
return
}
var body reqTokenLogin
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Errorln("Failed to parse login request:", err)
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Failed to parse request body",
ErrCode: mautrix.MBadJSON.ErrCode,
})
return
}
if err := user.Login(body.Token); err != nil {
log.Errorln("Failed to connect with provided token:", err)
jsonResponse(w, http.StatusUnauthorized, Error{
Error: "Failed to connect to Discord",
ErrCode: ErrCodePostLoginConnFailed,
})
return
}
log.Infoln("Successfully logged in")
jsonResponse(w, http.StatusOK, respLogin{
Success: true,
ID: user.DiscordID,
Username: user.Session.State.User.Username,
Discriminator: user.Session.State.User.Discriminator,
})
}
func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Connected() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're already connected to discord",
ErrCode: ErrCodeAlreadyConnected,
})
return
}
if err := user.Connect(); err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to connect to discord",
ErrCode: ErrCodeConnectFailed,
})
} else {
jsonResponse(w, http.StatusOK, Response{
Success: true,
Status: "Connected to Discord",
})
}
}
type guildEntry struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarURL id.ContentURI `json:"avatar_url"`
MXID id.RoomID `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
}
type respGuildsList struct {
Guilds []guildEntry `json:"guilds"`
}
func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
var resp respGuildsList
resp.Guilds = []guildEntry{}
for _, userGuild := range user.GetPortals() {
guild := p.bridge.GetGuildByID(userGuild.DiscordID, false)
if guild == nil {
continue
}
resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID,
Name: guild.PlainName,
AvatarURL: guild.AvatarURL,
MXID: guild.MXID,
AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
BridgingMode: guild.BridgingMode.String(),
})
}
jsonResponse(w, http.StatusOK, resp)
}
type reqBridgeGuild struct {
AutoCreateChannels bool `json:"auto_create_channels"`
}
type respBridgeGuild struct {
Success bool `json:"success"`
MXID id.RoomID `json:"mxid"`
}
func (p *ProvisioningAPI) guildsBridge(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
guildID := mux.Vars(r)["guildID"]
var body reqBridgeGuild
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
p.log.Errorln("Failed to parse bridge request:", err)
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Failed to parse request body",
ErrCode: mautrix.MBadJSON.ErrCode,
})
return
}
guild := user.bridge.GetGuildByID(guildID, false)
if guild == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
return
}
alreadyExists := guild.MXID == ""
if err := user.bridgeGuild(guildID, body.AutoCreateChannels); err != nil {
p.log.Errorfln("Error bridging %s: %v", guildID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal error while trying to bridge guild",
ErrCode: ErrCodeGuildBridgeFailed,
})
} else if alreadyExists {
jsonResponse(w, http.StatusOK, respBridgeGuild{
Success: true,
MXID: guild.MXID,
})
} else {
jsonResponse(w, http.StatusCreated, respBridgeGuild{
Success: true,
MXID: guild.MXID,
})
}
}
func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request) {
guildID := mux.Vars(r)["guildID"]
user := r.Context().Value("user").(*User)
if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
jsonResponse(w, http.StatusForbidden, Error{
Error: "Only bridge admins can unbridge guilds",
ErrCode: mautrix.MForbidden.ErrCode,
})
} else if guild := user.bridge.GetGuildByID(guildID, false); guild == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
} else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
jsonResponse(w, http.StatusNotFound, Error{
Error: "That guild is not bridged",
ErrCode: ErrCodeGuildNotBridged,
})
} else if err := user.unbridgeGuild(guildID); err != nil {
p.log.Errorfln("Error unbridging %s: %v", guildID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal error while trying to unbridge guild",
ErrCode: ErrCodeGuildUnbridgeFailed,
})
} else {
w.WriteHeader(http.StatusNoContent)
}
}

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