128 Commits

Author SHA1 Message Date
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
43 changed files with 3841 additions and 1377 deletions

9
.gitignore vendored
View File

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

View File

@@ -1,3 +1,73 @@
# 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) # v0.1.1 (2023-02-16)
* Started automatically subscribing to bridged guilds. This fixes two problems: * Started automatically subscribing to bridged guilds. This fixes two problems:

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) * [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)) (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 ### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md) [ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md)

View File

@@ -128,17 +128,17 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
ContentType: uploadMime, ContentType: uploadMime,
} }
if br.Config.Homeserver.AsyncMedia { if br.Config.Homeserver.AsyncMedia {
resp, err := intent.UnstableCreateMXC() resp, err := intent.CreateMXC()
if err != nil { if err != nil {
return nil, err return nil, err
} }
dbFile.MXC = resp.ContentURI dbFile.MXC = resp.ContentURI
req.UnstableMXC = resp.ContentURI req.MXC = resp.ContentURI
req.UploadURL = resp.UploadURL req.UnstableUploadURL = resp.UnstableUploadURL
go func() { go func() {
_, err = intent.UploadMedia(req) _, err = intent.UploadMedia(req)
if err != nil { 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() dbFile.Delete()
} }
}() }()
@@ -246,7 +246,7 @@ func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
} }
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) { func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
isCacheable := !encrypt isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt) returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil { if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt} transferKey := attachmentKey{url, encrypt}
@@ -288,13 +288,19 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
} }
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI { func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
var url, mimeType string var url, mimeType, ext string
if animated { if animated {
url = discordgo.EndpointEmojiAnimated(emojiID) url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif" mimeType = "image/gif"
ext = "gif"
} else { } else {
url = discordgo.EndpointEmoji(emojiID) url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png" 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{ dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID, AttachmentID: emojiID,
@@ -302,7 +308,7 @@ func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool
EmojiName: name, EmojiName: name,
}) })
if err != nil { if err != nil {
portal.log.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err) portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
return id.ContentURI{} return id.ContentURI{}
} }
return dbFile.MXC 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
}

322
backfill.go Normal file
View File

@@ -0,0 +1,322 @@
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/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func (portal *Portal) forwardBackfillInitial(source *User) {
defer portal.forwardBackfillLock.Unlock()
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
if portal.forwardBackfillLock.TryLock() {
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
}
if limit == 0 {
return
}
log := portal.log.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
portal.backfillLimited(log, source, limit, "")
}
func (portal *Portal) ForwardBackfillMissed(source *User, meta *discordgo.Channel) {
if portal.MXID == "" {
return
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
}
if limit == 0 {
return
}
log := portal.log.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
lastMessage := portal.bridge.DB.Message.GetLast(portal.Key)
if lastMessage == nil || meta.LastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, meta.LastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", meta.LastMessageID).
Msg("Not backfilling, last message in database is newer than last message in metadata")
return
}
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", meta.LastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, before, "", "")
if err != nil {
return nil, false, err
}
if until != "" {
for i, msg := range newMessages {
if compareMessageIDs(msg.ID, until) <= 0 {
log.Debug().
Str("message_id", msg.ID).
Str("until_id", until).
Msg("Found message that was already bridged")
newMessages = newMessages[:i]
foundAll = true
break
}
}
}
messages = append(messages, newMessages...)
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
break
}
before = newMessages[len(newMessages)-1].ID
}
if len(messages) > limit {
foundAll = false
messages = messages[:limit]
}
return messages, foundAll, nil
}
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after)
if err != nil {
log.Err(err).Msg("Error collecting messages to forward backfill")
return
}
log.Info().
Int("count", len(messages)).
Bool("found_all", foundAll).
Msg("Collected messages to backfill")
sort.Sort(MessageSlice(messages))
if !foundAll && 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)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string) {
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, "", after, "")
if err != nil {
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
return
}
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
sort.Sort(MessageSlice(messages))
portal.sendBackfillBatch(log, source, messages)
if len(messages) < messageFetchChunkSize {
// Assume that was all the missing messages
log.Debug().Msg("Chunk had less than 50 messages, stopping backfill")
return
}
after = messages[len(messages)-1].ID
}
}
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message) {
if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, nil)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message) {
evts, dbMessages := portal.convertMessageBatch(log, source, messages)
if len(evts) == 0 {
log.Warn().Msg("Didn't get any events to backfill")
return
}
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
resp, err := portal.MainIntent().BatchSend(portal.MXID, &mautrix.ReqBatchSend{
BeeperNewMessages: true,
Events: evts,
})
if err != nil {
log.Err(err).Msg("Error sending backfill batch")
return
}
for i, evtID := range resp.EventIDs {
dbMessages[i].MXID = evtID
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
log.Info().Msg("Inserted backfilled batch to database")
}
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message) ([]*event.Event, []database.Message) {
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.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, "")
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author, msg.WebhookID)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, "", 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 {
part.Content.RelatesTo = &event.RelatesTo{InReplyTo: replyTo}
// 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,
})
}
}
return evts, 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 ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"html" "html"
@@ -30,10 +31,13 @@ import (
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"maunium.net/go/mautrix" "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/bridge/commands"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth" "go.mau.fi/mautrix-discord/remoteauth"
) )
@@ -44,14 +48,22 @@ type WrappedCommandEvent struct {
Portal *Portal Portal *Portal
} }
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
func (br *DiscordBridge) RegisterCommands() { func (br *DiscordBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor) proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers( proc.AddHandlers(
cmdLoginToken, cmdLoginToken,
cmdLoginQR, cmdLoginQR,
cmdLogout, cmdLogout,
cmdPing,
cmdReconnect, cmdReconnect,
cmdDisconnect, cmdDisconnect,
cmdBridge,
cmdUnbridge,
cmdDeletePortal,
cmdSetRelay,
cmdUnsetRelay,
cmdGuilds, cmdGuilds,
cmdRejoinSpace, cmdRejoinSpace,
cmdDeleteAllPortals, cmdDeleteAllPortals,
@@ -78,12 +90,45 @@ var cmdLoginToken = &commands.FullHandler{
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionAuth, Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by extracting the access token manually.", 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) { func fnLoginToken(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 { if len(ce.Args) != 2 {
ce.Reply("**Usage**: `$cmdprefix login-token <token>`") ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
return return
} }
ce.MarkRead() ce.MarkRead()
@@ -92,7 +137,25 @@ func fnLoginToken(ce *WrappedCommandEvent) {
ce.Reply("You're already logged in") ce.Reply("You're already logged in")
return 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) ce.Reply("Error connecting to Discord: %v", err)
return return
} }
@@ -218,7 +281,7 @@ var cmdLogout = &commands.FullHandler{
func fnLogout(ce *WrappedCommandEvent) { func fnLogout(ce *WrappedCommandEvent) {
wasLoggedIn := ce.User.DiscordID != "" wasLoggedIn := ce.User.DiscordID != ""
ce.User.Logout() ce.User.Logout(false)
if wasLoggedIn { if wasLoggedIn {
ce.Reply("Logged out successfully.") ce.Reply("Logged out successfully.")
} else { } 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 (`%s`)", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator, ce.User.DiscordID)
}
}
var cmdDisconnect = &commands.FullHandler{ var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect), Func: wrapCommand(fnDisconnect),
Name: "disconnect", Name: "disconnect",
@@ -271,7 +357,7 @@ var cmdRejoinSpace = &commands.FullHandler{
Func: wrapCommand(fnRejoinSpace), Func: wrapCommand(fnRejoinSpace),
Name: "rejoin-space", Name: "rejoin-space",
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionPortalManagement,
Description: "Ask the bridge for an invite to a space you left", Description: "Ask the bridge for an invite to a space you left",
Args: "<_guild ID_/main/dms>", Args: "<_guild ID_/main/dms>",
}, },
@@ -285,10 +371,10 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
} }
user := ce.User user := ce.User
if ce.Args[0] == "main" { 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()) ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if ce.Args[0] == "dms" { } 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()) 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 { } else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
ce.Reply("Rejoining guild spaces is not yet implemented") 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{ var cmdGuilds = &commands.FullHandler{
Func: wrapCommand(fnGuilds), Func: wrapCommand(fnGuilds),
Name: "guilds", Name: "guilds",
Aliases: []string{"servers", "guild", "server"}, Aliases: []string{"servers", "guild", "server"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionPortalManagement,
Description: "Guild bridging management", Description: "Guild bridging management",
Args: "<status/bridge/unbridge> [_guild ID_] [--entire]", Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
}, },
RequiresLogin: true, 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 + ` const fullGuildsHelp = smallGuildsHelp + `
* **help** - View this help message. * **help** - View this help message.
* **status** - View the list of guilds and their bridging status. * **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. * **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.` * **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.`
func fnGuilds(ce *WrappedCommandEvent) { func fnGuilds(ce *WrappedCommandEvent) {
@@ -333,6 +576,8 @@ func fnGuilds(ce *WrappedCommandEvent) {
fnBridgeGuild(ce) fnBridgeGuild(ce)
case "unbridge", "delete": case "unbridge", "delete":
fnUnbridgeGuild(ce) fnUnbridgeGuild(ce)
case "bridging-mode", "mode":
fnGuildBridgingMode(ce)
case "help": case "help":
ce.Reply(fullGuildsHelp) ce.Reply(fullGuildsHelp)
default: default:
@@ -347,15 +592,11 @@ func fnListGuilds(ce *WrappedCommandEvent) {
if guild == nil { if guild == nil {
continue continue
} }
status := "not bridged"
if guild.MXID != "" {
status = "bridged"
}
var avatarHTML string var avatarHTML string
if !guild.AvatarURL.IsEmpty() { if !guild.AvatarURL.IsEmpty() {
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="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 { if len(items) == 0 {
ce.Reply("No guilds found") 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{ var cmdDeleteAllPortals = &commands.FullHandler{
Func: wrapCommand(fnDeleteAllPortals), Func: wrapCommand(fnDeleteAllPortals),
Name: "delete-all-portals", Name: "delete-all-portals",
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: commands.HelpSectionAdmin,
Description: "Delete all portals.", Description: "Delete all portals.",
}, },
RequiresAdmin: true, RequiresAdmin: true,
@@ -396,14 +816,15 @@ var cmdDeleteAllPortals = &commands.FullHandler{
func fnDeleteAllPortals(ce *WrappedCommandEvent) { func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortals() 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") ce.Reply("Didn't find any portals")
return return
} }
leave := func(portal *Portal) { leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
if len(portal.MXID) > 0 { if len(mxid) > 0 {
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ _, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
Reason: "Deleting portal", Reason: "Deleting portal",
UserID: ce.User.MXID, UserID: ce.User.MXID,
}) })
@@ -412,19 +833,23 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) {
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil { if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent() intent := customPuppet.CustomIntent()
leave = func(portal *Portal) { leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
if len(portal.MXID) > 0 { if len(mxid) > 0 {
_, _ = intent.LeaveRoom(portal.MXID) _, _ = intent.LeaveRoom(mxid)
_, _ = intent.ForgetRoom(portal.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 { for _, portal := range portals {
portal.Delete() 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() { go func() {
for _, portal := range portals { for _, portal := range portals {

View File

@@ -28,12 +28,14 @@ import (
"maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/bridge/commands"
) )
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
var cmdCommands = &commands.FullHandler{ var cmdCommands = &commands.FullHandler{
Func: wrapCommand(fnCommands), Func: wrapCommand(fnCommands),
Name: "commands", Name: "commands",
Aliases: []string{"cmds", "cs"}, Aliases: []string{"cmds", "cs"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionDiscordBots,
Description: "View parameters of bot interaction commands on Discord", Description: "View parameters of bot interaction commands on Discord",
Args: "search <_query_> OR help <_command_>", Args: "search <_query_> OR help <_command_>",
}, },
@@ -46,7 +48,7 @@ var cmdExec = &commands.FullHandler{
Name: "exec", Name: "exec",
Aliases: []string{"command", "cmd", "c", "exec", "e"}, Aliases: []string{"command", "cmd", "c", "exec", "e"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionDiscordBots,
Description: "Run bot interaction commands on Discord", Description: "Run bot interaction commands on Discord",
Args: "<_command_> [_arg=value ..._]", Args: "<_command_> [_arg=value ..._]",
}, },

View File

@@ -25,6 +25,7 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
) )
type BridgeConfig struct { type BridgeConfig struct {
@@ -32,7 +33,7 @@ type BridgeConfig struct {
DisplaynameTemplate string `yaml:"displayname_template"` DisplaynameTemplate string `yaml:"displayname_template"`
ChannelNameTemplate string `yaml:"channel_name_template"` ChannelNameTemplate string `yaml:"channel_name_template"`
GuildNameTemplate string `yaml:"guild_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"` PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
PortalMessageBuffer int `yaml:"portal_message_buffer"` PortalMessageBuffer int `yaml:"portal_message_buffer"`
@@ -50,7 +51,14 @@ type BridgeConfig struct {
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"` DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"` DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
FederateRooms bool `yaml:"federate_rooms"` FederateRooms bool `yaml:"federate_rooms"`
AnimatedSticker struct { PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
CacheMedia string `yaml:"cache_media"`
MediaPatterns MediaPatterns `yaml:"media_patterns"`
AnimatedSticker struct {
Target string `yaml:"target"` Target string `yaml:"target"`
Args struct { Args struct {
Width int `yaml:"width"` Width int `yaml:"width"`
@@ -66,6 +74,14 @@ type BridgeConfig struct {
CommandPrefix string `yaml:"command_prefix"` CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"` 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"` Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct { Provisioning struct {
@@ -81,6 +97,118 @@ type BridgeConfig struct {
guildNameTemplate *template.Template `yaml:"-"` 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"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool { func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo return bc.ResendBridgeInfo
} }
@@ -161,9 +289,14 @@ func (bc BridgeConfig) FormatUsername(userID string) string {
return buffer.String() return buffer.String()
} }
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) string { type DisplaynameParams struct {
*discordgo.User
Webhook bool
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook bool) string {
var buffer strings.Builder var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, user) _ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{user, webhook})
return buffer.String() return buffer.String()
} }

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@@ -25,11 +25,21 @@ import (
func DoUpgrade(helper *up.Helper) { func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(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", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template") helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channel_name_template") helper.Copy(up.Str, "bridge", "channel_name_template")
helper.Copy(up.Str, "bridge", "guild_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", "startup_private_channel_create_limit")
helper.Copy(up.Int, "bridge", "portal_message_buffer") helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "delivery_receipts") helper.Copy(up.Bool, "bridge", "delivery_receipts")
@@ -45,6 +55,15 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete") helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave") helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
helper.Copy(up.Bool, "bridge", "federate_rooms") 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.Str, "bridge", "animated_sticker", "target")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width") 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", "height")
@@ -57,17 +76,32 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected") helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected") 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.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "backfill", "enabled")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
helper.Copy(up.Bool, "bridge", "encryption", "allow") helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default") helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require") helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice") helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing") 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.Str, "bridge", "encryption", "verification_levels", "receive") 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", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share") helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom") helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") 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") helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {

View File

@@ -26,7 +26,7 @@ func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken strin
homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver] homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
if !found { if !found {
if homeserver == br.AS.HomeserverDomain { if homeserver == br.AS.HomeserverDomain {
homeserverURL = br.AS.HomeserverURL homeserverURL = ""
} else if br.Config.Bridge.DoublePuppetAllowDiscovery { } else if br.Config.Bridge.DoublePuppetAllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(homeserver) resp, err := mautrix.DiscoverClientAPI(homeserver)
if err != nil { if err != nil {
@@ -40,16 +40,7 @@ func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken strin
} }
} }
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken) return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL)
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() { func (puppet *Puppet) clearCustomMXID() {
@@ -111,13 +102,17 @@ func (puppet *Puppet) tryRelogin(cause error, action string) bool {
if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) { if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
return false return false
} }
puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action) log := puppet.log.With().
AnErr("cause_error", cause).
Str("while_action", action).
Logger()
log.Debug().Msg("Trying to relogin")
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID) accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
if err != nil { if err != nil {
puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err) log.Error().Err(err).Msg("Failed to relogin")
return false return false
} }
puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action) log.Info().Msg("Successfully relogined")
puppet.AccessToken = accessToken puppet.AccessToken = accessToken
puppet.Update() puppet.Update()
return true return true
@@ -125,7 +120,7 @@ func (puppet *Puppet) tryRelogin(cause error, action string) bool {
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) { func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse() _, homeserver, _ := mxid.Parse()
puppet.log.Debugfln("Logging into %s with shared secret", mxid) puppet.log.Debug().Str("user_id", mxid.String()).Msg("Logging into double puppet target with shared secret")
loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver] loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]
client, err := puppet.bridge.newDoublePuppetClient(mxid, "") client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
if err != nil { if err != nil {

View File

@@ -68,9 +68,10 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
return db return db
} }
func strPtr(val string) *string { func strPtr[T ~string](val T) *string {
if val == "" { if val == "" {
return nil return nil
} }
return &val valStr := string(val)
return &valStr
} }

View File

@@ -39,8 +39,8 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted)) return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
} }
func (fq *FileQuery) GetByMXC(mxc id.ContentURI) *File { func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1" query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1"
return fq.New().Scan(fq.db.QueryRow(query, mxc.String())) return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
} }
@@ -79,7 +79,7 @@ func (f *File) Scan(row dbutil.Scannable) *File {
} }
f.ID = fileID.String f.ID = fileID.String
f.EmojiName = emojiName.String f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp) f.Timestamp = time.UnixMilli(timestamp).UTC()
f.Width = int(width.Int32) f.Width = int(width.Int32)
f.Height = int(height.Int32) f.Height = int(height.Int32)
f.MXC, err = id.ParseContentURI(mxc) f.MXC, err = id.ParseContentURI(mxc)

View File

@@ -3,6 +3,8 @@ package database
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -10,13 +12,76 @@ import (
"maunium.net/go/mautrix/util/dbutil" "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 { type GuildQuery struct {
db *Database db *Database
log log.Logger log log.Logger
} }
const ( 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 { func (gq *GuildQuery) New() *Guild {
@@ -67,13 +132,13 @@ type Guild struct {
AvatarURL id.ContentURI AvatarURL id.ContentURI
AvatarSet bool AvatarSet bool
AutoBridgeChannels bool BridgingMode GuildBridgingMode
} }
func (g *Guild) Scan(row dbutil.Scannable) *Guild { func (g *Guild) Scan(row dbutil.Scannable) *Guild {
var mxid sql.NullString var mxid sql.NullString
var avatarURL string 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 err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
g.log.Errorln("Database scan failed:", err) g.log.Errorln("Database scan failed:", err)
@@ -82,6 +147,9 @@ func (g *Guild) Scan(row dbutil.Scannable) *Guild {
return nil 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.MXID = id.RoomID(mxid.String)
g.AvatarURL, _ = id.ParseContentURI(avatarURL) g.AvatarURL, _ = id.ParseContentURI(avatarURL)
return g return g
@@ -96,10 +164,10 @@ func (g *Guild) mxidPtr() *id.RoomID {
func (g *Guild) Insert() { func (g *Guild) Insert() {
query := ` 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) 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 { if err != nil {
g.log.Warnfln("Failed to insert %s: %v", g.ID, err) g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
panic(err) panic(err)
@@ -108,10 +176,10 @@ func (g *Guild) Insert() {
func (g *Guild) Update() { func (g *Guild) Update() {
query := ` 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 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 { if err != nil {
g.log.Warnfln("Failed to update %s: %v", g.ID, err) g.log.Warnfln("Failed to update %s: %v", g.ID, err)
panic(err) panic(err)

View File

@@ -19,7 +19,7 @@ type MessageQuery struct {
} }
const ( 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 { func (mq *MessageQuery) New() *Message {
@@ -46,17 +46,17 @@ func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
} }
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*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)) return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
} }
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message { 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)) return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
} }
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message { 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)) return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
} }
@@ -66,10 +66,15 @@ func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time
} }
func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message { 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)) 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) { func (mq *MessageQuery) DeleteAll(key PortalKey) {
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2" query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver) _, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
@@ -90,19 +95,51 @@ func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
return mq.New().Scan(row) 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 { type Message struct {
db *Database db *Database
log log.Logger log log.Logger
DiscordID string DiscordID string
AttachmentID string AttachmentID string
EditIndex int Channel PortalKey
Channel PortalKey SenderID string
SenderID string Timestamp time.Time
Timestamp time.Time EditTimestamp time.Time
ThreadID string ThreadID string
MXID id.EventID MXID id.EventID
SenderMXID id.UserID
} }
func (m *Message) DiscordProtoChannelID() string { func (m *Message) DiscordProtoChannelID() string {
@@ -114,9 +151,9 @@ func (m *Message) DiscordProtoChannelID() string {
} }
func (m *Message) Scan(row dbutil.Scannable) *Message { 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 err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
m.log.Errorln("Database scan failed:", err) m.log.Errorln("Database scan failed:", err)
@@ -127,7 +164,10 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
} }
if ts != 0 { 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 return m
@@ -135,39 +175,47 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
const messageInsertQuery = ` const messageInsertQuery = `
INSERT INTO message ( 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 { type MessagePart struct {
AttachmentID string AttachmentID string
MXID id.EventID 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 { if len(msgs) == 0 {
return 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 { if m.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?") valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
} }
params := make([]interface{}, 7+len(msgs)*2) params := make([]interface{}, 8+len(msgs)*2)
placeholders := make([]string, len(msgs)) placeholders := make([]string, len(msgs))
params[0] = m.DiscordID params[0] = m.DiscordID
params[1] = m.EditIndex params[1] = m.Channel.ChannelID
params[2] = m.Channel.ChannelID params[2] = m.Channel.Receiver
params[3] = m.Channel.Receiver params[3] = m.SenderID
params[4] = m.SenderID params[4] = m.Timestamp.UnixMilli()
params[5] = m.Timestamp.UnixMilli() params[5] = m.editTimestampVal()
params[6] = m.ThreadID params[6] = m.ThreadID
params[7] = m.SenderMXID.String()
for i, msg := range msgs { for i, msg := range msgs {
params[7+i*2] = msg.AttachmentID params[8+i*2] = msg.AttachmentID
params[7+i*2+1] = msg.MXID params[8+i*2+1] = msg.MXID
placeholders[i] = fmt.Sprintf(valueStringFormat, 7+i*2+1, 7+i*2+2) placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
} }
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...) _, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil { if err != nil {
@@ -178,8 +226,8 @@ func (m *Message) MassInsert(msgs []MessagePart) {
func (m *Message) Insert() { func (m *Message) Insert() {
_, err := m.db.Exec(messageInsertQuery, _, err := m.db.Exec(messageInsertQuery,
m.DiscordID, m.AttachmentID, m.EditIndex, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID, m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
m.Timestamp.UnixMilli(), m.ThreadID, m.MXID) m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
if err != nil { if err != nil {
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err) m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
@@ -187,6 +235,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() { 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" 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) _, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)

View File

@@ -15,8 +15,8 @@ import (
const ( const (
portalSelect = ` portalSelect = `
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, 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, plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal FROM portal
` `
) )
@@ -68,6 +68,10 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
return pq.get(portalSelect+" WHERE mxid=$1", mxid) 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 { func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM) return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
} }
@@ -109,28 +113,32 @@ type Portal struct {
MXID id.RoomID MXID id.RoomID
PlainName string PlainName string
Name string Name string
NameSet bool NameSet bool
Topic string FriendNick bool
TopicSet bool Topic string
Avatar string TopicSet bool
AvatarURL id.ContentURI Avatar string
AvatarSet bool AvatarURL id.ContentURI
Encrypted bool AvatarSet bool
InSpace id.RoomID Encrypted bool
InSpace id.RoomID
FirstEventID id.EventID FirstEventID id.EventID
RelayWebhookID string
RelayWebhookSecret string
} }
func (p *Portal) Scan(row dbutil.Scannable) *Portal { 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 chanType int32
var avatarURL string var avatarURL string
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID, 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, &mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID) &p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil { if err != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
@@ -148,6 +156,8 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
p.Type = discordgo.ChannelType(chanType) p.Type = discordgo.ChannelType(chanType)
p.FirstEventID = id.EventID(firstEventID.String) p.FirstEventID = id.EventID(firstEventID.String)
p.AvatarURL, _ = id.ParseContentURI(avatarURL) p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.RelayWebhookID = relayWebhookID.String
p.RelayWebhookSecret = relayWebhookSecret.String
return p return p
} }
@@ -155,14 +165,14 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
func (p *Portal) Insert() { func (p *Portal) Insert() {
query := ` query := `
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, 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, plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id) 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) 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, _, 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)), 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.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()) p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
if err != nil { if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.Key, err) p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
@@ -174,14 +184,16 @@ func (p *Portal) Update() {
query := ` query := `
UPDATE portal UPDATE portal
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5, 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, plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
encrypted=$14, in_space=$15, first_event_id=$16 avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
WHERE dcid=$17 AND receiver=$18 relay_webhook_id=$18, relay_webhook_secret=$19
WHERE dcid=$20 AND receiver=$21
` `
_, err := p.db.Exec(query, _, err := p.db.Exec(query,
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), 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.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), 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) p.Key.ChannelID, p.Key.Receiver)
if err != nil { if err != nil {

View File

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

View File

@@ -1,4 +1,4 @@
-- v0 -> v13: Latest revision -- v0 -> v22 (compatible with v19+): Latest revision
CREATE TABLE guild ( CREATE TABLE guild (
dcid TEXT PRIMARY KEY, dcid TEXT PRIMARY KEY,
@@ -10,7 +10,7 @@ CREATE TABLE guild (
avatar_url TEXT NOT NULL, avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL, avatar_set BOOLEAN NOT NULL,
auto_bridge_channels BOOLEAN NOT NULL bridging_mode INTEGER NOT NULL
); );
CREATE TABLE portal ( CREATE TABLE portal (
@@ -29,6 +29,7 @@ CREATE TABLE portal (
plain_name TEXT NOT NULL, plain_name TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
name_set BOOLEAN NOT NULL, name_set BOOLEAN NOT NULL,
friend_nick BOOLEAN NOT NULL,
topic TEXT NOT NULL, topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL, topic_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL, avatar TEXT NOT NULL,
@@ -39,6 +40,9 @@ CREATE TABLE portal (
first_event_id TEXT NOT NULL, first_event_id TEXT NOT NULL,
relay_webhook_id TEXT,
relay_webhook_secret TEXT,
PRIMARY KEY (dcid, receiver), 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_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 CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
@@ -59,11 +63,19 @@ CREATE TABLE thread (
CREATE TABLE puppet ( CREATE TABLE puppet (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
name_set BOOLEAN NOT NULL, name_set BOOLEAN NOT NULL DEFAULT false,
avatar TEXT NOT NULL, avatar TEXT NOT NULL,
avatar_url 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,
custom_mxid TEXT, custom_mxid TEXT,
access_token TEXT, access_token TEXT,
@@ -94,18 +106,19 @@ CREATE TABLE user_portal (
); );
CREATE TABLE message ( CREATE TABLE message (
dcid TEXT, dcid TEXT,
dc_attachment_id TEXT, dc_attachment_id TEXT,
dc_edit_index INTEGER, dc_chan_id TEXT,
dc_chan_id TEXT, dc_chan_receiver TEXT,
dc_chan_receiver TEXT, dc_sender TEXT NOT NULL,
dc_sender TEXT NOT NULL, timestamp BIGINT NOT NULL,
timestamp BIGINT NOT NULL, dc_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL, dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE, 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 CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
); );
@@ -117,13 +130,12 @@ CREATE TABLE reaction (
dc_emoji_name TEXT, dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL, dc_thread_id TEXT NOT NULL,
dc_first_attachment_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, mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), 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 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 ( CREATE TABLE role (
@@ -148,7 +160,7 @@ CREATE TABLE role (
CREATE TABLE discord_file ( CREATE TABLE discord_file (
url TEXT, url TEXT,
encrypted BOOLEAN, encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE, mxc TEXT NOT NULL,
id TEXT, id TEXT,
emoji_name TEXT, emoji_name TEXT,
@@ -162,3 +174,5 @@ CREATE TABLE discord_file (
PRIMARY KEY (url, encrypted) PRIMARY KEY (url, encrypted)
); );
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

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

@@ -29,7 +29,7 @@ func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
l.Errorln("Error scanning user portal:", err) l.Errorln("Error scanning user portal:", err)
panic(err) panic(err)
} }
up.Timestamp = time.UnixMilli(ts) up.Timestamp = time.UnixMilli(ts).UTC()
return &up return &up
} }

View File

@@ -18,30 +18,35 @@ func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
return false 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) member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
if errors.Is(err, discordgo.ErrStateNotFound) { 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) member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
if err != nil { 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 { } else {
err = user.Session.State.MemberAdd(member) err = user.Session.State.MemberAdd(member)
if err != nil { 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 { } 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) err = user.Session.State.ChannelAdd(channel)
if err != nil { 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) perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
if err != nil { 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 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 return perms&discordgo.PermissionViewChannel > 0
} }

View File

@@ -2,6 +2,9 @@
homeserver: homeserver:
# The address that this appservice can use to connect to the homeserver. # The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com 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). # The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com domain: example.com
@@ -17,6 +20,13 @@ homeserver:
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false 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. # Application service host/registration related details.
# Changing these values requires regeneration of the registration. # Changing these values requires regeneration of the registration.
appservice: appservice:
@@ -77,11 +87,13 @@ bridge:
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled. # Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# Available variables: # Available variables:
# .ID - Internal user ID # .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 # .Discriminator - The 4 numbers after the name on Discord
# .Bot - Whether the user is a bot # .Bot - Whether the user is a bot
# .System - Whether the user is an official system user # .System - Whether the user is an official system user
displayname_template: '{{.Username}}#{{.Discriminator}}{{if .Bot}} (bot){{end}}' # .Webhook - Whether the user is a webhook
displayname_template: '{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}'
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4). # Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
# Available variables: # Available variables:
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs. # .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
@@ -94,9 +106,11 @@ bridge:
# Available variables: # Available variables:
# .Name - Guild name # .Name - Guild name
guild_name_template: '{{.Name}}' guild_name_template: '{{.Name}}'
# Should the bridge explicitly set the avatar and room name for DM portal rooms? # Whether to explicitly set the avatar and room name for private chat portal rooms.
# This is implicitly enabled in encrypted rooms. # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
private_chat_portal_meta: false # 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 portal_message_buffer: 128
@@ -140,6 +154,33 @@ bridge:
# Whether or not created rooms should have federation enabled. # Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated. # If false, created portal rooms will never be federated.
federate_rooms: true 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. # Settings for converting animated stickers.
animated_sticker: animated_sticker:
# Format to which animated stickers should be converted. # Format to which animated stickers should be converted.
@@ -181,6 +222,28 @@ bridge:
# Optional extra text sent when joining a management room. # Optional extra text sent when joining a management room.
additional_help: "" additional_help: ""
# Settings for backfilling messages.
backfill:
# Limits for forward backfilling.
forward_limits:
# Initial backfill (when creating portal). 0 means backfill is disabled.
# A special unlimited value is not supported, you must set a limit. Initial backfill will
# fetch all messages first before backfilling anything, so high limits can take a lot of time.
initial:
dm: 0
channel: 0
# Missed message backfill (on startup).
# 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
# When using unlimited backfill (-1), messages are backfilled as they are fetched.
# With limits, all messages up to the limit are fetched first and backfilled afterwards.
missed:
dm: 0
channel: 0
# Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
# This can be used as a rough heuristic to disable backfilling in channels that are too active.
# Currently only applies to missed message backfill.
max_guild_members: -1
# End-to-bridge encryption support options. # End-to-bridge encryption support options.
# #
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
@@ -197,6 +260,25 @@ bridge:
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. # 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. # You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false 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
# What level of device verification should be required from users? # What level of device verification should be required from users?
# #
# Valid levels: # Valid levels:
@@ -232,6 +314,10 @@ bridge:
# default. # default.
messages: 100 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 # Settings for provisioning API
provisioning: provisioning:
# Prefix for the provisioning API paths. # Prefix for the provisioning API paths.
@@ -254,12 +340,15 @@ bridge:
"example.com": user "example.com": user
"@admin:example.com": admin "@admin:example.com": admin
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging: logging:
directory: ./logs min_level: debug
file_name_format: '{{.Date}}-{{.Index}}.log' writers:
file_date_format: "2006-01-02" - type: stdout
file_mode: 384 format: pretty-colored
timestamp_format: Jan _2, 2006 15:04:05 - type: file
print_level: debug format: json
print_json: false filename: ./logs/mautrix-discord.log
file_json: false max_size: 100
max_backups: 10
compress: true

View File

@@ -21,10 +21,12 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@@ -57,7 +59,7 @@ func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
var removeFeaturesExceptLinks = []any{ var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
parser.NewSetextHeadingParser(), parser.NewATXHeadingParser(), parser.NewThematicBreakParser(), parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(), parser.NewCodeBlockParser(),
} }
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser()) var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
@@ -90,23 +92,32 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
return format.UnwrapSingleParagraph(buf.String()) return format.UnwrapSingleParagraph(buf.String())
} }
const formatterContextUserKey = "fi.mau.discord.user"
const formatterContextPortalKey = "fi.mau.discord.portal" 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 { if len(mxid) == 0 {
return displayname return displayname
} }
user := ctx.ReturnData[formatterContextUserKey].(*User)
if mxid[0] == '#' { if mxid[0] == '#' {
alias, err := user.bridge.Bot.ResolveAlias(id.RoomAlias(mxid)) alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
if err != nil { if err != nil {
return displayname return displayname
} }
mxid = alias.RoomID.String() mxid = alias.RoomID.String()
} }
if mxid[0] == '!' { if mxid[0] == '!' {
portal := user.bridge.GetPortalByMXID(id.RoomID(mxid)) portal := br.GetPortalByMXID(id.RoomID(mxid))
if portal != nil { if portal != nil {
if eventID == "" { if eventID == "" {
//currentPortal := ctx[formatterContextPortalKey].(*Portal) //currentPortal := ctx[formatterContextPortalKey].(*Portal)
@@ -117,7 +128,7 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
//} else { //} else {
// // TODO is mentioning private channels possible at all? // // 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 guildID := portal.GuildID
if guildID == "" { if guildID == "" {
guildID = "@me" guildID = "@me"
@@ -126,12 +137,19 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
} }
} }
} else if mxid[0] == '@' { } 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 { if ok {
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
return fmt.Sprintf("<@%s>", parsedID) return fmt.Sprintf("<@%s>", parsedID)
} }
mentionedUser := user.bridge.GetUserByMXID(id.UserID(mxid)) mentionedUser := br.GetUserByMXID(id.UserID(mxid))
if mentionedUser != nil && mentionedUser.DiscordID != "" { if mentionedUser != nil && mentionedUser.DiscordID != "" {
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID) return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
} }
} }
@@ -152,6 +170,7 @@ var discordMarkdownEscaper = strings.NewReplacer(
"`", "\\`", "`", "\\`",
`|`, `\|`, `|`, `\|`,
`<`, `\<`, `<`, `\<`,
`#`, `\#`,
) )
func escapeDiscordMarkdown(s string) string { func escapeDiscordMarkdown(s string) string {
@@ -197,17 +216,21 @@ var matrixHTMLParser = &format.HTMLParser{
}, },
} }
func init() { func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
matrixHTMLParser.PillConverter = pillConverter allowedMentions := &discordgo.MessageAllowedMentions{
} Parse: []discordgo.AllowedMentionType{},
Users: []string{},
func (portal *Portal) parseMatrixHTML(user *User, content *event.MessageEventContent) string { RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext() ctx := format.NewContext()
ctx.ReturnData[formatterContextUserKey] = user
ctx.ReturnData[formatterContextPortalKey] = portal 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 { } else {
return variationselector.Remove(escapeDiscordMarkdown(content.Body)) return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
} }
} }

View File

@@ -30,7 +30,6 @@ import (
"github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"maunium.net/go/mautrix"
"go.mau.fi/mautrix-discord/database" "go.mau.fi/mautrix-discord/database"
) )
@@ -193,7 +192,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
case strings.HasPrefix(tagName, ":"): case strings.HasPrefix(tagName, ":"):
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag} return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
case strings.HasPrefix(tagName, "a:"): case strings.HasPrefix(tagName, "a:"):
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag} return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
default: default:
return nil return nil
} }
@@ -263,8 +262,11 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
} }
switch node := n.(type) { switch node := n.(type) {
case *astDiscordUserMention: case *astDiscordUserMention:
puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)) if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name) _, _ = 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 return
case *astDiscordRoleMention: case *astDiscordRoleMention:
role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10)) role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
@@ -279,7 +281,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
}) })
if portal != nil { if portal != nil {
if portal.MXID != "" { 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 { } else {
_, _ = w.WriteString(portal.Name) _, _ = w.WriteString(portal.Name)
} }
@@ -288,7 +290,11 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
case *astDiscordCustomEmoji: case *astDiscordCustomEmoji:
reactionMXC := node.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() { 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 return
} }
case *astDiscordTimestamp: case *astDiscordTimestamp:
@@ -303,9 +309,9 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700" const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
fullRFC := ts.Format(fullDatetimeFormat) fullRFC := ts.Format(fullDatetimeFormat)
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format()) 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 { if ok {
_, _ = w.WriteString(stringifiable.String()) _, _ = w.WriteString(stringifiable.String())
} else { } else {

28
go.mod
View File

@@ -1,37 +1,41 @@
module go.mau.fi/mautrix-discord module go.mau.fi/mautrix-discord
go 1.18 go 1.19
require ( require (
github.com/bwmarrin/discordgo v0.27.0 github.com/bwmarrin/discordgo v0.27.0
github.com/gabriel-vasile/mimetype v1.4.1 github.com/gabriel-vasile/mimetype v1.4.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.7 github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.17
github.com/rs/zerolog v1.29.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.5.4 github.com/yuin/goldmark v1.5.4
maunium.net/go/maulogger/v2 v2.3.2 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
maunium.net/go/mautrix v0.14.0 maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.15.3
) )
require ( require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.29.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.6.0 // indirect go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/net v0.6.0 // indirect golang.org/x/crypto v0.10.0 // indirect
golang.org/x/sys v0.5.0 // indirect golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect
) )
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230512133900-5b12693331c0

72
go.sum
View File

@@ -1,43 +1,37 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d h1:PndQKe7wiuQuVIWepQksfaRWUxZcoh6GWLXfWbdAN3g= github.com/beeper/discordgo v0.0.0-20230512133900-5b12693331c0 h1:ECBEbC4ruaXzcVJJ4UurkGpT/Xlm9ZnwsHiHn9gjPZw=
github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/beeper/discordgo v0.0.0-20230512133900-5b12693331c0/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= 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/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 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 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 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 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.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 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/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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -49,33 +43,27 @@ 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/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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-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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.14.0 h1:kdQ06HzmMaLGZqmSh/ykDhp5C2gIREQL9TS8hY+FqLs= maunium.net/go/mautrix v0.15.3 h1:C9BHSUM0gYbuZmAtopuLjIcH5XHLb/ZjTEz7nN+0jN0=
maunium.net/go/mautrix v0.14.0/go.mod h1:voJPvnTkA60rxBl6mvdPxcP7y7iY5w3d/K55IoX+2oY= maunium.net/go/mautrix v0.15.3/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=

View File

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

17
main.go
View File

@@ -92,14 +92,17 @@ func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge) br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands() br.RegisterCommands()
matrixHTMLParser.PillConverter = br.pillConverter
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) 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() { func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" { if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br) br.provisioning = newProvisioningAPI(br)
} }
br.WaitWebsocketConnected()
go br.startUsers() go br.startUsers()
} }
@@ -170,11 +173,13 @@ func main() {
attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](), attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](),
} }
br.Bridge = bridge.Bridge{ br.Bridge = bridge.Bridge{
Name: "mautrix-discord", Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord", URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.", Description: "A Matrix-Discord puppeting bridge.",
Version: "0.1.1", Version: "0.5.0",
ProtocolName: "Discord", ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp", CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",

1615
portal.go

File diff suppressed because it is too large Load Diff

716
portal_convert.go Normal file
View File

@@ -0,0 +1,716 @@
// 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.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 {
proxyURL = embed.Thumbnail.ProxyURL
}
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)
}
}
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
if msg.Member.Avatar != "" {
var err error
avatarURL, 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")
}
}
var discordAvararURL string
if msg.Member.Avatar != "" {
msg.Member.User = msg.Author
discordAvararURL = msg.Member.AvatarURL("")
}
part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
"nick": msg.Member.Nick,
"avatar_id": msg.Member.Avatar,
"avatar_url": discordAvararURL,
"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 {
return len(msg.Embeds) == 1 && msg.Embeds[0].URL == msg.Content &&
((msg.Embeds[0].Type == discordgo.EmbedTypeGifv && msg.Embeds[0].Video != nil) ||
(msg.Embeds[0].Type == discordgo.EmbedTypeImage && msg.Embeds[0].Image == nil && msg.Embeds[0].Thumbnail != nil))
}
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, "")
}
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, "")
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
}
if msg.Content != "" && !isPlainGifMessage(msg) {
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false))
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
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 != "" && 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/bridge/bridgeconfig"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth" "go.mau.fi/mautrix-discord/remoteauth"
) )
@@ -226,7 +227,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
} else { } else {
msg = "User wasn't logged in." msg = "User wasn't logged in."
} }
user.Logout() user.Logout(false)
jsonResponse(w, http.StatusOK, Response{true, msg}) 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) 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 { if err = user.Login(discordUser.Token); err != nil {
log.Errorln("Failed to connect after logging in:", err) log.Errorln("Failed to connect after logging in:", err)
@@ -429,11 +428,12 @@ func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
} }
type guildEntry struct { type guildEntry struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL id.ContentURI `json:"avatar_url"` AvatarURL id.ContentURI `json:"avatar_url"`
MXID id.RoomID `json:"mxid"` MXID id.RoomID `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"` AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
} }
type respGuildsList struct { type respGuildsList struct {
@@ -451,11 +451,12 @@ func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
continue continue
} }
resp.Guilds = append(resp.Guilds, guildEntry{ resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID, ID: guild.ID,
Name: guild.PlainName, Name: guild.PlainName,
AvatarURL: guild.AvatarURL, AvatarURL: guild.AvatarURL,
MXID: guild.MXID, 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", Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode, ErrCode: mautrix.MNotFound.ErrCode,
}) })
} else if !guild.AutoBridgeChannels && guild.MXID == "" { } else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
jsonResponse(w, http.StatusNotFound, Error{ jsonResponse(w, http.StatusNotFound, Error{
Error: "That guild is not bridged", Error: "That guild is not bridged",
ErrCode: ErrCodeGuildNotBridged, ErrCode: ErrCodeGuildNotBridged,

125
puppet.go
View File

@@ -3,14 +3,15 @@ package main
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"strings"
"sync" "sync"
log "maunium.net/go/maulogger/v2"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database" "go.mau.fi/mautrix-discord/database"
@@ -20,7 +21,7 @@ type Puppet struct {
*database.Puppet *database.Puppet
bridge *DiscordBridge bridge *DiscordBridge
log log.Logger log zerolog.Logger
MXID id.UserID MXID id.UserID
@@ -43,7 +44,7 @@ func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{ return &Puppet{
Puppet: dbPuppet, Puppet: dbPuppet,
bridge: br, 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), 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 { func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info) newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook)
if puppet.Name == newName && puppet.NameSet { if puppet.Name == newName && puppet.NameSet {
return false return false
} }
@@ -202,10 +203,10 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
puppet.NameSet = false puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(newName) err := puppet.DefaultIntent().SetDisplayName(newName)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to update displayname:", err) puppet.log.Warn().Err(err).Msg("Failed to update displayname")
} else { } else {
go puppet.updatePortalMeta(func(portal *Portal) { go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateNameDirect(puppet.Name) { if portal.UpdateNameDirect(puppet.Name, false) {
portal.Update() portal.Update()
portal.UpdateBridgeInfo() portal.UpdateBridgeInfo()
} }
@@ -215,20 +216,53 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
return true return true
} }
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, 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"
}
}
url := br.Config.Bridge.MediaPatterns.Avatar(userID, avatarID, ext)
if !url.IsEmpty() {
return url, nil
}
copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
})
if err != nil {
return url, err
}
return copied.MXC, nil
}
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool { 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 return false
} }
avatarChanged := info.Avatar != puppet.Avatar avatarChanged := avatarID != puppet.Avatar
puppet.Avatar = info.Avatar puppet.Avatar = avatarID
puppet.AvatarSet = false puppet.AvatarSet = false
puppet.AvatarURL = id.ContentURI{} 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) { 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 { 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 return true
} }
puppet.AvatarURL = url puppet.AvatarURL = url
@@ -236,7 +270,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to update avatar:", err) puppet.log.Warn().Err(err).Msg("Failed to update avatar")
} else { } else {
go puppet.updatePortalMeta(func(portal *Portal) { go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateAvatarFromPuppet(puppet) { if portal.UpdateAvatarFromPuppet(puppet) {
@@ -249,7 +283,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
return true return true
} }
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) { func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, webhookID string) {
puppet.syncLock.Lock() puppet.syncLock.Lock()
defer puppet.syncLock.Unlock() defer puppet.syncLock.Unlock()
@@ -258,23 +292,78 @@ func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
return return
} }
var err error 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) info, err = source.Session.User(puppet.ID)
if err != nil { 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 return
} }
} }
err := puppet.DefaultIntent().EnsureRegistered() err := puppet.DefaultIntent().EnsureRegistered()
if err != nil { if err != nil {
puppet.log.Errorln("Failed to ensure registered:", err) puppet.log.Error().Err(err).Msg("Failed to ensure registered")
} }
changed := false changed := false
if webhookID != "" && webhookID == info.ID && !puppet.IsWebhook {
puppet.IsWebhook = true
changed = true
}
changed = puppet.UpdateContactInfo(info) || changed
changed = puppet.UpdateName(info) || changed changed = puppet.UpdateName(info) || changed
changed = puppet.UpdateAvatar(info) || changed changed = puppet.UpdateAvatar(info) || changed
if changed { if changed {
puppet.Update() 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.Config.Homeserver.Software != bridgeconfig.SoftwareHungry || puppet.ContactInfoSet {
return
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": []string{
fmt.Sprintf("discord:%s#%s", puppet.Username, puppet.Discriminator),
},
"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

@@ -78,10 +78,16 @@ func (thread *Thread) Join(user *User) {
if user.IsInPortal(thread.ID) { if user.IsInPortal(thread.ID) {
return return
} }
user.log.Debugfln("Joining thread %s@%s", thread.ID, thread.ParentID) log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
err := user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu) log.Debug().Msg("Joining thread")
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 { 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 { } else {
user.MarkInPortal(database.UserPortal{ user.MarkInPortal(database.UserPortal{
DiscordID: thread.ID, DiscordID: thread.ID,

707
user.go

File diff suppressed because it is too large Load Diff