200 Commits

Author SHA1 Message Date
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
Tulir Asokan
42c48bfd90 Bump version to v0.3.0 2023-04-16 23:19:09 +03:00
Tulir Asokan
533054b8a0 Add option to disable backfilling in big guilds 2023-04-16 23:13:15 +03:00
Tulir Asokan
ed020c4233 Add basic support for incoming voice messages 2023-04-16 16:31:38 +03:00
Tulir Asokan
587ac68f60 Fix backfill things 2023-04-16 16:31:29 +03:00
Tulir Asokan
a0fb4a45d2 Update dependencies 2023-04-16 15:31:23 +03:00
Tulir Asokan
58befb3f96 Add initial backfilling on portal creation 2023-04-16 15:19:24 +03:00
Tulir Asokan
4194b4dfd9 Improve missed message backfilling 2023-04-16 15:06:02 +03:00
Tulir Asokan
d465bd2d67 Merge remote-tracking branch 'origin/max/be-8890' 2023-04-16 13:23:32 +03:00
Max Sandholm
693fe49a9a Check last message ID before attempting backfill 2023-04-14 23:09:59 +03:00
Max Sandholm
ef1142c614 Get 50 instead of 100 messages at a time 2023-04-14 18:59:17 +03:00
Max Sandholm
ee5ea87e83 Forward fill missing messages on startup 2023-04-14 18:48:35 +03:00
Tulir Asokan
35d0c209f2 Add option to not set room meta in encrypted rooms 2023-04-14 13:39:22 +03:00
Sumner Evans
dad71dd6c5 bridge bot: set service and network name
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 00:16:58 -06:00
Tulir Asokan
24b768903a Update mautrix-go 2023-04-13 17:24:55 +03:00
Tulir Asokan
16b086f62f Add options to automatically delete/ratchet megolm sessions 2023-04-13 17:08:52 +03:00
Tulir Asokan
a7095b1bd4 Stop falling back if hungryserv yeet fails 2023-03-22 16:58:53 +02:00
Tulir Asokan
69268f8d92 Bump version to v0.2.0 2023-03-16 13:04:30 +02:00
Tulir Asokan
05bc4f9312 Update dependencies 2023-03-16 12:46:59 +02:00
Tulir Asokan
f5ef87eb83 Update changelog and readme 2023-03-16 12:43:56 +02:00
Tulir Asokan
3cdf018c37 Update mautrix-go 2023-03-15 16:21:57 +02:00
Tulir Asokan
46115fafd5 Switch user and puppet files to zerolog 2023-03-12 14:25:24 +02:00
Tulir Asokan
15d4cf07f9 Fix mistake in reaction replace error handling 2023-03-11 00:07:53 +02:00
Tulir Asokan
ff052d7f18 Update portalsByMXID when manually bridging 2023-03-10 20:18:00 +02:00
Tulir Asokan
ef7e77515a Fix bugs in manual un/bridging 2023-03-10 18:41:29 +02:00
Tulir Asokan
0deec8b853 Retry on unknown errors when logging in 2023-03-10 17:39:34 +02:00
Tulir Asokan
d42c4722c9 Fill usersByID properly 2023-03-08 19:49:43 +02:00
Tulir Asokan
ee2ad7527e Add some logs when disconnecting 2023-03-08 19:21:58 +02:00
Tulir Asokan
5a40f0a2ab Fix error log 2023-03-07 21:00:29 +02:00
Tulir Asokan
c163fba712 Update mautrix-go 2023-03-05 23:51:02 +02:00
Tulir Asokan
9c87532d52 Update state cache after manaul bridging 2023-03-05 19:29:28 +02:00
Tulir Asokan
9b63defbe8 Fix order of cleanup and removing mxid 2023-03-04 14:32:55 +02:00
Tulir Asokan
4e9e50dbed Don't allow overriding set-relay without unsetting first 2023-03-04 14:28:01 +02:00
Tulir Asokan
3c52e76e15 Add bridge/unbridge/delete-portal commands
Fixes #34
2023-03-04 14:27:59 +02:00
Tulir Asokan
0e8b845014 Update changelog
[skip ci]
2023-03-01 22:50:19 +02:00
Tulir Asokan
f8bbcc9080 Update discordgo to fix some bugs
(and possibly add new bugs)
2023-03-01 22:16:42 +02:00
Tulir Asokan
febb28882e Add ping command 2023-03-01 22:05:57 +02:00
Tulir Asokan
0403a705b6 Add help sections for all commands 2023-03-01 20:43:40 +02:00
Tulir Asokan
2440ca4e83 Add unset-relay command 2023-03-01 20:36:07 +02:00
Tulir Asokan
39096c9347 Require room admin for set-relay 2023-03-01 20:35:50 +02:00
Tulir Asokan
72d4fb755b Add error status when user isn't logged in 2023-03-01 19:54:08 +02:00
Tulir Asokan
7bfa885530 Validate webhook URLs when using set-relay --url 2023-03-01 18:49:06 +02:00
Tulir Asokan
f7c8e03041 Handle redactions from webhook users 2023-03-01 18:48:40 +02:00
Tulir Asokan
d3828f2fb3 Update changelog
[skip ci]
2023-03-01 18:22:44 +02:00
Tulir Asokan
bccdc67eb2 Adjust guild info logs 2023-02-28 21:43:55 +02:00
Tulir Asokan
c625ee3ba7 Update gitignore 2023-02-28 00:44:59 +02:00
Tulir Asokan
17d4b79554 Add initial support for relay mode with webhooks 2023-02-28 00:40:53 +02:00
Tulir Asokan
6365db46cc Remove unnecessary user parameter in parseMatrixHTML 2023-02-27 22:47:45 +02:00
Tulir Asokan
af52979669 Fix attachment IDs in message converter 2023-02-27 18:51:13 +02:00
Tulir Asokan
ccd29752c7 Fetch missing channel info on message to support DMs for bots 2023-02-27 11:42:53 +02:00
Tulir Asokan
4eba894573 Fix state store not being updated on double puppet requests 2023-02-27 01:29:20 +02:00
Tulir Asokan
71d1689776 Adjust some calls for bot accounts 2023-02-27 01:19:26 +02:00
Tulir Asokan
ce4d05bb11 Don't save discord token before login is successful 2023-02-27 01:19:26 +02:00
Tulir Asokan
681a5ff2ab Create Matrix user mentions even without double puppeting. Fixes #21 2023-02-27 01:03:01 +02:00
Tulir Asokan
60c260a471 Add initial support for bot accounts. Fixes #12 2023-02-27 01:02:58 +02:00
Tulir Asokan
efd22e33b5 Delete guild portals too in delete-all-portals 2023-02-27 00:10:06 +02:00
Tulir Asokan
7b5c057dcf Refactor message handling to fully use convert pattern 2023-02-26 23:47:01 +02:00
Tulir Asokan
a0cc5ec9bc Fully qualify emojis instead of removing VS16. Fixes #58 2023-02-26 21:57:21 +02:00
Tulir Asokan
77b230f4d8 Update mautrix-go and switch to zerolog 2023-02-26 21:57:18 +02:00
Tulir Asokan
cace8b5939 Handle gif stickers 2023-02-26 20:46:12 +02:00
Nick Mills-Barrett
ac7ad471a5 Ensure room is muted before sending events to it 2023-02-24 18:31:18 +00:00
Tulir Asokan
a6c3b84db5 Fix update ghost info on reaction 2023-02-23 15:09:21 +02:00
Tulir Asokan
4676ec98c4 Add more options for guild message handling 2023-02-18 22:56:20 +02:00
Tulir Asokan
541c8e1169 Bump Go version in go.mod. Fixes #57
[skip ci]
2023-02-16 16:54:59 +02:00
Tulir Asokan
69f1793e24 Bump version to v0.1.1 2023-02-16 12:50:42 +02:00
Tulir Asokan
eab19f6679 Update mautrix-go 2023-02-16 12:48:33 +02:00
Tulir Asokan
839933005c Remove lottie conversion temp dir after converting 2023-02-15 22:19:31 +02:00
Tulir Asokan
a28735beb7 Update discordgo 2023-02-15 22:19:28 +02:00
Tulir Asokan
5d7a6e7088 Update changelog and dependencies 2023-02-13 15:40:13 +02:00
Tulir Asokan
f9ba906bbd Update ghost info on incoming reactions 2023-02-13 11:53:00 +02:00
Tulir Asokan
41d51ec992 Handle guild join messages 2023-02-13 00:25:23 +02:00
Tulir Asokan
6ccf87bc0a Handle call start messages
Closes #53
2023-02-13 00:14:05 +02:00
Tulir Asokan
011c60610a Adjust github action 2023-02-13 00:01:12 +02:00
Tulir Asokan
669964272e Fix typo
[skip cd]
2023-02-12 12:25:42 +02:00
Tulir Asokan
943f2dd6f0 Update linters
[skip cd]
2023-02-12 12:24:52 +02:00
Tulir Asokan
3e5baa502e Update discordgo to fix handling guilds in ready event 2023-02-04 16:33:40 +02:00
Tulir Asokan
c336804c7e Fix sticker sizes 2023-02-04 16:17:17 +02:00
Tulir Asokan
2421cd7817 Specify lottieconverter docker tag 2023-02-04 16:13:02 +02:00
Tulir Asokan
a7864c28d8 Add support for converting lottie stickers 2023-02-04 16:10:03 +02:00
Tulir Asokan
0dba4fbdd4 Fix typo in initial db migration 2023-02-04 15:58:22 +02:00
Tulir Asokan
fac7d79c5e Subscribe to guild when bridging it 2023-02-04 14:49:10 +02:00
Tulir Asokan
f32fd8d904 Update changelog and dependencies 2023-02-04 14:27:23 +02:00
Tulir Asokan
1e81fc6a02 Improve typing notification handling 2023-02-04 14:17:59 +02:00
Tulir Asokan
80f8bed9b9 Subscribe to bridged guilds on connect 2023-02-04 14:17:56 +02:00
Tulir Asokan
7cdd1bb9e4 Double check bridging status before handling message
Some webhook messages don't seem to have the guild ID specified
2023-02-04 13:45:50 +02:00
Tulir Asokan
a2121347e8 Don't set extra data in edit fallbacks 2023-02-02 22:23:51 +02:00
Tulir Asokan
85395c0230 Bridge youtube embeds as link previews 2023-02-02 22:23:34 +02:00
Tulir Asokan
787ce75dde Fix transferring same attachment multiple times in parallel 2023-01-31 13:11:02 +02:00
Tulir Asokan
5b715cd9e2 Allow inline links in Discord embed descriptions 2023-01-30 18:35:17 +02:00
Tulir Asokan
a9e03f092c Fix removing custom emoji reactions from Matrix 2023-01-30 01:48:43 +02:00
Tulir Asokan
466139164c Merge emoji and discord_file tables
Also fix duplicate reaction when reacting with custom emoji from Matrix
2023-01-30 01:35:22 +02:00
Tulir Asokan
e183f5cffa Disable caching reuploaded encrypted files 2023-01-30 01:01:10 +02:00
Tulir Asokan
e7615ef4be Refactor tag rendering to avoid recreating goldmark instance for each message 2023-01-30 00:44:06 +02:00
Tulir Asokan
694733a4e9 Don't specify width in inline images
They don't necessarily need to be square, so only specify height
and let clients make the width fit automatically.
2023-01-30 00:15:57 +02:00
Tulir Asokan
6f4c51852c Disable more unsupported features in discord markdown parser 2023-01-30 00:15:54 +02:00
60 changed files with 4683 additions and 1819 deletions

View File

@@ -5,17 +5,13 @@ on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: [1.19]
steps:
- uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go-version }}
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
go-version: "1.20"
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3

9
.gitignore vendored
View File

@@ -1,5 +1,6 @@
config.yaml
discord
logs/
registration.yaml
*.yaml
!example-config.yaml
!.pre-commit-config.yaml
*.db*
*.log*

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
rev: v4.4.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@@ -9,7 +9,7 @@ repos:
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-beta.5
rev: v1.0.0-rc.1
hooks:
- id: go-imports-repo
- id: go-vet-repo-mod

View File

@@ -1,3 +1,120 @@
# 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.
* Added options to automatically ratchet/delete megolm sessions to minimize
access to old messages.
* Added basic support for incoming voice messages.
# v0.2.0 (2023-03-16)
* Switched to zerolog for logging.
* The basic log config will be migrated automatically, but you may want to
tweak it as the options are different.
* Added support for logging in with a bot account.
The [Authentication docs](https://docs.mau.fi/bridges/go/discord/authentication.html)
have been updated with instructions for creating a bot.
* Added support for relaying messages for unauthenticated users using a webhook.
See [docs](https://docs.mau.fi/bridges/go/discord/relay.html) for instructions.
* Added commands to bridge and unbridge channels manually.
* Added `ping` command.
* Added support for gif stickers from Discord.
* Changed mention bridging so mentions for users logged into the bridge use the
Matrix user's MXID even if double puppeting is not enabled.
* Actually fixed ghost user info not being synced when receiving reactions.
* Fixed uncommon bug with sending messages that only occurred after login
before restarting the bridge.
* Fixed guild name not being synced immediately after joining a new guild.
* Fixed variation selectors when bridging emojis to Discord.
# v0.1.1 (2023-02-16)
* Started automatically subscribing to bridged guilds. This fixes two problems:
* Typing notifications should now work automatically in guilds.
* Huge guilds now actually get messages bridged.
* Added support for converting animated lottie stickers to raster formats using
[lottieconverter](https://github.com/sot-tech/LottieConverter).
* Added basic bridging for call start and guild join messages.
* Improved markdown parsing to disable more features that don't exist on Discord.
* Removed width from inline images (e.g. in the `guilds status` output) to
handle non-square images properly.
* Fixed ghost user info not being synced when receiving reactions.
# v0.1.0 (2023-01-29)
Initial release.

View File

@@ -1,4 +1,6 @@
FROM golang:1-alpine3.17 AS builder
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.18 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -6,13 +8,16 @@ COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
FROM alpine:3.17
FROM alpine:3.18
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm 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 --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

View File

@@ -1,10 +1,15 @@
FROM alpine:3.17
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM alpine:3.18
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \
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
ARG EXECUTABLE=./mautrix-discord
COPY $EXECUTABLE /usr/bin/mautrix-discord
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml

View File

@@ -1,7 +1,12 @@
FROM golang:1-alpine3.17 AS builder
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl
FROM golang:1-alpine3.18 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

View File

@@ -8,7 +8,8 @@ All setup and usage instructions are located on [docs.mau.fi]. Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord)
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html)
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html),
[Relaying with webhooks](https://docs.mau.fi/bridges/go/discord/relay.html)
### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md)

View File

@@ -2,25 +2,34 @@ package main
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(url string) ([]byte, error) {
func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -36,9 +45,24 @@ func downloadDiscordAttachment(url string) ([]byte, error) {
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 nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
}
if resp.Header.Get("Content-Length") != "" {
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse content length: %w", err)
} else if length > maxSize {
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
}
return io.ReadAll(resp.Body)
} else {
var mbe *http.MaxBytesError
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
if err != nil && errors.As(err, &mbe) {
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
}
return data, err
}
}
func uploadDiscordAttachment(url string, data []byte) error {
@@ -62,7 +86,7 @@ func uploadDiscordAttachment(url string, data []byte) error {
return nil
}
func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventContent) ([]byte, error) {
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
@@ -76,7 +100,7 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
return nil, err
}
data, err := portal.MainIntent().DownloadBytes(mxc)
data, err := intent.DownloadBytes(mxc)
if err != nil {
return nil, err
}
@@ -91,23 +115,24 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
return data, nil
}
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, attachmentID, mime string) (*database.File, error) {
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now()
dbFile.URL = url
dbFile.ID = attachmentID
dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String()
if mime == "" {
mime = dbFile.MimeType
if meta.MimeType == "" {
meta.MimeType = dbFile.MimeType
}
if strings.HasPrefix(mime, "image/") {
if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width
dbFile.Height = cfg.Height
}
uploadMime := mime
uploadMime := meta.MimeType
if encrypt {
dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
@@ -119,17 +144,19 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
ContentType: uploadMime,
}
if br.Config.Homeserver.AsyncMedia {
resp, err := intent.UnstableCreateMXC()
resp, err := intent.CreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.UnstableMXC = resp.ContentURI
req.UploadURL = resp.UploadURL
req.MXC = resp.ContentURI
req.UnstableUploadURL = resp.UnstableUploadURL
semaWg.Add(1)
go func() {
defer semaWg.Done()
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.UnstableMXC, err)
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
dbFile.Delete()
}
}()
@@ -140,22 +167,184 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
}
dbFile.MXC = uploaded.ContentURI
}
dbFile.Insert(nil)
return dbFile, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, attachmentID, mime string) (*database.File, error) {
dbFile := br.DB.File.Get(url, encrypt)
if dbFile == nil {
data, err := downloadDiscordAttachment(url)
if err != nil {
return nil, err
type AttachmentMeta struct {
AttachmentID string
MimeType string
EmojiName string
CopyIfMissing bool
Converter func([]byte) ([]byte, string, error)
}
dbFile, err = br.uploadMatrixAttachment(intent, data, url, encrypt, attachmentID, mime)
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, err
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() {
removErr := os.RemoveAll(tempdir)
if removErr != nil {
br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr)
}
}()
lottieOutput := filepath.Join(tempdir, "out_")
if lottieTarget != "pngs" {
lottieOutput = filepath.Join(tempdir, "output."+lottieTarget)
}
cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps))
cmd.Stdin = bytes.NewReader(data)
err = cmd.Run()
if err != nil {
return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err)
}
var path string
if lottieTarget == "pngs" {
var videoCodec string
outputExtension := "." + target
if target == "webm" {
videoCodec = "libvpx-vp9"
} else if target == "webp" {
videoCodec = "libwebp_anim"
} else {
panic(fmt.Errorf("impossible case: unknown target %q", target))
}
path, err = ffmpeg.ConvertPath(
ctx, lottieOutput+"*.png", outputExtension,
[]string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"},
[]string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target},
false,
)
if err != nil {
return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err)
}
} else {
path = lottieOutput
}
data, err = os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("failed to read converted file: %w", err)
}
return data, outputMime, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{})
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
if isCacheable {
onceDBFile = br.DB.File.Get(url, encrypt)
if onceDBFile != nil {
return
}
}
return dbFile, nil
const attachmentSizeVal = 1
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
cancel()
if onceErr != nil {
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
onceErr = fmt.Errorf("reuploading timed out")
return
}
var semaWg sync.WaitGroup
semaWg.Add(1)
defer semaWg.Done()
go func() {
semaWg.Wait()
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
}()
var data []byte
data, onceErr = downloadDiscordAttachment(url, br.MediaConfig.UploadSize)
if onceErr != nil {
return
}
if meta.Converter != nil {
data, meta.MimeType, onceErr = meta.Converter(data)
if onceErr != nil {
onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr)
return
}
}
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg)
if onceErr != nil {
return
}
if isCacheable {
onceDBFile.Insert(nil)
}
br.attachmentTransfers.Delete(transferKey)
return
})
}
return
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
var url, mimeType, ext string
if animated {
url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif"
ext = "gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
ext = "png"
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Emoji(emojiID, ext)
if !mxc.IsEmpty() {
return mxc
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,
MimeType: mimeType,
EmojiName: name,
})
if err != nil {
portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
return id.ContentURI{}
}
return dbFile.MXC
}

View File

@@ -1,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
}

380
backfill.go Normal file
View File

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

View File

@@ -19,6 +19,7 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"html"
@@ -30,10 +31,13 @@ import (
"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"
)
@@ -44,14 +48,22 @@ type WrappedCommandEvent struct {
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,
@@ -78,12 +90,45 @@ var cmdLoginToken = &commands.FullHandler{
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) == 0 {
ce.Reply("**Usage**: `$cmdprefix login-token <token>`")
if len(ce.Args) != 2 {
ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
return
}
ce.MarkRead()
@@ -92,11 +137,29 @@ func fnLoginToken(ce *WrappedCommandEvent) {
ce.Reply("You're already logged in")
return
}
if err := ce.User.Login(ce.Args[0]); err != nil {
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)
ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username)
}
var cmdLoginQR = &commands.FullHandler{
@@ -165,7 +228,7 @@ func fnLoginQR(ce *WrappedCommandEvent) {
ce.User.DiscordID = user.UserID
ce.User.Update()
ce.User.Unlock()
ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
ce.Reply("Successfully logged in as @%s", user.Username)
}
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
@@ -218,7 +281,7 @@ var cmdLogout = &commands.FullHandler{
func fnLogout(ce *WrappedCommandEvent) {
wasLoggedIn := ce.User.DiscordID != ""
ce.User.Logout()
ce.User.Logout(false)
if wasLoggedIn {
ce.Reply("Logged out successfully.")
} else {
@@ -226,6 +289,29 @@ func fnLogout(ce *WrappedCommandEvent) {
}
}
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`)", ce.User.Session.State.User.Username, ce.User.DiscordID)
}
}
var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect),
Name: "disconnect",
@@ -271,7 +357,7 @@ var cmdRejoinSpace = &commands.FullHandler{
Func: wrapCommand(fnRejoinSpace),
Name: "rejoin-space",
Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified,
Section: HelpSectionPortalManagement,
Description: "Ask the bridge for an invite to a space you left",
Args: "<_guild ID_/main/dms>",
},
@@ -285,10 +371,10 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
}
user := ce.User
if ce.Args[0] == "main" {
user.ensureInvited(nil, user.GetSpaceRoom(), false)
user.ensureInvited(nil, user.GetSpaceRoom(), false, true)
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)
user.ensureInvited(nil, user.GetDMSpaceRoom(), false, true)
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")
@@ -298,25 +384,182 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
}
}
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: commands.HelpSectionUnclassified,
Section: HelpSectionPortalManagement,
Description: "Guild bridging management",
Args: "<status/bridge/unbridge> [_guild ID_] [--entire]",
Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
},
RequiresLogin: true,
}
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [--entire]`"
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) {
@@ -333,6 +576,8 @@ func fnGuilds(ce *WrappedCommandEvent) {
fnBridgeGuild(ce)
case "unbridge", "delete":
fnUnbridgeGuild(ce)
case "bridging-mode", "mode":
fnGuildBridgingMode(ce)
case "help":
ce.Reply(fullGuildsHelp)
default:
@@ -347,15 +592,11 @@ func fnListGuilds(ce *WrappedCommandEvent) {
if guild == nil {
continue
}
status := "not bridged"
if guild.MXID != "" {
status = "bridged"
}
var avatarHTML string
if !guild.AvatarURL.IsEmpty() {
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" width="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String())
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, status))
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")
@@ -384,11 +625,190 @@ func fnUnbridgeGuild(ce *WrappedCommandEvent) {
}
}
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.HelpSectionUnclassified,
Section: commands.HelpSectionAdmin,
Description: "Delete all portals.",
},
RequiresAdmin: true,
@@ -396,14 +816,15 @@ var cmdDeleteAllPortals = &commands.FullHandler{
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortals()
if len(portals) == 0 {
guilds := ce.Bridge.GetAllGuilds()
if len(portals) == 0 && len(guilds) == 0 {
ce.Reply("Didn't find any portals")
return
}
leave := func(portal *Portal) {
if len(portal.MXID) > 0 {
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
Reason: "Deleting portal",
UserID: ce.User.MXID,
})
@@ -412,19 +833,23 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) {
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent()
leave = func(portal *Portal) {
if len(portal.MXID) > 0 {
_, _ = intent.LeaveRoom(portal.MXID)
_, _ = intent.ForgetRoom(portal.MXID)
leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.LeaveRoom(mxid)
_, _ = intent.ForgetRoom(mxid)
}
}
}
ce.Reply("Found %d portals, deleting...", len(portals))
ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
for _, portal := range portals {
portal.Delete()
leave(portal)
leave(portal.MXID, portal.MainIntent())
}
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.")
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 {

View File

@@ -28,12 +28,14 @@ import (
"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: commands.HelpSectionUnclassified,
Section: HelpSectionDiscordBots,
Description: "View parameters of bot interaction commands on Discord",
Args: "search <_query_> OR help <_command_>",
},
@@ -46,7 +48,7 @@ var cmdExec = &commands.FullHandler{
Name: "exec",
Aliases: []string{"command", "cmd", "c", "exec", "e"},
Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified,
Section: HelpSectionDiscordBots,
Description: "Run bot interaction commands on Discord",
Args: "<_command_> [_arg=value ..._]",
},

View File

@@ -25,6 +25,7 @@ import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type BridgeConfig struct {
@@ -32,7 +33,7 @@ type BridgeConfig struct {
DisplaynameTemplate string `yaml:"displayname_template"`
ChannelNameTemplate string `yaml:"channel_name_template"`
GuildNameTemplate string `yaml:"guild_name_template"`
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
@@ -50,14 +51,35 @@ type BridgeConfig struct {
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
FederateRooms bool `yaml:"federate_rooms"`
PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
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"`
CacheMedia string `yaml:"cache_media"`
MediaPatterns MediaPatterns `yaml:"media_patterns"`
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"`
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
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 {
@@ -73,6 +95,119 @@ type BridgeConfig struct {
guildNameTemplate *template.Template `yaml:"-"`
}
type MediaPatterns struct {
Enabled bool `yaml:"enabled"`
TplAttachments string `yaml:"attachments"`
TplEmojis string `yaml:"emojis"`
TplStickers string `yaml:"stickers"`
TplAvatars string `yaml:"avatars"`
attachments *template.Template `yaml:"-"`
emojis *template.Template `yaml:"-"`
stickers *template.Template `yaml:"-"`
avatars *template.Template `yaml:"-"`
}
type umMediaPatterns MediaPatterns
func (mp *MediaPatterns) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umMediaPatterns)(mp))
if err != nil {
return err
}
tpl := template.New("media_patterns")
pairs := []struct {
ptr **template.Template
name string
template string
}{
{&mp.attachments, "attachments", mp.TplAttachments},
{&mp.emojis, "emojis", mp.TplEmojis},
{&mp.stickers, "stickers", mp.TplStickers},
{&mp.avatars, "avatars", mp.TplAvatars},
}
for _, pair := range pairs {
if pair.template == "" {
continue
}
*pair.ptr, err = tpl.New(pair.name).Parse(pair.template)
if err != nil {
return err
}
}
return nil
}
type attachmentParams struct {
ChannelID string
AttachmentID string
FileName string
}
type emojiStickerParams struct {
ID string
Ext string
}
type avatarParams struct {
UserID string
AvatarID string
Ext string
}
func (mp *MediaPatterns) execute(tpl *template.Template, params any) id.ContentURI {
if tpl == nil || !mp.Enabled {
return id.ContentURI{}
}
var out strings.Builder
err := tpl.Execute(&out, params)
if err != nil {
panic(err)
}
uri, err := id.ParseContentURI(out.String())
if err != nil {
panic(err)
}
return uri
}
func (mp *MediaPatterns) Attachment(channelID, attachmentID, filename string) id.ContentURI {
return mp.execute(mp.attachments, attachmentParams{
ChannelID: channelID,
AttachmentID: attachmentID,
FileName: filename,
})
}
func (mp *MediaPatterns) Emoji(emojiID, ext string) id.ContentURI {
return mp.execute(mp.emojis, emojiStickerParams{
ID: emojiID,
Ext: ext,
})
}
func (mp *MediaPatterns) Sticker(stickerID, ext string) id.ContentURI {
return mp.execute(mp.stickers, emojiStickerParams{
ID: stickerID,
Ext: ext,
})
}
func (mp *MediaPatterns) Avatar(userID, avatarID, ext string) id.ContentURI {
return mp.execute(mp.avatars, avatarParams{
UserID: userID,
AvatarID: avatarID,
Ext: ext,
})
}
type BackfillLimitPart struct {
DM int `yaml:"dm"`
Channel int `yaml:"channel"`
Thread int `yaml:"thread"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
@@ -135,6 +270,10 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
return bc.DoublePuppetConfig
}
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
@@ -153,9 +292,19 @@ func (bc BridgeConfig) FormatUsername(userID string) string {
return buffer.String()
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) string {
type DisplaynameParams struct {
*discordgo.User
Webhook bool
Application bool
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, user)
_ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
User: user,
Webhook: webhook,
Application: application,
})
return buffer.String()
}

View File

@@ -29,7 +29,7 @@ type Config struct {
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
return hasSecret
}

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// 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
@@ -17,19 +17,29 @@
package config
import (
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"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")
helper.Copy(up.Bool, "bridge", "private_chat_portal_meta")
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")
@@ -45,6 +55,19 @@ func DoUpgrade(helper *up.Helper) {
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.Bool, "bridge", "prefix_webhook_messages")
helper.Copy(up.Bool, "bridge", "enable_webhook_avatars")
helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload")
helper.Copy(up.Bool, "bridge", "media_patterns", "enabled")
helper.Copy(up.Str, "bridge", "cache_media")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "attachments")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "emojis")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "stickers")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "avatars")
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")
@@ -53,21 +76,39 @@ func DoUpgrade(helper *up.Helper) {
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", "initial", "thread")
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", "forward_limits", "missed", "thread")
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", "plaintext_mentions")
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.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
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.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
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)
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")

View File

@@ -1,175 +1,72 @@
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 = br.AS.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)
}
}
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
if err != nil {
return nil, err
}
client.Logger = br.AS.Log.Sub(mxid.String())
client.Client = br.AS.HTTPClient
client.DefaultHTTPRetries = br.AS.DefaultHTTPRetries
return client, nil
}
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
}
puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
if err != nil {
puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
return false
}
puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
puppet.AccessToken = accessToken
puppet.Update()
return true
}
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse()
puppet.log.Debugfln("Logging into %s with shared secret", mxid)
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
puppet.Update()
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
}
func (puppet *Puppet) ClearCustomMXID() {
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
puppet.bridge.puppetsLock.Lock()
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
}
puppet.bridge.puppetsLock.Unlock()
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
if save {
puppet.Update()
}
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
if err != nil {
puppet.ClearCustomMXID()
return err
}
puppet.bridge.puppetsLock.Lock()
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
puppet.bridge.puppetsLock.Unlock()
if puppet.AccessToken != newAccessToken {
puppet.AccessToken = newAccessToken
puppet.Update()
}
puppet.customIntent = newIntent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
return nil
}
func (user *User) tryAutomaticDoublePuppeting() {
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
return
}
user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetByID(user.DiscordID)
if len(puppet.CustomMXID) > 0 {
user.log.Debug().Msg("User already has double-puppeting enabled")
// Custom puppet already enabled
return
}
puppet.CustomMXID = user.MXID
err := puppet.StartCustomMXID(true)
if err != nil {
user.log.Warn().Err(err).Msg("Failed to login with shared secret")
} else {
// TODO leave rooms with default puppet
user.log.Debug().Msg("Successfully automatically enabled custom puppet")
}
}

View File

@@ -5,10 +5,9 @@ import (
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
"go.mau.fi/mautrix-discord/database/upgrades"
)
@@ -21,7 +20,6 @@ type Database struct {
Message *MessageQuery
Thread *ThreadQuery
Reaction *ReactionQuery
Emoji *EmojiQuery
Guild *GuildQuery
Role *RoleQuery
File *FileQuery
@@ -54,10 +52,6 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
db: db,
log: log.Sub("Reaction"),
}
db.Emoji = &EmojiQuery{
db: db,
log: log.Sub("Emoji"),
}
db.Guild = &GuildQuery{
db: db,
log: log.Sub("Guild"),
@@ -73,9 +67,10 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
return db
}
func strPtr(val string) *string {
func strPtr[T ~string](val T) *string {
if val == "" {
return nil
}
return &val
valStr := string(val)
return &valStr
}

View File

@@ -1,99 +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 EmojiQuery struct {
db *Database
log log.Logger
}
const (
emojiSelect = "SELECT discord_id, discord_name, matrix_url FROM emoji"
)
func (eq *EmojiQuery) New() *Emoji {
return &Emoji{
db: eq.db,
log: eq.log,
}
}
func (eq *EmojiQuery) GetByDiscordID(discordID string) *Emoji {
query := emojiSelect + " WHERE discord_id=$1"
return eq.get(query, discordID)
}
func (eq *EmojiQuery) GetByMatrixURL(matrixURL id.ContentURI) *Emoji {
query := emojiSelect + " WHERE matrix_url=$1"
return eq.get(query, matrixURL.String())
}
func (eq *EmojiQuery) get(query string, args ...interface{}) *Emoji {
return eq.New().Scan(eq.db.QueryRow(query, args...))
}
type Emoji struct {
db *Database
log log.Logger
DiscordID string
DiscordName string
MatrixURL id.ContentURI
}
func (e *Emoji) Scan(row dbutil.Scannable) *Emoji {
var matrixURL sql.NullString
err := row.Scan(&e.DiscordID, &e.DiscordName, &matrixURL)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
e.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
e.MatrixURL, _ = id.ParseContentURI(matrixURL.String)
return e
}
func (e *Emoji) Insert() {
query := "INSERT INTO emoji" +
" (discord_id, discord_name, matrix_url)" +
" VALUES ($1, $2, $3);"
_, err := e.db.Exec(query, e.DiscordID, e.DiscordName, e.MatrixURL.String())
if err != nil {
e.log.Warnfln("Failed to insert emoji %s: %v", e.DiscordID, err)
panic(err)
}
}
func (e *Emoji) Delete() {
query := "DELETE FROM emoji WHERE discord_id=$1"
_, err := e.db.Exec(query, e.DiscordID)
if err != nil {
e.log.Warnfln("Failed to delete emoji %s: %v", e.DiscordID, err)
panic(err)
}
}
func (e *Emoji) APIName() string {
if e.DiscordID != "" && e.DiscordName != "" {
return e.DiscordName + ":" + e.DiscordID
} else if e.DiscordName != "" {
return e.DiscordName
}
return e.DiscordID
}

View File

@@ -6,11 +6,10 @@ import (
"errors"
"time"
"go.mau.fi/util/dbutil"
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 {
@@ -20,10 +19,10 @@ type FileQuery struct {
// language=postgresql
const (
fileSelect = "SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
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, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
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)
`
)
@@ -39,15 +38,21 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
}
func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 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
MXC id.ContentURI
EmojiName string
Size int
Width int
@@ -55,16 +60,15 @@ type File struct {
MimeType string
DecryptionInfo *attachment.EncryptedFile
Timestamp time.Time
}
func (f *File) Scan(row dbutil.Scannable) *File {
var fileID, decryptionInfo sql.NullString
var fileID, emojiName, decryptionInfo sql.NullString
var width, height sql.NullInt32
var timestamp int64
var mxc string
err := row.Scan(&f.URL, &f.Encrypted, &fileID, &mxc, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, &timestamp)
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)
@@ -73,7 +77,8 @@ func (f *File) Scan(row dbutil.Scannable) *File {
return nil
}
f.ID = fileID.String
f.Timestamp = time.UnixMilli(timestamp)
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp).UTC()
f.Width = int(width.Int32)
f.Height = int(height.Int32)
f.MXC, err = id.ParseContentURI(mxc)
@@ -114,7 +119,7 @@ func (f *File) Insert(txn dbutil.Execable) {
decryptionInfoStr.String = string(decryptionInfo)
}
_, err := txn.Exec(fileInsert,
f.URL, f.Encrypted, strPtr(f.ID), f.MXC.String(), f.Size,
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(),
)

View File

@@ -3,20 +3,84 @@ package database
import (
"database/sql"
"errors"
"fmt"
"strings"
"go.mau.fi/util/dbutil"
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, auto_bridge_channels FROM guild"
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
)
func (gq *GuildQuery) New() *Guild {
@@ -67,13 +131,13 @@ type Guild struct {
AvatarURL id.ContentURI
AvatarSet bool
AutoBridgeChannels 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.AutoBridgeChannels)
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)
@@ -82,6 +146,9 @@ func (g *Guild) Scan(row dbutil.Scannable) *Guild {
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
@@ -96,10 +163,10 @@ func (g *Guild) mxidPtr() *id.RoomID {
func (g *Guild) Insert() {
query := `
INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, auto_bridge_channels)
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.AutoBridgeChannels)
_, 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)
@@ -108,10 +175,10 @@ func (g *Guild) Insert() {
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, auto_bridge_channels=$8
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.AutoBridgeChannels, g.ID)
_, 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)

View File

@@ -7,10 +7,9 @@ import (
"strings"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type MessageQuery struct {
@@ -19,7 +18,7 @@ type MessageQuery struct {
}
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"
messageSelect = "SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid FROM message"
)
func (mq *MessageQuery) New() *Message {
@@ -46,17 +45,17 @@ func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
}
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"
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 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"
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 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"
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
}
@@ -66,10 +65,15 @@ func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time
}
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"
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 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 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)
@@ -90,19 +94,51 @@ func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
return mq.New().Scan(row)
}
func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)"
if mq.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 2+len(msgs)*8)
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.SenderID
params[baseIndex+3] = msg.Timestamp.UnixMilli()
params[baseIndex+4] = msg.editTimestampVal()
params[baseIndex+5] = msg.ThreadID
params[baseIndex+6] = msg.MXID
params[baseIndex+7] = msg.SenderMXID.String()
placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8)
}
_, 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
EditTimestamp time.Time
ThreadID string
MXID id.EventID
SenderMXID id.UserID
}
func (m *Message) DiscordProtoChannelID() string {
@@ -114,9 +150,9 @@ func (m *Message) DiscordProtoChannelID() string {
}
func (m *Message) Scan(row dbutil.Scannable) *Message {
var ts int64
var ts, editTS int64
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.EditIndex, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &m.ThreadID, &m.MXID)
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &editTS, &m.ThreadID, &m.MXID, &m.SenderMXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
m.log.Errorln("Database scan failed:", err)
@@ -127,7 +163,10 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
}
if ts != 0 {
m.Timestamp = time.UnixMilli(ts)
m.Timestamp = time.UnixMilli(ts).UTC()
}
if editTS != 0 {
m.EditTimestamp = time.Unix(0, editTS).UTC()
}
return m
@@ -135,39 +174,47 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
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
dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9)", "%s", 1)
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1)
type MessagePart struct {
AttachmentID string
MXID id.EventID
}
func (m *Message) MassInsert(msgs []MessagePart) {
func (m *Message) editTimestampVal() int64 {
if m.EditTimestamp.IsZero() {
return 0
}
return m.EditTimestamp.UnixNano()
}
func (m *Message) MassInsertParts(msgs []MessagePart) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d)"
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)"
if m.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 7+len(msgs)*2)
params := make([]interface{}, 8+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[1] = m.Channel.ChannelID
params[2] = m.Channel.Receiver
params[3] = m.SenderID
params[4] = m.Timestamp.UnixMilli()
params[5] = m.editTimestampVal()
params[6] = m.ThreadID
params[7] = m.SenderMXID.String()
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)
params[8+i*2] = msg.AttachmentID
params[8+i*2+1] = msg.MXID
placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
}
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil {
@@ -178,8 +225,8 @@ func (m *Message) MassInsert(msgs []MessagePart) {
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)
m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
if err != nil {
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
@@ -187,6 +234,20 @@ func (m *Message) Insert() {
}
}
const editUpdateQuery = `
UPDATE message
SET dc_edit_timestamp=$1
WHERE dcid=$2 AND dc_attachment_id=$3 AND dc_chan_id=$4 AND dc_chan_receiver=$5 AND dc_edit_timestamp<$1
`
func (m *Message) UpdateEditTimestamp(ts time.Time) {
_, err := m.db.Exec(editUpdateQuery, ts.UnixNano(), m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver)
if err != nil {
m.log.Warnfln("Failed to update edit timestamp of %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)

View File

@@ -4,19 +4,17 @@ import (
"database/sql"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
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
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal
`
)
@@ -68,6 +66,10 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
}
func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal {
return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
}
@@ -112,6 +114,7 @@ type Portal struct {
PlainName string
Name string
NameSet bool
FriendNick bool
Topic string
TopicSet bool
Avatar string
@@ -121,16 +124,19 @@ type Portal struct {
InSpace id.RoomID
FirstEventID id.EventID
RelayWebhookID string
RelayWebhookSecret string
}
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var otherUserID, guildID, parentID, mxid, firstEventID sql.NullString
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)
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil {
if err != sql.ErrNoRows {
@@ -148,6 +154,8 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
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
}
@@ -155,14 +163,14 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
plain_name, name, name_set, friend_nick, 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, $21)
`
_, 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())
p.PlainName, p.Name, p.NameSet, p.FriendNick, 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)
@@ -174,14 +182,16 @@ 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
WHERE dcid=$17 AND receiver=$18
plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
relay_webhook_id=$18, relay_webhook_secret=$19
WHERE dcid=$20 AND receiver=$21
`
_, 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(),
p.PlainName, p.Name, p.NameSet, p.FriendNick, 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 {

View File

@@ -3,15 +3,14 @@ package database
import (
"database/sql"
"go.mau.fi/util/dbutil"
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" +
" contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" +
" FROM puppet "
)
@@ -73,6 +72,15 @@ type Puppet struct {
AvatarURL id.ContentURI
AvatarSet bool
ContactInfoSet bool
GlobalName string
Username string
Discriminator string
IsBot bool
IsWebhook bool
IsApplication bool
CustomMXID id.UserID
AccessToken string
NextBatch string
@@ -82,8 +90,8 @@ 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)
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet,
&p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch)
if err != nil {
if err != sql.ErrNoRows {
@@ -104,11 +112,16 @@ func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
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)
INSERT INTO puppet (
id, name, name_set, avatar, avatar_url, avatar_set, contact_info_set,
global_name, username, discriminator, is_bot, is_webhook, is_application,
custom_mxid, access_token, next_batch
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`
_, 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))
_, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch))
if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
@@ -118,13 +131,18 @@ func (p *Puppet) Insert() {
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
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6,
global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12,
custom_mxid=$13, access_token=$14, next_batch=$15
WHERE id=$16
`
_, 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)
_, err := p.db.Exec(
query,
p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch),
p.ID,
)
if err != nil {
p.log.Warnfln("Failed to update %s: %v", p.ID, err)

View File

@@ -4,10 +4,9 @@ import (
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type ReactionQuery struct {

View File

@@ -4,11 +4,9 @@ import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
)
type RoleQuery struct {

View File

@@ -4,10 +4,9 @@ import (
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type ThreadQuery struct {

View File

@@ -1,4 +1,4 @@
-- v0 -> v12: Latest revision
-- v0 -> v23 (compatible with v19+): Latest revision
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
@@ -10,7 +10,7 @@ CREATE TABLE guild (
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
auto_bridge_channels BOOLEAN NOT NULL
bridging_mode INTEGER NOT NULL
);
CREATE TABLE portal (
@@ -29,6 +29,7 @@ CREATE TABLE portal (
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
friend_nick BOOLEAN NOT NULL,
topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
@@ -39,6 +40,9 @@ CREATE TABLE portal (
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
@@ -60,10 +64,19 @@ CREATE TABLE puppet (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
global_name TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
discriminator TEXT NOT NULL DEFAULT '',
is_bot BOOLEAN NOT NULL DEFAULT false,
is_webhook BOOLEAN NOT NULL DEFAULT false,
is_application BOOLEAN NOT NULL DEFAULT false,
custom_mxid TEXT,
access_token TEXT,
@@ -96,16 +109,17 @@ CREATE TABLE user_portal (
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_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
sender_mxid TEXT NOT NULL DEFAULT '',
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
PRIMARY KEY (dcid, dc_attachment_id, 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
);
@@ -118,18 +132,11 @@ CREATE TABLE reaction (
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 emoji (
discord_id TEXT PRIMARY KEY,
discord_name TEXT,
matrix_url TEXT
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
CREATE TABLE role (
@@ -154,18 +161,19 @@ CREATE TABLE role (
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL,
id TEXT,
mxc TEXT NOT NULL,
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)
);
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View File

@@ -0,0 +1,4 @@
-- 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

@@ -0,0 +1,24 @@
-- 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

@@ -0,0 +1,7 @@
-- 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

@@ -0,0 +1,3 @@
-- 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

@@ -0,0 +1,3 @@
-- v16: Store whether custom contact info has been set for the puppet
ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- v17: Store whether DM portal name is a friend nickname
ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,4 @@
-- v18 (compatible with v15+): Store additional metadata for ghosts
ALTER TABLE puppet ADD COLUMN username TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN discriminator TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,15 @@
-- v19: Replace dc_edit_index with dc_edit_timestamp
-- transaction: off
BEGIN;
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE message DROP CONSTRAINT message_pkey;
ALTER TABLE message DROP COLUMN dc_edit_index;
ALTER TABLE reaction DROP COLUMN _dc_first_edit_index;
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver);
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE;
ALTER TABLE message ADD COLUMN dc_edit_timestamp BIGINT NOT NULL DEFAULT 0;
ALTER TABLE message ALTER COLUMN dc_edit_timestamp DROP DEFAULT;
COMMIT;

View File

@@ -0,0 +1,48 @@
-- v19: Replace dc_edit_index with dc_edit_timestamp
-- transaction: off
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
dcid TEXT,
dc_attachment_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, 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 message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid)
SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message;
DROP TABLE message;
ALTER TABLE message_new RENAME TO message;
CREATE TABLE reaction_new (
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,
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_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
INSERT INTO reaction_new (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, COALESCE(dc_thread_id, ''), dc_first_attachment_id, mxid FROM reaction;
DROP TABLE reaction;
ALTER TABLE reaction_new RENAME TO reaction;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View File

@@ -0,0 +1,2 @@
-- v20 (compatible with v19+): Store message sender Matrix user ID
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,3 @@
-- v21 (compatible with v19+): Store global displayname and is webhook status for puppets
ALTER TABLE puppet ADD COLUMN global_name TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN is_webhook BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,26 @@
-- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache
CREATE TABLE new_discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL,
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, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
DROP TABLE discord_file;
ALTER TABLE new_discord_file RENAME TO discord_file;
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View File

@@ -0,0 +1,2 @@
-- v23 (compatible with v19+): Store is application status for puppets
ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false;

View File

@@ -19,7 +19,7 @@ package upgrades
import (
"embed"
"maunium.net/go/mautrix/util/dbutil"
"go.mau.fi/util/dbutil"
)
var Table dbutil.UpgradeTable

View File

@@ -3,10 +3,9 @@ package database
import (
"database/sql"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type UserQuery struct {

View File

@@ -5,8 +5,8 @@ import (
"errors"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
)
const (
@@ -29,7 +29,7 @@ func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
l.Errorln("Error scanning user portal:", err)
panic(err)
}
up.Timestamp = time.UnixMilli(ts)
up.Timestamp = time.UnixMilli(ts).UTC()
return &up
}

View File

@@ -18,30 +18,35 @@ func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
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) {
user.log.Debugfln("Fetching own membership in %s to check own roles", channel.GuildID)
log.Debug().Msg("Fetching own membership in guild to check roles")
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
if err != nil {
user.log.Warnfln("Failed to get own membership in %s from server to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err)
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else {
err = user.Session.State.MemberAdd(member)
if err != nil {
user.log.Warnfln("Failed to add own membership in %s to cache: %v", channel.GuildID, err)
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
}
}
} else if err != nil {
user.log.Warnfln("Failed to get own membership in %s from cache to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err)
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
}
err = user.Session.State.ChannelAdd(channel)
if err != nil {
user.log.Warnfln("Failed to add channel %s/%s to cache: %v", channel.GuildID, channel.ID, err)
log.Warn().Err(err).Msg("Failed to add channel to cache")
}
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
if err != nil {
user.log.Warnfln("Failed to get permissions in %s/%s to determine if it's bridgeable: %v", channel.GuildID, channel.ID, err)
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true
}
user.log.Debugfln("Computed permissions in %s/%s: %d (view channel: %t)", channel.GuildID, channel.ID, perms, perms&discordgo.PermissionViewChannel > 0)
log.Debug().
Int64("permissions", perms).
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
Msg("Computed permissions in channel")
return perms&discordgo.PermissionViewChannel > 0
}

View File

@@ -1,79 +0,0 @@
package main
import (
"io/ioutil"
"net/http"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
dbEmoji := portal.bridge.DB.Emoji.GetByDiscordID(emojiID)
if dbEmoji == nil {
data, mimeType, err := portal.downloadDiscordEmoji(emojiID, animated)
if err != nil {
portal.log.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err)
return id.ContentURI{}
}
uri, err := portal.uploadMatrixEmoji(portal.MainIntent(), data, mimeType)
if err != nil {
portal.log.Warnfln("Failed to upload discord emoji %s to homeserver: %v", emojiID, err)
return id.ContentURI{}
}
dbEmoji = portal.bridge.DB.Emoji.New()
dbEmoji.DiscordID = emojiID
dbEmoji.DiscordName = name
dbEmoji.MatrixURL = uri
dbEmoji.Insert()
}
return dbEmoji.MatrixURL
}
func (portal *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
var url string
var mimeType string
if animated {
// This url requests a gif, so that's what we set the mimetype to.
url = discordgo.EndpointEmojiAnimated(id)
mimeType = "image/gif"
} else {
// This url requests a png, so that's what we set the mimetype to.
url = discordgo.EndpointEmoji(id)
mimeType = "image/png"
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, mimeType, err
}
req.Header.Set("User-Agent", discordgo.DroidBrowserUserAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, mimeType, err
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
return data, mimeType, err
}
func (portal *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
uploaded, err := intent.UploadBytes(data, mimeType)
if err != nil {
return id.ContentURI{}, err
}
return uploaded.ContentURI, nil
}

View File

@@ -2,6 +2,9 @@
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
@@ -17,6 +20,13 @@ homeserver:
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
@@ -77,11 +87,14 @@ bridge:
# 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
# .Username - Legacy display/username on Discord
# .GlobalName - New 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}}'
# .Webhook - Whether the user is a webhook and is not an application
# .Application - Whether the user is an application
displayname_template: '{{or .GlobalName .Username}}{{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.
@@ -94,9 +107,11 @@ bridge:
# Available variables:
# .Name - Guild name
guild_name_template: '{{.Name}}'
# Should the bridge explicitly set the avatar and room name for DM portal rooms?
# This is implicitly enabled in encrypted rooms.
private_chat_portal_meta: false
# 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
@@ -140,6 +155,47 @@ bridge:
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Prefix messages from webhooks with the profile info? This can be used along with a custom displayname_template
# to better handle webhooks that change their name all the time (like ones used by bridges).
prefix_webhook_messages: false
# Bridge webhook avatars?
enable_webhook_avatars: true
# Should the bridge upload media to the Discord CDN directly before sending the message when using a user token,
# like the official client does? The other option is sending the media in the message send request as a form part
# (which is always used by bots and webhooks).
use_discord_cdn_upload: true
# Should mxc uris copied from Discord be cached?
# This can be `never` to never cache, `unencrypted` to only cache unencrypted mxc uris, or `always` to cache everything.
# If you have a media repo that generates non-unique mxc uris, you should set this to never.
cache_media: unencrypted
# Patterns for converting Discord media to custom mxc:// URIs instead of reuploading.
# Each of the patterns can be set to null to disable custom URIs for that type of media.
# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
media_patterns:
# Should custom mxc:// URIs be used instead of reuploading media?
enabled: false
# Pattern for normal message attachments.
attachments: mxc://discord-media.mau.dev/attachments|{{.ChannelID}}|{{.AttachmentID}}|{{.FileName}}
# Pattern for custom emojis.
emojis: mxc://discord-media.mau.dev/emojis|{{.ID}}.{{.Ext}}
# Pattern for stickers. Note that animated lottie stickers will not be converted if this is enabled.
stickers: mxc://discord-media.mau.dev/stickers|{{.ID}}.{{.Ext}}
# Pattern for static user avatars.
avatars: mxc://discord-media.mau.dev/avatars|{{.UserID}}|{{.AvatarID}}.{{.Ext}}
# 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
@@ -167,6 +223,30 @@ bridge:
# 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
thread: 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
thread: 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.
@@ -183,6 +263,29 @@ bridge:
# 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
# Should users mentions be in the event wire content to enable the server to send push notifications?
plaintext_mentions: 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
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
@@ -218,6 +321,10 @@ bridge:
# default.
messages: 100
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
@@ -240,12 +347,15 @@ bridge:
"example.com": user
"@admin:example.com": admin
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
directory: ./logs
file_name_format: '{{.Date}}-{{.Index}}.log'
file_date_format: "2006-01-02"
file_mode: 384
timestamp_format: Jan _2, 2006 15:04:05
print_level: debug
print_json: false
file_json: false
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,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// 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
@@ -21,57 +21,102 @@ import (
"regexp"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"go.mau.fi/util/variationselector"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/variationselector"
)
var discordExtensions = goldmark.WithExtensions(mdext.SimpleSpoiler, mdext.DiscordUnderline, &DiscordEveryone{})
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
//
// Discord allows escaping with just one backslash, e.g. \__a__,
// but standard markdown requires both to be escaped (\_\_a__)
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string) string {
text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string {
func escapeReplacement(s string) string {
return s[:2] + `\` + s[2:]
})
}
mdRenderer := goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
type indentableParagraphParser struct {
parser.BlockParser
}
var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
return true
}
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
)),
format.Extensions, format.HTMLOptions, discordExtensions,
goldmark.WithExtensions(&DiscordTag{portal}),
parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(),
}
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
var discordRenderer = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
var discordRendererWithInlineLinks = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder
err := mdRenderer.Convert([]byte(text), &buf)
ctx := parser.NewContext()
ctx.Set(parserContextPortal, portal)
renderer := discordRenderer
if allowInlineLinks {
renderer = discordRendererWithInlineLinks
}
err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err))
}
return format.UnwrapSingleParagraph(buf.String())
}
const formatterContextUserKey = "fi.mau.discord.user"
const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
func pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
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
}
user := ctx.ReturnData[formatterContextUserKey].(*User)
if mxid[0] == '#' {
alias, err := user.bridge.Bot.ResolveAlias(id.RoomAlias(mxid))
alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
if err != nil {
return displayname
}
mxid = alias.RoomID.String()
}
if mxid[0] == '!' {
portal := user.bridge.GetPortalByMXID(id.RoomID(mxid))
portal := br.GetPortalByMXID(id.RoomID(mxid))
if portal != nil {
if eventID == "" {
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
@@ -82,7 +127,7 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
//} else {
// // TODO is mentioning private channels possible at all?
//}
} else if msg := user.bridge.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
} else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
guildID := portal.GuildID
if guildID == "" {
guildID = "@me"
@@ -91,23 +136,33 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
}
}
} else if mxid[0] == '@' {
parsedID, ok := user.bridge.ParsePuppetMXID(id.UserID(mxid))
allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID)
if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) {
return displayname
}
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 := user.bridge.GetUserByMXID(id.UserID(mxid))
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
}
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(`https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`)
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
var discordMarkdownEscaper = strings.NewReplacer(
`\`, `\\`,
@@ -117,6 +172,7 @@ var discordMarkdownEscaper = strings.NewReplacer(
"`", "\\`",
`|`, `\|`,
`<`, `\<`,
`#`, `\#`,
)
func escapeDiscordMarkdown(s string) string {
@@ -160,19 +216,31 @@ var matrixHTMLParser = &format.HTMLParser{
}
return fmt.Sprintf("||%s||", text)
},
LinkConverter: func(text, href string, ctx format.Context) string {
if text == href {
return text
} else if !discordLinkRegexFull.MatchString(href) {
return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href))
}
return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
},
}
func init() {
matrixHTMLParser.PillConverter = pillConverter
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
RepliedUser: true,
}
func (portal *Portal) parseMatrixHTML(user *User, content *event.MessageEventContent) string {
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext()
ctx.ReturnData[formatterContextUserKey] = user
ctx.ReturnData[formatterContextPortalKey] = portal
return variationselector.Remove(matrixHTMLParser.Parse(content.FormattedBody, ctx))
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.Remove(escapeDiscordMarkdown(content.Body))
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
}
}

View File

@@ -96,9 +96,11 @@ func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, so
return
}
type DiscordEveryone struct{}
type discordEveryone struct{}
func (e *DiscordEveryone) Extend(m goldmark.Markdown) {
var ExtDiscordEveryone = &discordEveryone{}
func (e *discordEveryone) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordEveryoneParser, 600),
))

View File

@@ -30,13 +30,13 @@ import (
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix"
"go.mau.fi/mautrix-discord/database"
)
type astDiscordTag struct {
ast.BaseInline
portal *Portal
id int64
}
@@ -143,7 +143,10 @@ func (s *discordTagParser) Trigger() []byte {
return []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)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
@@ -157,7 +160,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
if err != nil {
return nil
}
tag := astDiscordTag{id: id}
tag := astDiscordTag{id: id, portal: portal}
tagName := string(match[1])
switch {
case tagName == "@":
@@ -189,7 +192,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
}
@@ -199,9 +202,9 @@ func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordTagHTMLRenderer struct {
portal *Portal
}
type discordTagHTMLRenderer struct{}
var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordTag, r.renderDiscordMention)
@@ -259,32 +262,39 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
}
switch node := n.(type) {
case *astDiscordUserMention:
puppet := r.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10))
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name)
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, user.MXID.URI().MatrixToURL(), user.MXID)
} else if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, puppet.MXID.URI().MatrixToURL(), puppet.Name)
}
return
case *astDiscordRoleMention:
role := r.portal.bridge.DB.Role.GetByID(r.portal.GuildID, strconv.FormatInt(node.id, 10))
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
}
case *astDiscordChannelMention:
portal := r.portal.bridge.GetExistingPortalByID(database.PortalKey{
portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
ChannelID: strconv.FormatInt(node.id, 10),
Receiver: "",
})
if 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.AS.HomeserverDomain).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
return
}
case *astDiscordCustomEmoji:
reactionMXC := r.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
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)
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:
@@ -299,9 +309,9 @@ 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.(mautrix.Stringifiable)
stringifiable, ok := n.(fmt.Stringer)
if ok {
_, _ = w.WriteString(stringifiable.String())
} else {
@@ -310,15 +320,15 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return
}
type DiscordTag struct {
Portal *Portal
}
type discordTag struct{}
func (e *DiscordTag) Extend(m goldmark.Markdown) {
var ExtDiscordTag = &discordTag{}
func (e *discordTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordTagParser, 600),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&discordTagHTMLRenderer{e.Portal}, 600),
util.Prioritized(defaultDiscordTagHTMLRenderer, 600),
))
}

36
go.mod
View File

@@ -1,37 +1,43 @@
module go.mau.fi/mautrix-discord
go 1.18
go 1.20
require (
github.com/bwmarrin/discordgo v0.26.1
github.com/gabriel-vasile/mimetype v1.4.1
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.7
github.com/mattn/go-sqlite3 v1.14.16
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.17
github.com/rs/zerolog v1.30.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.1
github.com/yuin/goldmark v1.5.3
maunium.net/go/maulogger/v2 v2.3.2
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52
github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.5.6
go.mau.fi/util v0.1.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/sync v0.3.0
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.16.1
)
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/rs/zerolog v1.28.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/gjson v1.16.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.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-20230129125832-37978ff8e399
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718

86
go.sum
View File

@@ -1,81 +1,73 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399 h1:3GZhhiyeXo/r40NmaQddBpCfosSSIrSrqZBLXJWrtYc=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718 h1:gzOFOenpzAWnsiskTmOOorrrejm2wGjSpxzQ5zgpSso=
github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
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/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.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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.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/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=
github.com/tidwall/gjson v1.16.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/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.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
github.com/yuin/goldmark v1.5.6 h1:COmQAWTCcGetChm3Ig7G/t8AFAN00t+o8Mt4cf7JpwA=
github.com/yuin/goldmark v1.5.6/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE=
go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
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.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
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.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.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.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 h1:7KoJL/7eozYlu4GW2jADHO+Qhm8WL45Afcm7A45BivM=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM=
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.16.1 h1:Wb3CvOCe8A/NLsFeZYxKrgXKiqeZUQEBD1zqm7n/kWk=
maunium.net/go/mautrix v0.16.1/go.mod h1:2Jf15tulVtr6LxoiRL4smRXwpkGWUNfBFhwh/aXDBuk=

View File

@@ -22,6 +22,7 @@ import (
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/maulogger/v2/maulogadapt"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@@ -219,13 +220,14 @@ func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
guild.bridge.guildsLock.Unlock()
guild.log.Infoln("Matrix room created:", guild.MXID)
user.ensureInvited(nil, guild.MXID, false)
user.ensureInvited(nil, guild.MXID, false, true)
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
@@ -235,6 +237,7 @@ func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.G
guild.UpdateBridgeInfo()
guild.Update()
}
source.ensureInvited(nil, guild.MXID, false, false)
return meta
}
@@ -269,12 +272,15 @@ func (guild *Guild) UpdateAvatar(iconID string) bool {
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))
// TODO direct media support
copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{
AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID),
})
if err != nil {
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", guild.Avatar, err)
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
return true
}
guild.AvatarURL = copied.MXC
}
if guild.MXID != "" {
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
@@ -292,14 +298,14 @@ func (guild *Guild) cleanup() {
return
}
intent := guild.bridge.Bot
if guild.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] {
if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
err := intent.BeeperDeleteRoom(guild.MXID)
if err == nil || errors.Is(err, mautrix.MNotFound) {
if err != nil && !errors.Is(err, mautrix.MNotFound) {
guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
}
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)
guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
}
func (guild *Guild) RemoveMXID() {
@@ -312,6 +318,17 @@ func (guild *Guild) RemoveMXID() {
guild.MXID = ""
guild.AvatarSet = false
guild.NameSet = false
guild.AutoBridgeChannels = 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()
}

19
main.go
View File

@@ -20,10 +20,12 @@ import (
_ "embed"
"sync"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/exsync"
"golang.org/x/sync/semaphore"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
@@ -71,6 +73,9 @@ type DiscordBridge struct {
puppets map[string]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
parallelAttachmentSemaphore *semaphore.Weighted
}
func (br *DiscordBridge) GetExampleConfig() string {
@@ -89,14 +94,17 @@ 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.Log.Sub("Discord")
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
}
func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
br.WaitWebsocketConnected()
go br.startUsers()
}
@@ -163,13 +171,18 @@ func main() {
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
parallelAttachmentSemaphore: semaphore.NewWeighted(3),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.1.0",
Version: "0.6.2",
ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",

1575
portal.go

File diff suppressed because it is too large Load Diff

736
portal_convert.go Normal file
View File

@@ -0,0 +1,736 @@
// 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 (
"context"
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/id"
"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(ctx context.Context, 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 {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
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 dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
return content
}
func (portal *Portal) 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
}
}
}
func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
var mime, ext string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
ext = "png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
ext = "png"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
ext = "json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
ext = "gif"
default:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
Str("sticker_id", sticker.ID).
Msg("Unknown sticker format")
}
content := &event.MessageEventContent{
Body: sticker.Name, // TODO find description from somewhere?
Info: &event.FileInfo{
MimeType: mime,
},
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Sticker(sticker.ID, ext)
if mxc.IsEmpty() {
content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
} else {
content.URL = mxc.CUString()
}
portal.cleanupConvertedStickerInfo(content)
return &ConvertedMessage{
AttachmentID: sticker.ID,
Type: event.EventSticker,
Content: content,
}
}
func (portal *Portal) convertDiscordAttachment(ctx context.Context, 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
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Attachment(portal.Key.ChannelID, att.ID, att.Filename)
if mxc.IsEmpty() {
content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
} else {
content.URL = mxc.CUString()
}
return &ConvertedMessage{
AttachmentID: att.ID,
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
attachmentID := fmt.Sprintf("video_%s", embed.URL)
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 &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: &event.MessageEventContent{
Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
MsgType: event.MsgNotice,
},
}
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: portal.createMediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: embed.URL,
Info: &event.FileInfo{
MimeType: dbFile.MimeType,
Size: dbFile.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
}
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 content.MsgType == event.MsgVideo && 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(ctx context.Context, puppet *Puppet, 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(ctx, intent, msg); textPart != nil {
parts = append(parts, textPart)
}
log := zerolog.Ctx(ctx)
handledIDs := make(map[string]struct{})
for _, att := range msg.Attachments {
if _, handled := handledIDs[att.ID]; handled {
continue
}
handledIDs[att.ID] = struct{}{}
log := log.With().Str("attachment_id", att.ID).Logger()
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
parts = append(parts, part)
}
}
for _, sticker := range msg.StickerItems {
if _, handled := handledIDs[sticker.ID]; handled {
continue
}
handledIDs[sticker.ID] = struct{}{}
log := log.With().Str("sticker_id", sticker.ID).Logger()
if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, 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 _, handled := handledIDs[embed.URL]; handled {
continue
}
handledIDs[embed.URL] = struct{}{}
log := log.With().
Str("computed_embed_type", "video").
Str("embed_type", string(embed.Type)).
Int("embed_index", i).
Logger()
part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
if part != nil {
parts = append(parts, part)
}
}
if len(parts) == 0 && msg.Thread != nil {
parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
}})
}
for _, part := range parts {
puppet.addWebhookMeta(part, msg)
puppet.addMemberMeta(part, msg)
}
return parts
}
func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.Member == nil {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
var discordAvatarURL string
if msg.Member.Avatar != "" {
var err error
avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload guild user avatar")
}
}
part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
"nick": msg.Member.Nick,
"avatar_id": msg.Member.Avatar,
"avatar_url": discordAvatarURL,
"avatar_mxc": avatarURL.String(),
}
if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
perMessageProfile := map[string]any{
"is_multiple_users": false,
"displayname": msg.Member.Nick,
"avatar_url": avatarURL.String(),
}
if msg.Member.Nick == "" {
perMessageProfile["displayname"] = puppet.Name
}
if avatarURL.IsEmpty() {
perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
}
part.Extra["com.beeper.per_message_profile"] = perMessageProfile
}
}
func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.WebhookID == "" {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
if msg.Author.Avatar != "" {
var err error
avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload webhook avatar")
}
}
part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
"id": msg.WebhookID,
"name": msg.Author.Username,
"avatar_id": msg.Author.Avatar,
"avatar_url": msg.Author.AvatarURL(""),
"avatar_mxc": avatarURL.String(),
}
part.Extra["com.beeper.per_message_profile"] = map[string]any{
"is_multiple_users": true,
"avatar_url": avatarURL.String(),
"displayname": msg.Author.Username,
}
}
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(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) 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 != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
if err != nil {
log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
} 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 {
log.Warn().Err(err).Msg("Failed to reupload image in embed")
} 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 {
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 != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
if err != nil {
log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
} 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(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
return
}
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(ctx context.Context, 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(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
portal.convertDiscordLinkEmbedImage(ctx, 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(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
}
}
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
contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
return contentIsOnlyURL && (isGifVideo || isGifImage)
}
func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
var matrixMentions event.Mentions
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
if syncGhosts {
puppet.UpdateInfo(nil, mention, nil)
}
user := portal.bridge.GetUserByID(mention.ID)
if user != nil {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
} else {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
}
}
slices.Sort(matrixMentions.UserIDs)
matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
if msg.MentionEveryone {
matrixMentions.Room = true
}
return &matrixMentions
}
func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
log := zerolog.Ctx(ctx)
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, nil)
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, true))
}
previews := make([]*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, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
case EmbedLinkPreview:
log := with.Str("computed_embed_type", "link preview").Logger()
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
case EmbedVideo:
// Ignore video embeds, they're handled as separate messages
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,
}
if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
content.EnsureHasHTML()
content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

View File

@@ -18,6 +18,7 @@ import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth"
)
@@ -226,7 +227,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
} else {
msg = "User wasn't logged in."
}
user.Logout()
user.Logout(false)
jsonResponse(w, http.StatusOK, Response{true, msg})
}
@@ -327,8 +328,6 @@ func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
}
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
user.DiscordID = discordUser.UserID
user.Update()
if err = user.Login(discordUser.Token); err != nil {
log.Errorln("Failed to connect after logging in:", err)
@@ -434,6 +433,7 @@ type guildEntry struct {
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 {
@@ -455,7 +455,8 @@ func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
Name: guild.PlainName,
AvatarURL: guild.AvatarURL,
MXID: guild.MXID,
AutoBridge: guild.AutoBridgeChannels,
AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
BridgingMode: guild.BridgingMode.String(),
})
}
@@ -526,7 +527,7 @@ func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request)
Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
} else if !guild.AutoBridgeChannels && guild.MXID == "" {
} else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
jsonResponse(w, http.StatusNotFound, Error{
Error: "That guild is not bridged",
ErrCode: ErrCodeGuildNotBridged,

146
puppet.go
View File

@@ -3,12 +3,13 @@ package main
import (
"fmt"
"regexp"
"strings"
"sync"
log "maunium.net/go/maulogger/v2"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
@@ -20,7 +21,7 @@ type Puppet struct {
*database.Puppet
bridge *DiscordBridge
log log.Logger
log zerolog.Logger
MXID id.UserID
@@ -43,7 +44,7 @@ func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{
Puppet: dbPuppet,
bridge: br,
log: br.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(),
MXID: br.FormatPuppetMXID(dbPuppet.ID),
}
@@ -194,7 +195,7 @@ func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
}
func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info)
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication)
if puppet.Name == newName && puppet.NameSet {
return false
}
@@ -202,10 +203,10 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(newName)
if err != nil {
puppet.log.Warnln("Failed to update displayname:", err)
puppet.log.Warn().Err(err).Msg("Failed to update displayname")
} else {
go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateNameDirect(puppet.Name) {
if portal.UpdateNameDirect(puppet.Name, false) {
portal.Update()
portal.UpdateBridgeInfo()
}
@@ -215,20 +216,55 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
return true
}
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
var downloadURL, ext string
if guildID == "" {
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
ext = "gif"
}
} else {
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
ext = "gif"
}
}
if guildID == "" {
url := br.Config.Bridge.MediaPatterns.Avatar(userID, avatarID, ext)
if !url.IsEmpty() {
return url, downloadURL, nil
}
}
copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
})
if err != nil {
return id.ContentURI{}, downloadURL, err
}
return copied.MXC, downloadURL, nil
}
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
if puppet.Avatar == info.Avatar && puppet.AvatarSet {
avatarID := info.Avatar
if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars {
avatarID = ""
}
if puppet.Avatar == avatarID && puppet.AvatarSet {
return false
}
avatarChanged := info.Avatar != puppet.Avatar
puppet.Avatar = info.Avatar
avatarChanged := avatarID != puppet.Avatar
puppet.Avatar = avatarID
puppet.AvatarSet = false
puppet.AvatarURL = id.ContentURI{}
// TODO should we just use discord's default avatars for users with no avatar?
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
url, err := uploadAvatar(puppet.DefaultIntent(), info.AvatarURL(""))
url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar)
if err != nil {
puppet.log.Warnfln("Failed to reupload user avatar %s: %v", puppet.Avatar, err)
puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
return true
}
puppet.AvatarURL = url
@@ -236,7 +272,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil {
puppet.log.Warnln("Failed to update avatar:", err)
puppet.log.Warn().Err(err).Msg("Failed to update avatar")
} else {
go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateAvatarFromPuppet(puppet) {
@@ -249,7 +285,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
return true
}
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) {
puppet.syncLock.Lock()
defer puppet.syncLock.Unlock()
@@ -258,23 +294,97 @@ func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
return
}
var err error
puppet.log.Debugfln("Fetching info through %s to update", source.DiscordID)
puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet")
info, err = source.Session.User(puppet.ID)
if err != nil {
puppet.log.Errorfln("Failed to fetch info through %s: %v", source.DiscordID, err)
puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user")
return
}
}
err := puppet.DefaultIntent().EnsureRegistered()
if err != nil {
puppet.log.Errorln("Failed to ensure registered:", err)
puppet.log.Error().Err(err).Msg("Failed to ensure registered")
}
changed := false
if message != nil {
if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook {
puppet.log.Debug().
Str("message_id", message.ID).
Str("webhook_id", message.WebhookID).
Msg("Found webhook ID in message, marking ghost as a webhook")
puppet.IsWebhook = true
changed = true
}
if message.ApplicationID != "" && !puppet.IsApplication {
puppet.log.Debug().
Str("message_id", message.ID).
Str("application_id", message.ApplicationID).
Msg("Found application ID in message, marking ghost as an application")
puppet.IsApplication = true
puppet.IsWebhook = false
changed = true
}
}
changed = puppet.UpdateContactInfo(info) || changed
changed = puppet.UpdateName(info) || changed
changed = puppet.UpdateAvatar(info) || changed
if changed {
puppet.Update()
}
}
func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool {
changed := false
if puppet.Username != info.Username {
puppet.Username = info.Username
changed = true
}
if puppet.GlobalName != info.GlobalName {
puppet.GlobalName = info.GlobalName
changed = true
}
if puppet.Discriminator != info.Discriminator {
puppet.Discriminator = info.Discriminator
changed = true
}
if puppet.IsBot != info.Bot {
puppet.IsBot = info.Bot
changed = true
}
if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet {
puppet.ContactInfoSet = false
puppet.ResendContactInfo()
return true
}
return false
}
func (puppet *Puppet) ResendContactInfo() {
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
return
}
discordUsername := puppet.Username
if puppet.Discriminator != "0" {
discordUsername += "#" + puppet.Discriminator
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": []string{
fmt.Sprintf("discord:%s", discordUsername),
},
"com.beeper.bridge.remote_id": puppet.ID,
"com.beeper.bridge.service": puppet.bridge.BeeperServiceName,
"com.beeper.bridge.network": puppet.bridge.BeeperNetworkName,
"com.beeper.bridge.is_network_bot": puppet.IsBot,
}
if puppet.IsWebhook {
contactInfo["com.beeper.bridge.identifiers"] = []string{}
}
err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
if err != nil {
puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
} else {
puppet.ContactInfoSet = true
}
}

View File

@@ -1,10 +1,13 @@
package main
import (
"context"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
@@ -15,6 +18,7 @@ type Thread struct {
Parent *Portal
creationNoticeLock sync.Mutex
initialBackfillAttempted bool
}
func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread {
@@ -74,19 +78,80 @@ func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *
return thread
}
func (br *DiscordBridge) threadFound(ctx context.Context, source *User, rootMessage *database.Message, id string, metadata *discordgo.Channel) {
thread := br.GetThreadByID(id, rootMessage)
log := zerolog.Ctx(ctx)
log.Debug().Msg("Marked message as thread root")
if thread.CreationNoticeMXID == "" {
thread.Parent.sendThreadCreationNotice(ctx, thread)
}
// TODO member_ids_preview is probably not guaranteed to contain the source user
if source != nil && metadata != nil && slices.Contains(metadata.MemberIDsPreview, source.DiscordID) && !source.IsInPortal(thread.ID) {
source.MarkInPortal(database.UserPortal{
DiscordID: thread.ID,
Type: database.UserPortalTypeThread,
Timestamp: time.Now(),
})
if metadata.MessageCount > 0 {
go thread.maybeInitialBackfill(source)
} else {
thread.initialBackfillAttempted = true
}
}
}
func (thread *Thread) maybeInitialBackfill(source *User) {
if thread.initialBackfillAttempted || thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread == 0 {
return
}
thread.Parent.forwardBackfillLock.Lock()
if thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) != nil {
thread.Parent.forwardBackfillLock.Unlock()
return
}
thread.Parent.forwardBackfillInitial(source, thread)
}
func (thread *Thread) Join(user *User) {
if user.IsInPortal(thread.ID) {
return
}
user.log.Debugfln("Joining thread %s@%s", thread.ID, thread.ParentID)
err := user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu)
log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
log.Debug().Msg("Joining thread")
var doBackfill, backfillStarted bool
if !thread.initialBackfillAttempted && thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread > 0 {
thread.Parent.forwardBackfillLock.Lock()
lastMessage := thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID)
if lastMessage != nil {
thread.Parent.forwardBackfillLock.Unlock()
} else {
doBackfill = true
defer func() {
if !backfillStarted {
thread.Parent.forwardBackfillLock.Unlock()
}
}()
}
}
var err error
if user.Session.IsUser {
err = user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu)
} else {
err = user.Session.ThreadJoin(thread.ID)
}
if err != nil {
user.log.Errorfln("Error joining thread %s@%s: %v", thread.ID, thread.ParentID, err)
log.Error().Err(err).Msg("Error joining thread")
} else {
user.MarkInPortal(database.UserPortal{
DiscordID: thread.ID,
Type: database.UserPortalTypeThread,
Timestamp: time.Now(),
})
if doBackfill {
go thread.Parent.forwardBackfillInitial(user, thread)
backfillStarted = true
}
}
}

793
user.go

File diff suppressed because it is too large Load Diff