101 Commits

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

View File

@@ -1,3 +1,67 @@
# v0.6.1 (2023-08-16)
* Bumped minimum Go version to 1.20.
* Fixed all logged-in users being invited to existing portal rooms even if they
don't have permission to view the channel on Discord.
* Fixed gif links not being treated as embeds if the canonical URL is different
than the URL in the message body.
# v0.6.0 (2023-07-16)
* Added initial support for backfilling threads.
* Exposed `Application` flag to displayname template.
* Changed `m.emote` bridging to use italics on Discord.
* Updated Docker image to Alpine 3.18.
* Added limit to parallel media transfers to avoid high memory usage if lots
of messages are received at the same time.
* Fixed guilds being unbridged if Discord has server issues and temporarily
marks a guild as unavailable.
* Fixed using `guilds bridge` command without `--entire` flag.
* Fixed panic if lottieconverter isn't installed.
* Fixed relay webhook secret being leaked in network error messages.
# v0.5.0 (2023-06-16)
* Added support for intentional mentions in Matrix (MSC3952).
* Added `GlobalName` variable to displayname templates and updated the default
template to prefer it over usernames.
* Added `Webhook` variable to displayname templates to allow determining if a
ghost user is a webhook.
* Added guild profiles and webhook profiles as a custom field in Matrix
message events.
* Added support for bulk message delete from Discord.
* Added support for appservice websockets.
* Enabled parsing headers (`#`) in Discord markdown.
* Messages that consist of a single image link are now bridged as images to
closer match Discord.
* Stopped bridging incoming typing notifications from users who are logged into
the bridge to prevent echoes.
# v0.4.0 (2023-05-16)
* Added bridging of friend nicks into DM room names.
* Added option to bypass homeserver for Discord media.
See [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for more info.
* Added conversion of replies to embeds when sending messages via webhook.
* Added option to disable caching reuploaded media. This may be necessary when
using a media repo that doesn't create a unique mxc URI for each upload.
* Added option to disable uploading files directly to the Discord CDN
(and send as form parts in the message send request instead).
* Improved formatting of error messages returned by Discord.
* Enabled discordgo info logs by default.
* Fixed limited backfill always stopping after 50 messages
(thanks to [@odrling] in [#81]).
* Fixed startup sync to sync most recent private channels first.
* Fixed syncing group DM participants when they change.
* Fixed bridging animated emojis in messages.
* Stopped handling all message edits from relay webhook to prevent incorrect
edits.
* Possibly fixed inviting to portal rooms when multiple Matrix users use the
bridge.
[@odrling]: https://github.com/odrling
[#81]: https://github.com/mautrix/discord/pull/81
# v0.3.0 (2023-04-16)
* Added support for backfilling on room creation and missed messages on startup.

View File

@@ -1,6 +1,6 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.17 AS builder
FROM golang:1-alpine3.18 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -8,7 +8,7 @@ COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
FROM alpine:3.17
FROM alpine:3.18
ENV UID=1337 \
GID=1337

View File

@@ -1,6 +1,6 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM alpine:3.17
FROM alpine:3.18
ENV UID=1337 \
GID=1337

View File

@@ -1,6 +1,6 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.17 AS builder
FROM golang:1-alpine3.18 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \
zlib libpng giflib libstdc++ libgcc

View File

@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
@@ -12,23 +13,23 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/ffmpeg"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(url string) ([]byte, error) {
func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -44,9 +45,24 @@ func downloadDiscordAttachment(url string) ([]byte, error) {
defer resp.Body.Close()
if resp.StatusCode > 300 {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, data)
return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
}
if resp.Header.Get("Content-Length") != "" {
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse content length: %w", err)
} else if length > maxSize {
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
}
return io.ReadAll(resp.Body)
} else {
var mbe *http.MaxBytesError
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
if err != nil && errors.As(err, &mbe) {
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
}
return data, err
}
return io.ReadAll(resp.Body)
}
func uploadDiscordAttachment(url string, data []byte) error {
@@ -99,7 +115,7 @@ func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.Messa
return data, nil
}
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta) (*database.File, error) {
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now()
dbFile.URL = url
@@ -128,17 +144,19 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
ContentType: uploadMime,
}
if br.Config.Homeserver.AsyncMedia {
resp, err := intent.UnstableCreateMXC()
resp, err := intent.CreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.UnstableMXC = resp.ContentURI
req.UploadURL = resp.UploadURL
req.MXC = resp.ContentURI
req.UnstableUploadURL = resp.UnstableUploadURL
semaWg.Add(1)
go func() {
defer semaWg.Done()
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.UnstableMXC, err)
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
dbFile.Delete()
}
}()
@@ -246,11 +264,11 @@ 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) {
isCacheable := !encrypt
isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &util.ReturnableOnce[*database.File]{})
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{})
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
if isCacheable {
onceDBFile = br.DB.File.Get(url, encrypt)
@@ -259,8 +277,25 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
}
}
const attachmentSizeVal = 1
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
cancel()
if onceErr != nil {
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
onceErr = fmt.Errorf("reuploading timed out")
return
}
var semaWg sync.WaitGroup
semaWg.Add(1)
defer semaWg.Done()
go func() {
semaWg.Wait()
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
}()
var data []byte
data, onceErr = downloadDiscordAttachment(url)
data, onceErr = downloadDiscordAttachment(url, br.MediaConfig.UploadSize)
if onceErr != nil {
return
}
@@ -273,7 +308,7 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
}
}
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta)
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg)
if onceErr != nil {
return
}
@@ -288,13 +323,19 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
var url, mimeType string
var url, mimeType, ext string
if animated {
url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif"
ext = "gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
ext = "png"
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Emoji(emojiID, ext)
if !mxc.IsEmpty() {
return mxc
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,
@@ -302,7 +343,7 @@ func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool
EmojiName: name,
})
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 dbFile.MXC

View File

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

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
@@ -9,15 +10,18 @@ import (
"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()
func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) {
log := portal.log
defer func() {
log.Debug().Msg("Forward backfill finished, unlocking lock")
portal.forwardBackfillLock.Unlock()
}()
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
if portal.forwardBackfillLock.TryLock() {
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
@@ -26,21 +30,28 @@ func (portal *Portal) forwardBackfillInitial(source *User) {
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread
thread.initialBackfillAttempted = true
}
}
if limit == 0 {
return
}
log := portal.zlog.With().
with := log.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log = with.Logger()
portal.backfillLimited(log, source, limit, "")
portal.backfillLimited(log, source, limit, "", thread)
}
func (portal *Portal) ForwardBackfillMissed(source *User, meta *discordgo.Channel) {
func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) {
if portal.MXID == "" {
return
}
@@ -48,50 +59,65 @@ func (portal *Portal) ForwardBackfillMissed(source *User, meta *discordgo.Channe
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread
}
}
if limit == 0 {
return
}
log := portal.zlog.With().
with := portal.log.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit).
Logger()
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log := with.Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
lastMessage := portal.bridge.DB.Message.GetLast(portal.Key)
if lastMessage == nil || meta.LastMessageID == "" {
var lastMessage *database.Message
if thread != nil {
lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
} else {
lastMessage = portal.bridge.DB.Message.GetLast(portal.Key)
}
if lastMessage == nil || serverLastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, meta.LastMessageID) {
} else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", meta.LastMessageID).
Str("last_server_message", serverLastMessageID).
Msg("Not backfilling, last message in database is newer than last message in metadata")
return
}
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", meta.LastMessageID).
Str("last_server_message", serverLastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID)
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID)
portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string) ([]*discordgo.Message, bool, error) {
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, before, "", "")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "")
if err != nil {
return nil, false, err
}
@@ -110,7 +136,7 @@ func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User,
}
messages = append(messages, newMessages...)
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
if len(newMessages) <= messageFetchChunkSize || len(messages) >= limit {
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
break
}
before = newMessages[len(newMessages)-1].ID
@@ -122,8 +148,8 @@ func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User,
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)
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread)
if err != nil {
log.Err(err).Msg("Error collecting messages to forward backfill")
return
@@ -133,7 +159,7 @@ func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit in
Bool("found_all", foundAll).
Msg("Collected messages to backfill")
sort.Sort(MessageSlice(messages))
if !foundAll {
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.",
@@ -144,13 +170,17 @@ func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit in
log.Debug().Msg("Sent warning about possibly missed messages")
}
}
portal.sendBackfillBatch(log, source, messages)
portal.sendBackfillBatch(log, source, messages, thread)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string) {
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) {
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(portal.Key.ChannelID, messageFetchChunkSize, "", after, "")
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "")
if err != nil {
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
return
@@ -158,7 +188,7 @@ func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User,
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
sort.Sort(MessageSlice(messages))
portal.sendBackfillBatch(log, source, messages)
portal.sendBackfillBatch(log, source, messages, thread)
if len(messages) < messageFetchChunkSize {
// Assume that was all the missing messages
@@ -169,40 +199,97 @@ func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User,
}
}
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message) {
if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages)
portal.forwardBatchSend(log, source, messages, thread)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, nil)
portal.handleDiscordMessageCreate(source, msg, thread)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message) {
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread)
if len(evts) == 0 {
log.Warn().Msg("Didn't get any events to backfill")
return
}
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{
Forward: true,
Events: evts,
})
if err != nil {
log.Err(err).Msg("Error sending backfill batch")
return
}
for i, evtID := range resp.EventIDs {
dbMessages[i].MXID = evtID
if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread {
// TODO proper context
ctx := log.WithContext(context.Background())
portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread)
}
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
}
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) {
var discordThreadID string
var threadRootEvent, lastThreadEvent id.EventID
if thread != nil {
discordThreadID = thread.ID
threadRootEvent = thread.RootMXID
lastThreadEvent = threadRootEvent
lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
if lastInThread != nil {
lastThreadEvent = lastInThread.MXID
}
}
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.Message, 0, len(messages))
metas := make([]*discordgo.Message, 0, len(messages))
ctx := context.Background()
for _, msg := range messages {
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
puppet.UpdateInfo(nil, mention)
puppet.UpdateInfo(nil, mention, nil)
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author)
puppet.UpdateInfo(source, msg.Author, msg)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, msg.MessageReference, true)
replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true)
mentions := portal.convertDiscordMentions(msg, false)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
parts := portal.convertDiscordMessage(intent, msg)
log := log.With().
Str("message_id", msg.ID).
Int("message_type", int(msg.Type)).
Str("author_id", msg.Author.ID).
Logger()
parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg)
for i, part := range parts {
if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
part.Content.RelatesTo = &event.RelatesTo{}
}
if threadRootEvent != "" {
part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
}
if replyTo != nil {
part.Content.RelatesTo = &event.RelatesTo{InReplyTo: replyTo}
part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
// Only set reply for first event
replyTo = nil
}
part.Content.Mentions = mentions
// Only set mentions for first event, but keep empty object for rest
mentions = &event.Mentions{}
partName := part.AttachmentID
// Always use blank part name for first part so that replies and other things
// can reference it without knowing about attachments.
@@ -233,27 +320,17 @@ func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, message
SenderID: msg.Author.ID,
Timestamp: ts,
AttachmentID: part.AttachmentID,
SenderMXID: intent.UserID,
})
if i == 0 {
metas = append(metas, msg)
} else {
metas = append(metas, nil)
}
lastThreadEvent = evt.ID
}
}
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")
return evts, metas, dbMessages
}
func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID {

View File

@@ -159,7 +159,7 @@ func fnLoginToken(ce *WrappedCommandEvent) {
ce.Reply("Error connecting to Discord: %v", err)
return
}
ce.Reply("Successfully logged in as %s#%s", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator)
ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username)
}
var cmdLoginQR = &commands.FullHandler{
@@ -228,7 +228,7 @@ func fnLoginQR(ce *WrappedCommandEvent) {
ce.User.DiscordID = user.UserID
ce.User.Update()
ce.User.Unlock()
ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
ce.Reply("Successfully logged in as @%s", user.Username)
}
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
@@ -308,7 +308,7 @@ func fnPing(ce *WrappedCommandEvent) {
} 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)
ce.Reply("You're logged in as @%s (`%s`)", ce.User.Session.State.User.Username, ce.User.DiscordID)
}
}
@@ -371,10 +371,10 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
}
user := ce.User
if ce.Args[0] == "main" {
user.ensureInvited(nil, user.GetSpaceRoom(), false)
user.ensureInvited(nil, user.GetSpaceRoom(), false, true)
ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if ce.Args[0] == "dms" {
user.ensureInvited(nil, user.GetDMSpaceRoom(), false)
user.ensureInvited(nil, user.GetDMSpaceRoom(), false, true)
ce.Reply("Invited you to your DM space ([link](%s))", user.GetDMSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
ce.Reply("Rejoining guild spaces is not yet implemented")

View File

@@ -25,6 +25,7 @@ import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type BridgeConfig struct {
@@ -50,7 +51,14 @@ type BridgeConfig struct {
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
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"`
Args struct {
Width int `yaml:"width"`
@@ -89,9 +97,117 @@ type BridgeConfig struct {
guildNameTemplate *template.Template `yaml:"-"`
}
type MediaPatterns struct {
Enabled bool `yaml:"enabled"`
TplAttachments string `yaml:"attachments"`
TplEmojis string `yaml:"emojis"`
TplStickers string `yaml:"stickers"`
TplAvatars string `yaml:"avatars"`
attachments *template.Template `yaml:"-"`
emojis *template.Template `yaml:"-"`
stickers *template.Template `yaml:"-"`
avatars *template.Template `yaml:"-"`
}
type umMediaPatterns MediaPatterns
func (mp *MediaPatterns) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umMediaPatterns)(mp))
if err != nil {
return err
}
tpl := template.New("media_patterns")
pairs := []struct {
ptr **template.Template
name string
template string
}{
{&mp.attachments, "attachments", mp.TplAttachments},
{&mp.emojis, "emojis", mp.TplEmojis},
{&mp.stickers, "stickers", mp.TplStickers},
{&mp.avatars, "avatars", mp.TplAvatars},
}
for _, pair := range pairs {
if pair.template == "" {
continue
}
*pair.ptr, err = tpl.New(pair.name).Parse(pair.template)
if err != nil {
return err
}
}
return nil
}
type attachmentParams struct {
ChannelID string
AttachmentID string
FileName string
}
type emojiStickerParams struct {
ID string
Ext string
}
type avatarParams struct {
UserID string
AvatarID string
Ext string
}
func (mp *MediaPatterns) execute(tpl *template.Template, params any) id.ContentURI {
if tpl == nil || !mp.Enabled {
return id.ContentURI{}
}
var out strings.Builder
err := tpl.Execute(&out, params)
if err != nil {
panic(err)
}
uri, err := id.ParseContentURI(out.String())
if err != nil {
panic(err)
}
return uri
}
func (mp *MediaPatterns) Attachment(channelID, attachmentID, filename string) id.ContentURI {
return mp.execute(mp.attachments, attachmentParams{
ChannelID: channelID,
AttachmentID: attachmentID,
FileName: filename,
})
}
func (mp *MediaPatterns) Emoji(emojiID, ext string) id.ContentURI {
return mp.execute(mp.emojis, emojiStickerParams{
ID: emojiID,
Ext: ext,
})
}
func (mp *MediaPatterns) Sticker(stickerID, ext string) id.ContentURI {
return mp.execute(mp.stickers, emojiStickerParams{
ID: stickerID,
Ext: ext,
})
}
func (mp *MediaPatterns) Avatar(userID, avatarID, ext string) id.ContentURI {
return mp.execute(mp.avatars, avatarParams{
UserID: userID,
AvatarID: avatarID,
Ext: ext,
})
}
type BackfillLimitPart struct {
DM int `yaml:"dm"`
Channel int `yaml:"channel"`
Thread int `yaml:"thread"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
@@ -174,9 +290,19 @@ func (bc BridgeConfig) FormatUsername(userID string) string {
return buffer.String()
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) string {
type DisplaynameParams struct {
*discordgo.User
Webhook bool
Application bool
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, user)
_ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
User: user,
Webhook: webhook,
Application: application,
})
return buffer.String()
}

View File

@@ -17,9 +17,9 @@
package config
import (
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/util"
up "maunium.net/go/mautrix/util/configupgrade"
)
func DoUpgrade(helper *up.Helper) {
@@ -55,6 +55,15 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Bool, "bridge", "prefix_webhook_messages")
helper.Copy(up.Bool, "bridge", "enable_webhook_avatars")
helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload")
helper.Copy(up.Bool, "bridge", "media_patterns", "enabled")
helper.Copy(up.Str, "bridge", "cache_media")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "attachments")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "emojis")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "stickers")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "avatars")
helper.Copy(up.Str, "bridge", "animated_sticker", "target")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
@@ -70,14 +79,17 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "backfill", "enabled")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread")
helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
@@ -85,16 +97,18 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := util.RandomString(64)
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")

View File

@@ -5,10 +5,9 @@ import (
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/util/dbutil"
"go.mau.fi/mautrix-discord/database/upgrades"
)
@@ -68,9 +67,10 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
return db
}
func strPtr(val string) *string {
func strPtr[T ~string](val T) *string {
if val == "" {
return nil
}
return &val
valStr := string(val)
return &valStr
}

View File

@@ -6,11 +6,10 @@ import (
"errors"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type FileQuery struct {
@@ -39,8 +38,8 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
}
func (fq *FileQuery) GetByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1"
func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1"
return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
}
@@ -79,7 +78,7 @@ func (f *File) Scan(row dbutil.Scannable) *File {
}
f.ID = fileID.String
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp)
f.Timestamp = time.UnixMilli(timestamp).UTC()
f.Width = int(width.Int32)
f.Height = int(height.Int32)
f.MXC, err = id.ParseContentURI(mxc)

View File

@@ -6,10 +6,9 @@ import (
"fmt"
"strings"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type GuildBridgingMode int

View File

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

View File

@@ -4,18 +4,16 @@ import (
"database/sql"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
// language=postgresql
const (
portalSelect = `
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal
`
@@ -68,6 +66,10 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
}
func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal {
return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
}
@@ -109,16 +111,17 @@ type Portal struct {
MXID id.RoomID
PlainName string
Name string
NameSet bool
Topic string
TopicSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Encrypted bool
InSpace id.RoomID
PlainName string
Name string
NameSet bool
FriendNick bool
Topic string
TopicSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Encrypted bool
InSpace id.RoomID
FirstEventID id.EventID
@@ -132,7 +135,7 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var avatarURL string
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil {
@@ -160,13 +163,13 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
func (p *Portal) Insert() {
query := `
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
`
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
if err != nil {
@@ -179,14 +182,16 @@ func (p *Portal) Update() {
query := `
UPDATE portal
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
plain_name=$6, name=$7, name_set=$8, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13,
encrypted=$14, in_space=$15, first_event_id=$16, relay_webhook_id=$17, relay_webhook_secret=$18
WHERE dcid=$19 AND receiver=$20
plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
relay_webhook_id=$18, relay_webhook_secret=$19
WHERE dcid=$20 AND receiver=$21
`
_, err := p.db.Exec(query,
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.Encrypted, p.InSpace, p.FirstEventID.String(),
strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
p.Key.ChannelID, p.Key.Receiver)
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
-- v0 -> v15: Latest revision
-- v0 -> v23 (compatible with v19+): Latest revision
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
@@ -29,6 +29,7 @@ CREATE TABLE portal (
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
friend_nick BOOLEAN NOT NULL,
topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
@@ -62,11 +63,20 @@ CREATE TABLE thread (
CREATE TABLE puppet (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
global_name TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
discriminator TEXT NOT NULL DEFAULT '',
is_bot BOOLEAN NOT NULL DEFAULT false,
is_webhook BOOLEAN NOT NULL DEFAULT false,
is_application BOOLEAN NOT NULL DEFAULT false,
custom_mxid TEXT,
access_token TEXT,
@@ -97,18 +107,19 @@ CREATE TABLE user_portal (
);
CREATE TABLE message (
dcid TEXT,
dc_attachment_id TEXT,
dc_edit_index INTEGER,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
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,
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
);
@@ -120,13 +131,12 @@ CREATE TABLE reaction (
dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL,
dc_first_attachment_id TEXT NOT NULL,
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
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_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 (
@@ -151,7 +161,7 @@ CREATE TABLE role (
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
mxc TEXT NOT NULL,
id TEXT,
emoji_name TEXT,
@@ -165,3 +175,5 @@ CREATE TABLE discord_file (
PRIMARY KEY (url, encrypted)
);
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
-- v19: Replace dc_edit_index with dc_edit_timestamp
-- transaction: off
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
dcid TEXT,
dc_attachment_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
INSERT INTO message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid)
SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message;
DROP TABLE message;
ALTER TABLE message_new RENAME TO message;
CREATE TABLE reaction_new (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL,
dc_first_attachment_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
INSERT INTO reaction_new (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, COALESCE(dc_thread_id, ''), dc_first_attachment_id, mxid FROM reaction;
DROP TABLE reaction;
ALTER TABLE reaction_new RENAME TO reaction;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
-- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache
CREATE TABLE new_discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);
INSERT INTO new_discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
DROP TABLE discord_file;
ALTER TABLE new_discord_file RENAME TO discord_file;
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,13 @@ homeserver:
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
@@ -80,11 +87,14 @@ bridge:
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# Available variables:
# .ID - Internal user ID
# .Username - User's displayname on Discord
# .Username - Legacy display/username on Discord
# .GlobalName - New displayname on Discord
# .Discriminator - The 4 numbers after the name on Discord
# .Bot - Whether the user is a bot
# .System - Whether the user is an official system user
displayname_template: '{{.Username}}#{{.Discriminator}}{{if .Bot}} (bot){{end}}'
# .Webhook - Whether the user is a webhook and is not an application
# .Application - Whether the user is an application
displayname_template: '{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}'
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
# Available variables:
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
@@ -145,6 +155,33 @@ bridge:
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Prefix messages from webhooks with the profile info? This can be used along with a custom displayname_template
# to better handle webhooks that change their name all the time (like ones used by bridges).
prefix_webhook_messages: false
# Bridge webhook avatars?
enable_webhook_avatars: true
# Should the bridge upload media to the Discord CDN directly before sending the message when using a user token,
# like the official client does? The other option is sending the media in the message send request as a form part
# (which is always used by bots and webhooks).
use_discord_cdn_upload: true
# Should mxc uris copied from Discord be cached?
# This can be `never` to never cache, `unencrypted` to only cache unencrypted mxc uris, or `always` to cache everything.
# If you have a media repo that generates non-unique mxc uris, you should set this to never.
cache_media: unencrypted
# Patterns for converting Discord media to custom mxc:// URIs instead of reuploading.
# Each of the patterns can be set to null to disable custom URIs for that type of media.
# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
media_patterns:
# Should custom mxc:// URIs be used instead of reuploading media?
enabled: false
# Pattern for normal message attachments.
attachments: mxc://discord-media.mau.dev/attachments|{{.ChannelID}}|{{.AttachmentID}}|{{.FileName}}
# Pattern for custom emojis.
emojis: mxc://discord-media.mau.dev/emojis|{{.ID}}.{{.Ext}}
# Pattern for stickers. Note that animated lottie stickers will not be converted if this is enabled.
stickers: mxc://discord-media.mau.dev/stickers|{{.ID}}.{{.Ext}}
# Pattern for static user avatars.
avatars: mxc://discord-media.mau.dev/avatars|{{.UserID}}|{{.AvatarID}}.{{.Ext}}
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
@@ -196,6 +233,7 @@ bridge:
initial:
dm: 0
channel: 0
thread: 0
# Missed message backfill (on startup).
# 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
# When using unlimited backfill (-1), messages are backfilled as they are fetched.
@@ -203,6 +241,7 @@ bridge:
missed:
dm: 0
channel: 0
thread: 0
# Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
# This can be used as a rough heuristic to disable backfilling in channels that are too active.
# Currently only applies to missed message backfill.
@@ -224,6 +263,8 @@ bridge:
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Should users mentions be in the event wire content to enable the server to send push notifications?
plaintext_mentions: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
@@ -241,6 +282,10 @@ bridge:
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
@@ -276,6 +321,10 @@ bridge:
# default.
messages: 100
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.

View File

@@ -26,12 +26,12 @@ import (
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"go.mau.fi/util/variationselector"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/variationselector"
)
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
@@ -58,7 +58,7 @@ func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
parser.NewSetextHeadingParser(), parser.NewATXHeadingParser(), parser.NewThematicBreakParser(),
parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(),
}
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
@@ -93,6 +93,7 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
func appendIfNotContains(arr []string, newItem string) []string {
for _, item := range arr {
@@ -135,6 +136,10 @@ func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx fo
}
}
} else if mxid[0] == '@' {
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 {
@@ -150,11 +155,14 @@ func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx fo
return displayname
}
const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
// Discord links start with http:// or https://, contain at least two characters afterwards,
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
//
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
var discordLinkRegex = regexp.MustCompile(`https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`)
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
var discordMarkdownEscaper = strings.NewReplacer(
`\`, `\\`,
@@ -164,6 +172,7 @@ var discordMarkdownEscaper = strings.NewReplacer(
"`", "\\`",
`|`, `\|`,
`<`, `\<`,
`#`, `\#`,
)
func escapeDiscordMarkdown(s string) string {
@@ -219,6 +228,9 @@ func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (strin
ctx := format.NewContext()
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil {
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
}
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else {
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions

View File

@@ -192,7 +192,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
case strings.HasPrefix(tagName, ":"):
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
case strings.HasPrefix(tagName, "a:"):
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag}
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
default:
return nil
}
@@ -263,9 +263,9 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
switch node := n.(type) {
case *astDiscordUserMention:
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%[1]s">%[1]s</a>`, user.MXID)
_, _ = 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="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name)
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, puppet.MXID.URI().MatrixToURL(), puppet.Name)
}
return
case *astDiscordRoleMention:
@@ -281,7 +281,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
})
if portal != nil {
if portal.MXID != "" {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s?via=%s">%s</a>`, portal.MXID, portal.bridge.AS.HomeserverDomain, portal.Name)
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
@@ -290,7 +290,11 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
case *astDiscordCustomEmoji:
reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
if !reactionMXC.IsEmpty() {
_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
attrs := "data-mx-emoticon"
if node.animated {
attrs += " data-mau-animated-emoji"
}
_, _ = fmt.Fprintf(w, `<img %[3]s src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name, attrs)
return
}
case *astDiscordTimestamp:
@@ -305,7 +309,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
fullRFC := ts.Format(fullDatetimeFormat)
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted)
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s" data-discord-style="%c"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, node.style, formatted)
}
stringifiable, ok := n.(fmt.Stringer)
if ok {

27
go.mod
View File

@@ -1,6 +1,6 @@
module go.mau.fi/mautrix-discord
go 1.19
go 1.20
require (
github.com/bwmarrin/discordgo v0.27.0
@@ -8,14 +8,17 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.8
github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.29.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.17
github.com/rs/zerolog v1.30.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.2
github.com/yuin/goldmark v1.5.4
github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.5.5
go.mau.fi/util v0.0.0-20230805171708-199bf3eec776
golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb
golang.org/x/sync v0.3.0
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.15.1
maunium.net/go/mautrix v0.16.0
)
require (
@@ -24,17 +27,17 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/gjson v1.16.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/crypto v0.8.0 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718

66
go.sum
View File

@@ -1,9 +1,8 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961 h1:eSGaliexlehYBeP4YQW8dQpV9XWWgfR1qH8kfHgrDcY=
github.com/beeper/discordgo v0.0.0-20230416132336-325ee6a8c961/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718 h1:gzOFOenpzAWnsiskTmOOorrrejm2wGjSpxzQ5zgpSso=
github.com/beeper/discordgo v0.0.0-20230804154054-72abb5417718/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@@ -13,69 +12,62 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.10.8 h1:3fdt97i/cwSU83+E0hZTC/Xpc9mTZxc6UWSCRcSbxiE=
github.com/lib/pq v1.10.8/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=
github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.5 h1:IJznPe8wOzfIKETmMkd06F8nXkmlhaHqFRM9l1hAGsU=
github.com/yuin/goldmark v1.5.5/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.0.0-20230805171708-199bf3eec776 h1:VrxDCO/gLFHLQywGUsJzertrvt2mUEMrZPf4hEL/s18=
go.mau.fi/util v0.0.0-20230805171708-199bf3eec776/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA=
golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.15.1 h1:pmCtMjYRpd83+2UL+KTRFYQo5to0373yulimvLK+1k0=
maunium.net/go/mautrix v0.15.1/go.mod h1:icQIrvz2NldkRLTuzSGzmaeuMUmw+fzO7UVycPeauN8=
maunium.net/go/mautrix v0.16.0 h1:iUqCzJE2yqBC1ddAK6eAn159My8rLb4X8g4SFtQh2Dk=
maunium.net/go/mautrix v0.16.0/go.mod h1:XAjE9pTSGcr6vXaiNgQGiip7tddJ8FQV1a29u2QdBG4=

View File

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

29
main.go
View File

@@ -18,18 +18,14 @@ package main
import (
_ "embed"
"fmt"
"runtime"
"strings"
"sync"
"github.com/rs/zerolog"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/exsync"
"golang.org/x/sync/semaphore"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
@@ -78,7 +74,8 @@ type DiscordBridge struct {
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
attachmentTransfers *util.SyncMap[attachmentKey, *util.ReturnableOnce[*database.File]]
attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
parallelAttachmentSemaphore *semaphore.Weighted
}
func (br *DiscordBridge) GetExampleConfig() string {
@@ -101,22 +98,13 @@ func (br *DiscordBridge) Init() {
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
// TODO move this to mautrix-go?
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
files := strings.Split(file, "/")
file = files[len(files)-1]
name := runtime.FuncForPC(pc).Name()
fns := strings.Split(name, ".")
name = fns[len(fns)-1]
return fmt.Sprintf("%s:%d:%s()", file, line, name)
}
}
func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
br.WaitWebsocketConnected()
go br.startUsers()
}
@@ -184,13 +172,14 @@ func main() {
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](),
attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
parallelAttachmentSemaphore: semaphore.NewWeighted(3),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.3.0",
Version: "0.6.1",
ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",

769
portal.go

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
package main
import (
"context"
"fmt"
"html"
"strconv"
@@ -24,6 +25,9 @@ import (
"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"
@@ -48,14 +52,14 @@ func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEv
const DiscordStickerSize = 160
func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
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 {
portal.log.Errorfln("Error copying attachment %s to Matrix: %v", id, err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
return portal.createMediaFailedMessage(err)
}
if typeName == "sticker" && content.Info.MimeType == "application/json" {
@@ -66,10 +70,6 @@ func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.Int
content.Info.Width = dbFile.Width
content.Info.Height = dbFile.Height
}
if content.Info.Width == 0 && content.Info.Height == 0 && typeName == "sticker" {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
if dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
@@ -78,8 +78,17 @@ func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.Int
} else {
content.URL = dbFile.MXC.CUString()
}
return content
}
if typeName == "sticker" && (content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize) {
func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
if content.Info == nil {
return
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
@@ -91,36 +100,51 @@ func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.Int
content.Info.Height = DiscordStickerSize
}
}
return content
}
func (portal *Portal) convertDiscordSticker(intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
var mime string
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:
portal.log.Warnfln("Unknown sticker format %d in %s", sticker.FormatType, sticker.ID)
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: portal.convertDiscordFile("sticker", intent, sticker.ID, sticker.URL(), &event.MessageEventContent{
Body: sticker.Name, // TODO find description from somewhere?
Info: &event.FileInfo{
MimeType: mime,
},
}),
Content: content,
}
}
func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
content := &event.MessageEventContent{
Body: att.Filename,
Info: &event.FileInfo{
@@ -158,7 +182,12 @@ func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att
default:
content.MsgType = event.MsgFile
}
content = portal.convertDiscordFile("attachment", intent, att.ID, att.URL, content)
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,
@@ -167,10 +196,17 @@ func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att
}
}
func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
attachmentID := fmt.Sprintf("video_%s", embed.URL)
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta)
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,
@@ -179,16 +215,21 @@ func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, emb
}
content := &event.MessageEventContent{
MsgType: event.MsgVideo,
Body: embed.URL,
Body: embed.URL,
Info: &event.FileInfo{
Width: embed.Video.Width,
Height: embed.Video.Height,
MimeType: dbFile.MimeType,
Size: dbFile.Size,
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
@@ -202,7 +243,7 @@ func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, emb
content.URL = dbFile.MXC.CUString()
}
extra := map[string]any{}
if embed.Type == discordgo.EmbedTypeGifv {
if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
extra["info"] = map[string]any{
"fi.mau.discord.gifv": true,
"fi.mau.loop": true,
@@ -219,22 +260,24 @@ func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, emb
}
}
func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
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(intent, msg); textPart != nil {
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{}{}
if part := portal.convertDiscordAttachment(intent, att); part != nil {
log := log.With().Str("attachment_id", att.ID).Logger()
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
parts = append(parts, part)
}
}
@@ -243,13 +286,14 @@ func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *d
continue
}
handledIDs[sticker.ID] = struct{}{}
if part := portal.convertDiscordSticker(intent, sticker); part != nil {
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 _, embed := range msg.Embeds {
for i, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if getEmbedType(embed) != EmbedVideo {
if getEmbedType(msg, embed) != EmbedVideo {
continue
}
// Discord deduplicates embeds by URL. It makes things easier for us too.
@@ -257,14 +301,102 @@ func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *d
continue
}
handledIDs[embed.URL] = struct{}{}
part := portal.convertDiscordVideoEmbed(intent, embed)
log := log.With().
Str("computed_embed_type", "video").
Str("embed_type", string(embed.Type)).
Int("embed_index", i).
Logger()
part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
if part != nil {
parts = append(parts, part)
}
}
if len(parts) == 0 && msg.Thread != nil {
parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
}})
}
for _, part := range parts {
puppet.addWebhookMeta(part, msg)
puppet.addMemberMeta(part, msg)
}
return parts
}
func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.Member == nil {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
var discordAvatarURL string
if msg.Member.Avatar != "" {
var err error
avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload guild user avatar")
}
}
part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
"nick": msg.Member.Nick,
"avatar_id": msg.Member.Avatar,
"avatar_url": discordAvatarURL,
"avatar_mxc": avatarURL.String(),
}
if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
perMessageProfile := map[string]any{
"is_multiple_users": false,
"displayname": msg.Member.Nick,
"avatar_url": avatarURL.String(),
}
if msg.Member.Nick == "" {
perMessageProfile["displayname"] = puppet.Name
}
if avatarURL.IsEmpty() {
perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
}
part.Extra["com.beeper.per_message_profile"] = perMessageProfile
}
}
func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.WebhookID == "" {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
if msg.Author.Avatar != "" {
var err error
avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload webhook avatar")
}
}
part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
"id": msg.WebhookID,
"name": msg.Author.Username,
"avatar_id": msg.Author.Avatar,
"avatar_url": msg.Author.AvatarURL(""),
"avatar_mxc": avatarURL.String(),
}
part.Extra["com.beeper.per_message_profile"] = map[string]any{
"is_multiple_users": true,
"avatar_url": avatarURL.String(),
"displayname": msg.Author.Username,
}
}
const (
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
@@ -286,7 +418,8 @@ const (
embedFooterDateSeparator = ``
)
func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
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
@@ -298,7 +431,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
if embed.Author.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err)
log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
} else {
authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
}
@@ -348,7 +481,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
if embed.Image != nil {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err)
log.Warn().Err(err).Msg("Failed to reupload image in embed")
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
}
@@ -358,7 +491,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
formattedTime := embed.Timestamp
parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
if err != nil {
portal.log.Warnfln("Failed to parse timestamp in embed #%d of message %s: %v", index+1, msgID, err)
log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
} else {
formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
}
@@ -374,7 +507,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
if embed.Footer.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
if err != nil {
portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err)
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)
}
@@ -403,40 +536,40 @@ type BeeperLinkPreview struct {
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
}
func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
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 {
portal.log.Warnfln("Failed to copy image in URL preview: %v", err)
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 {
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()
preview.ImageWidth = dbFile.Width
preview.ImageHeight = dbFile.Height
}
preview.ImageSize = dbFile.Size
preview.ImageType = dbFile.MimeType
if dbFile.Encrypted {
preview.ImageEncryption = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
preview.ImageURL = dbFile.MXC.CUString()
}
}
func (portal *Portal) convertDiscordLinkEmbedToBeeper(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
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(intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
portal.convertDiscordLinkEmbedImage(intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
}
return &preview
}
@@ -462,7 +595,7 @@ func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
return embed.Video != nil && embed.Video.ProxyURL == ""
}
func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
switch embed.Type {
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
return EmbedLinkPreview
@@ -473,7 +606,14 @@ func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
return EmbedVideo
case discordgo.EmbedTypeGifv:
return EmbedVideo
case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage:
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
@@ -481,10 +621,40 @@ func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
}
func isPlainGifMessage(msg *discordgo.Message) bool {
return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv
if len(msg.Embeds) != 1 {
return false
}
embed := msg.Embeds[0]
isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil
contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
return contentIsOnlyURL && (isGifVideo || isGifImage)
}
func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
var matrixMentions event.Mentions
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
if syncGhosts {
puppet.UpdateInfo(nil, mention, nil)
}
user := portal.bridge.GetUserByID(mention.ID)
if user != nil {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
} else {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
}
}
slices.Sort(matrixMentions.UserIDs)
matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
if msg.MentionEveryone {
matrixMentions.Room = true
}
return &matrixMentions
}
func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
log := zerolog.Ctx(ctx)
if msg.Type == discordgo.MessageTypeCall {
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
@@ -499,7 +669,7 @@ func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, ms
var htmlParts []string
if msg.Interaction != nil {
puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
puppet.UpdateInfo(nil, msg.Interaction.User)
puppet.UpdateInfo(nil, msg.Interaction.User, nil)
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
}
if msg.Content != "" && !isPlainGifMessage(msg) {
@@ -507,15 +677,24 @@ func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, ms
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
switch getEmbedType(embed) {
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:
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i))
log := with.Str("computed_embed_type", "rich").Logger()
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
case EmbedLinkPreview:
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed))
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:
portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID)
log := with.Logger()
log.Warn().Msg("Unknown embed type in message")
}
}
@@ -537,5 +716,11 @@ func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, ms
"com.beeper.linkpreviews": previews,
}
if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
content.EnsureHasHTML()
content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

127
puppet.go
View File

@@ -3,11 +3,13 @@ package main
import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
@@ -193,7 +195,7 @@ func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
}
func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info)
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication)
if puppet.Name == newName && puppet.NameSet {
return false
}
@@ -204,7 +206,7 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
puppet.log.Warn().Err(err).Msg("Failed to update displayname")
} else {
go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateNameDirect(puppet.Name) {
if portal.UpdateNameDirect(puppet.Name, false) {
portal.Update()
portal.UpdateBridgeInfo()
}
@@ -214,18 +216,53 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
return true
}
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
var downloadURL, ext string
if guildID == "" {
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
ext = "gif"
}
} else {
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
ext = "gif"
}
}
if guildID == "" {
url := br.Config.Bridge.MediaPatterns.Avatar(userID, avatarID, ext)
if !url.IsEmpty() {
return url, downloadURL, nil
}
}
copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
})
if err != nil {
return id.ContentURI{}, downloadURL, err
}
return copied.MXC, downloadURL, nil
}
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
if puppet.Avatar == info.Avatar && puppet.AvatarSet {
avatarID := info.Avatar
if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars {
avatarID = ""
}
if puppet.Avatar == avatarID && puppet.AvatarSet {
return false
}
avatarChanged := info.Avatar != puppet.Avatar
puppet.Avatar = info.Avatar
avatarChanged := avatarID != puppet.Avatar
puppet.Avatar = avatarID
puppet.AvatarSet = false
puppet.AvatarURL = id.ContentURI{}
// TODO should we just use discord's default avatars for users with no avatar?
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
url, err := uploadAvatar(puppet.DefaultIntent(), info.AvatarURL(""))
url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar)
if err != nil {
puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
return true
@@ -248,7 +285,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
return true
}
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) {
puppet.syncLock.Lock()
defer puppet.syncLock.Unlock()
@@ -271,9 +308,83 @@ func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
}
changed := false
if message != nil {
if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook {
puppet.log.Debug().
Str("message_id", message.ID).
Str("webhook_id", message.WebhookID).
Msg("Found webhook ID in message, marking ghost as a webhook")
puppet.IsWebhook = true
changed = true
}
if message.ApplicationID != "" && !puppet.IsApplication {
puppet.log.Debug().
Str("message_id", message.ID).
Str("application_id", message.ApplicationID).
Msg("Found application ID in message, marking ghost as an application")
puppet.IsApplication = true
puppet.IsWebhook = false
changed = true
}
}
changed = puppet.UpdateContactInfo(info) || changed
changed = puppet.UpdateName(info) || changed
changed = puppet.UpdateAvatar(info) || changed
if changed {
puppet.Update()
}
}
func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool {
changed := false
if puppet.Username != info.Username {
puppet.Username = info.Username
changed = true
}
if puppet.GlobalName != info.GlobalName {
puppet.GlobalName = info.GlobalName
changed = true
}
if puppet.Discriminator != info.Discriminator {
puppet.Discriminator = info.Discriminator
changed = true
}
if puppet.IsBot != info.Bot {
puppet.IsBot = info.Bot
changed = true
}
if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet {
puppet.ContactInfoSet = false
puppet.ResendContactInfo()
return true
}
return false
}
func (puppet *Puppet) ResendContactInfo() {
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
return
}
discordUsername := puppet.Username
if puppet.Discriminator != "0" {
discordUsername += "#" + puppet.Discriminator
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": []string{
fmt.Sprintf("discord:%s", discordUsername),
},
"com.beeper.bridge.remote_id": puppet.ID,
"com.beeper.bridge.service": puppet.bridge.BeeperServiceName,
"com.beeper.bridge.network": puppet.bridge.BeeperNetworkName,
"com.beeper.bridge.is_network_bot": puppet.IsBot,
}
if puppet.IsWebhook {
contactInfo["com.beeper.bridge.identifiers"] = []string{}
}
err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
if err != nil {
puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
} else {
puppet.ContactInfoSet = true
}
}

View File

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

389
user.go
View File

@@ -1,11 +1,13 @@
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
@@ -15,7 +17,7 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
@@ -61,6 +63,8 @@ type User struct {
pendingInteractionsLock sync.Mutex
nextDiscordUploadID atomic.Int32
relationships map[string]*discordgo.Relationship
}
func (user *User) GetRemoteID() string {
@@ -69,6 +73,9 @@ func (user *User) GetRemoteID() string {
func (user *User) GetRemoteName() string {
if user.Session != nil && user.Session.State != nil && user.Session.State.User != nil {
if user.Session.State.User.Discriminator == "0" {
return fmt.Sprintf("@%s", user.Session.State.User.Username)
}
return fmt.Sprintf("%s#%s", user.Session.State.User.Username, user.Session.State.User.Discriminator)
}
return user.DiscordID
@@ -76,20 +83,24 @@ func (user *User) GetRemoteName() string {
var discordLog zerolog.Logger
func discordToZeroLevel(level int) zerolog.Level {
switch level {
case discordgo.LogError:
return zerolog.ErrorLevel
case discordgo.LogWarning:
return zerolog.WarnLevel
case discordgo.LogInformational:
return zerolog.InfoLevel
case discordgo.LogDebug:
fallthrough
default:
return zerolog.DebugLevel
}
}
func init() {
discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) {
var level zerolog.Level
switch msgL {
case discordgo.LogError:
level = zerolog.ErrorLevel
case discordgo.LogWarning:
level = zerolog.WarnLevel
case discordgo.LogInformational:
level = zerolog.InfoLevel
case discordgo.LogDebug:
level = zerolog.DebugLevel
}
discordLog.WithLevel(level).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
}
}
@@ -178,6 +189,12 @@ func (br *DiscordBridge) GetUserByID(id string) *User {
return user
}
func (br *DiscordBridge) GetCachedUserByID(id string) *User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
return br.usersByID[id]
}
func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
user := &User{
User: dbUser,
@@ -188,6 +205,8 @@ func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID),
pendingInteractions: make(map[string]*WrappedCommandEvent),
relationships: make(map[string]*discordgo.Relationship),
}
user.nextDiscordUploadID.Store(rand.Int31n(100))
user.BridgeState = br.NewBridgeStateQueue(user)
@@ -241,7 +260,7 @@ func (user *User) startupTryConnect(retryCount int) {
user.log.Error().Err(err).Msg("Error connecting on startup")
closeErr := &websocket.CloseError{}
if errors.As(err, &closeErr) && closeErr.Code == 4004 {
user.invalidAuthHandler(nil, nil)
user.invalidAuthHandler(nil)
} else if retryCount < 6 {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-unknown-websocket-error", Message: err.Error()})
retryInSeconds := 2 << retryCount
@@ -323,7 +342,7 @@ func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.Roo
} else {
*ptr = resp.RoomID
user.Update()
user.ensureInvited(nil, *ptr, false)
user.ensureInvited(nil, *ptr, false, true)
if parent != "" {
_, err = user.bridge.Bot.SendStateEvent(parent, event.StateSpaceChild, resp.RoomID.String(), &event.SpaceChildEventContent{
@@ -429,7 +448,7 @@ func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool)
}
// TODO sync mute status properly
if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate {
if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate && justCreated {
user.mutePortal(doublePuppetIntent, portal, false)
}
}
@@ -555,44 +574,94 @@ func (user *User) Connect() error {
// TODO move to config
if os.Getenv("DISCORD_DEBUG") == "1" {
session.LogLevel = discordgo.LogDebug
} else {
session.LogLevel = discordgo.LogInformational
}
userDiscordLog := user.log.With().Str("component", "discordgo").Logger()
session.Logger = func(msgL, caller int, format string, a ...interface{}) {
userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
}
if !session.IsUser {
session.Identify.Intents = BotIntents
}
session.EventHandler = user.eventHandlerSync
user.Session = session
user.Session.AddHandler(user.readyHandler)
user.Session.AddHandler(user.resumeHandler)
user.Session.AddHandler(user.connectedHandler)
user.Session.AddHandler(user.disconnectedHandler)
user.Session.AddHandler(user.invalidAuthHandler)
user.Session.AddHandler(user.guildCreateHandler)
user.Session.AddHandler(user.guildDeleteHandler)
user.Session.AddHandler(user.guildUpdateHandler)
user.Session.AddHandler(user.guildRoleCreateHandler)
user.Session.AddHandler(user.guildRoleUpdateHandler)
user.Session.AddHandler(user.guildRoleDeleteHandler)
user.Session.AddHandler(user.channelCreateHandler)
user.Session.AddHandler(user.channelDeleteHandler)
user.Session.AddHandler(user.channelPinsUpdateHandler)
user.Session.AddHandler(user.channelUpdateHandler)
user.Session.AddHandler(user.messageCreateHandler)
user.Session.AddHandler(user.messageDeleteHandler)
user.Session.AddHandler(user.messageUpdateHandler)
user.Session.AddHandler(user.reactionAddHandler)
user.Session.AddHandler(user.reactionRemoveHandler)
user.Session.AddHandler(user.messageAckHandler)
user.Session.AddHandler(user.typingStartHandler)
user.Session.AddHandler(user.interactionSuccessHandler)
return user.Session.Open()
}
func (user *User) eventHandlerSync(rawEvt any) {
go user.eventHandler(rawEvt)
}
func (user *User) eventHandler(rawEvt any) {
switch evt := rawEvt.(type) {
case *discordgo.Ready:
user.readyHandler(evt)
case *discordgo.Resumed:
user.resumeHandler(evt)
case *discordgo.Connect:
user.connectedHandler(evt)
case *discordgo.Disconnect:
user.disconnectedHandler(evt)
case *discordgo.InvalidAuth:
user.invalidAuthHandler(evt)
case *discordgo.GuildCreate:
user.guildCreateHandler(evt)
case *discordgo.GuildDelete:
user.guildDeleteHandler(evt)
case *discordgo.GuildUpdate:
user.guildUpdateHandler(evt)
case *discordgo.GuildRoleCreate:
user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
case *discordgo.GuildRoleUpdate:
user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
case *discordgo.GuildRoleDelete:
user.bridge.DB.Role.DeleteByID(evt.GuildID, evt.RoleID)
case *discordgo.ChannelCreate:
user.channelCreateHandler(evt)
case *discordgo.ChannelDelete:
user.channelDeleteHandler(evt)
case *discordgo.ChannelUpdate:
user.channelUpdateHandler(evt)
case *discordgo.ChannelRecipientAdd:
user.channelRecipientAdd(evt)
case *discordgo.ChannelRecipientRemove:
user.channelRecipientRemove(evt)
case *discordgo.RelationshipAdd:
user.relationshipAddHandler(evt)
case *discordgo.RelationshipRemove:
user.relationshipRemoveHandler(evt)
case *discordgo.RelationshipUpdate:
user.relationshipUpdateHandler(evt)
case *discordgo.MessageCreate:
user.pushPortalMessage(evt, "message create", evt.ChannelID, evt.GuildID)
case *discordgo.MessageDelete:
user.pushPortalMessage(evt, "message delete", evt.ChannelID, evt.GuildID)
case *discordgo.MessageDeleteBulk:
user.pushPortalMessage(evt, "bulk message delete", evt.ChannelID, evt.GuildID)
case *discordgo.MessageUpdate:
user.pushPortalMessage(evt, "message update", evt.ChannelID, evt.GuildID)
case *discordgo.MessageReactionAdd:
user.pushPortalMessage(evt, "reaction add", evt.ChannelID, evt.GuildID)
case *discordgo.MessageReactionRemove:
user.pushPortalMessage(evt, "reaction remove", evt.ChannelID, evt.GuildID)
case *discordgo.MessageAck:
user.messageAckHandler(evt)
case *discordgo.TypingStart:
user.typingStartHandler(evt)
case *discordgo.InteractionSuccess:
user.interactionSuccessHandler(evt)
case *discordgo.ThreadListSync:
user.threadListSyncHandler(evt)
case *discordgo.Event:
// Ignore
default:
user.log.Debug().Type("event_type", evt).Msg("Unhandled event")
}
}
func (user *User) Disconnect() error {
user.Lock()
defer user.Unlock()
@@ -619,7 +688,24 @@ func (user *User) getGuildBridgingMode(guildID string) database.GuildBridgingMod
return guild.BridgingMode
}
func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
type ChannelSlice []*discordgo.Channel
func (s ChannelSlice) Len() int {
return len(s)
}
func (s ChannelSlice) Less(i, j int) bool {
if s[i].Position != 0 || s[j].Position != 0 {
return s[i].Position < s[j].Position
}
return compareMessageIDs(s[i].LastMessageID, s[j].LastMessageID) == 1
}
func (s ChannelSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (user *User) readyHandler(r *discordgo.Ready) {
user.log.Debug().Msg("Discord connection ready")
user.bridgeStateLock.Lock()
user.wasLoggedOut = false
@@ -642,6 +728,10 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling})
user.tryAutomaticDoublePuppeting()
for _, relationship := range r.Relationships {
user.relationships[relationship.ID] = relationship
}
updateTS := time.Now()
portalsInSpace := make(map[string]bool)
for _, guild := range user.GetPortals() {
@@ -650,6 +740,8 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
for _, guild := range r.Guilds {
user.handleGuild(guild, updateTS, portalsInSpace[guild.ID])
}
// The private channel list doesn't seem to be sorted by default, so sort it by message IDs (highest=newest first)
sort.Sort(ChannelSlice(r.PrivateChannels))
for i, ch := range r.PrivateChannels {
portal := user.GetPortalByMeta(ch)
user.handlePrivateChannel(portal, ch, updateTS, i < user.bridge.Config.Bridge.PrivateChannelCreateLimit, portalsInSpace[portal.Key.ChannelID])
@@ -659,7 +751,7 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
if r.ReadState != nil && r.ReadState.Version > user.ReadStateVersion {
// TODO can we figure out which read states are actually new?
for _, entry := range r.ReadState.Entries {
user.messageAckHandler(nil, &discordgo.MessageAck{
user.messageAckHandler(&discordgo.MessageAck{
MessageID: string(entry.LastMessageID),
ChannelID: entry.ID,
})
@@ -696,7 +788,7 @@ func (user *User) subscribeGuilds(delay time.Duration) {
}
}
func (user *User) resumeHandler(_ *discordgo.Session, r *discordgo.Resumed) {
func (user *User) resumeHandler(_ *discordgo.Resumed) {
user.log.Debug().Msg("Discord connection resumed")
user.subscribeGuilds(0 * time.Second)
}
@@ -718,6 +810,55 @@ func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
}
}
func (user *User) relationshipAddHandler(r *discordgo.RelationshipAdd) {
user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship added")
user.relationships[r.ID] = r.Relationship
user.handleRelationshipChange(r.ID, r.Nickname)
}
func (user *User) relationshipUpdateHandler(r *discordgo.RelationshipUpdate) {
user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship update")
user.relationships[r.ID] = r.Relationship
user.handleRelationshipChange(r.ID, r.Nickname)
}
func (user *User) relationshipRemoveHandler(r *discordgo.RelationshipRemove) {
user.log.Debug().Str("other_user_id", r.ID).Msg("Relationship removed")
delete(user.relationships, r.ID)
user.handleRelationshipChange(r.ID, "")
}
func (user *User) handleRelationshipChange(userID, nickname string) {
puppet := user.bridge.GetPuppetByID(userID)
portal := user.FindPrivateChatWith(userID)
if portal == nil || puppet == nil {
return
}
updated := portal.FriendNick == (nickname != "")
portal.FriendNick = nickname != ""
if nickname != "" {
updated = portal.UpdateNameDirect(nickname, true)
} else if portal.Name != puppet.Name {
if portal.shouldSetDMRoomMetadata() {
updated = portal.UpdateNameDirect(puppet.Name, false)
} else if portal.NameSet {
_, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateRoomName, "", map[string]any{})
if err != nil {
portal.log.Warn().Err(err).Msg("Failed to clear room name after friend nickname was removed")
} else {
portal.log.Debug().Msg("Cleared room name after friend nickname was removed")
portal.NameSet = false
portal.Update()
updated = true
}
}
}
if !updated {
portal.Update()
}
}
func (user *User) handlePrivateChannel(portal *Portal, meta *discordgo.Channel, timestamp time.Time, create, isInSpace bool) {
if create && portal.MXID == "" {
err := portal.CreateMatrixRoom(user, meta)
@@ -728,7 +869,7 @@ func (user *User) handlePrivateChannel(portal *Portal, meta *discordgo.Channel,
}
} else {
portal.UpdateInfo(user, meta)
portal.ForwardBackfillMissed(user, meta)
portal.ForwardBackfillMissed(user, meta.LastMessageID, nil)
}
user.MarkInPortal(database.UserPortal{
DiscordID: portal.Key.ChannelID,
@@ -760,7 +901,7 @@ func (user *User) addGuildToSpace(guild *Guild, isInSpace bool, timestamp time.T
return isInSpace
}
func (user *User) discordRoleToDB(guildID string, role *discordgo.Role, dbRole *database.Role) (*database.Role, bool) {
func (user *User) discordRoleToDB(guildID string, role *discordgo.Role, dbRole *database.Role, txn dbutil.Execable) bool {
var changed bool
if dbRole == nil {
dbRole = user.bridge.DB.Role.New()
@@ -778,7 +919,10 @@ func (user *User) discordRoleToDB(guildID string, role *discordgo.Role, dbRole *
dbRole.Permissions != role.Permissions
}
dbRole.Role = *role
return dbRole, changed
if changed {
dbRole.Upsert(txn)
}
return changed
}
func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
@@ -793,11 +937,8 @@ func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
panic(err)
}
for _, role := range newRoles {
dbRole, changed := user.discordRoleToDB(guildID, role, existingRoleMap[role.ID])
user.discordRoleToDB(guildID, role, existingRoleMap[role.ID], txn)
delete(existingRoleMap, role.ID)
if changed {
dbRole.Upsert(txn)
}
}
for _, removeRole := range existingRoleMap {
removeRole.Delete(txn)
@@ -813,27 +954,16 @@ func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
}
}
func (user *User) guildRoleCreateHandler(_ *discordgo.Session, r *discordgo.GuildRoleCreate) {
dbRole, _ := user.discordRoleToDB(r.GuildID, r.Role, nil)
dbRole.Upsert(nil)
}
func (user *User) guildRoleUpdateHandler(_ *discordgo.Session, r *discordgo.GuildRoleUpdate) {
dbRole, _ := user.discordRoleToDB(r.GuildID, r.Role, nil)
dbRole.Upsert(nil)
}
func (user *User) guildRoleDeleteHandler(_ *discordgo.Session, r *discordgo.GuildRoleDelete) {
user.bridge.DB.Role.DeleteByID(r.GuildID, r.RoleID)
}
func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSpace bool) {
guild := user.bridge.GetGuildByID(meta.ID, true)
guild.UpdateInfo(user, meta)
if len(meta.Channels) > 0 {
for _, ch := range meta.Channels {
if !user.channelIsBridgeable(ch) {
continue
}
portal := user.GetPortalByMeta(ch)
if guild.BridgingMode >= database.GuildBridgeEverything && portal.MXID == "" && user.channelIsBridgeable(ch) {
if guild.BridgingMode >= database.GuildBridgeEverything && portal.MXID == "" {
err := portal.CreateMatrixRoom(user, ch)
if err != nil {
user.log.Error().Err(err).
@@ -844,7 +974,7 @@ func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSp
} else {
portal.UpdateInfo(user, ch)
if user.bridge.Config.Bridge.Backfill.MaxGuildMembers < 0 || meta.MemberCount < user.bridge.Config.Bridge.Backfill.MaxGuildMembers {
portal.ForwardBackfillMissed(user, ch)
portal.ForwardBackfillMissed(user, ch.LastMessageID, nil)
}
}
}
@@ -855,7 +985,7 @@ func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSp
user.addGuildToSpace(guild, isInSpace, timestamp)
}
func (user *User) connectedHandler(_ *discordgo.Session, _ *discordgo.Connect) {
func (user *User) connectedHandler(_ *discordgo.Connect) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
user.log.Debug().Msg("Connected to Discord")
@@ -865,7 +995,7 @@ func (user *User) connectedHandler(_ *discordgo.Session, _ *discordgo.Connect) {
}
}
func (user *User) disconnectedHandler(_ *discordgo.Session, _ *discordgo.Disconnect) {
func (user *User) disconnectedHandler(_ *discordgo.Disconnect) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
if user.wasLoggedOut {
@@ -877,7 +1007,7 @@ func (user *User) disconnectedHandler(_ *discordgo.Session, _ *discordgo.Disconn
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-transient-disconnect", Message: "Temporarily disconnected from Discord, trying to reconnect"})
}
func (user *User) invalidAuthHandler(_ *discordgo.Session, _ *discordgo.InvalidAuth) {
func (user *User) invalidAuthHandler(_ *discordgo.InvalidAuth) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
user.log.Info().Msg("Got logged out from Discord due to invalid token")
@@ -886,7 +1016,7 @@ func (user *User) invalidAuthHandler(_ *discordgo.Session, _ *discordgo.InvalidA
go user.Logout(false)
}
func (user *User) guildCreateHandler(_ *discordgo.Session, g *discordgo.GuildCreate) {
func (user *User) guildCreateHandler(g *discordgo.GuildCreate) {
user.log.Info().
Str("guild_id", g.ID).
Str("name", g.Name).
@@ -895,7 +1025,11 @@ func (user *User) guildCreateHandler(_ *discordgo.Session, g *discordgo.GuildCre
user.handleGuild(g.Guild, time.Now(), false)
}
func (user *User) guildDeleteHandler(_ *discordgo.Session, g *discordgo.GuildDelete) {
func (user *User) guildDeleteHandler(g *discordgo.GuildDelete) {
if g.Unavailable {
user.log.Info().Str("guild_id", g.ID).Msg("Ignoring guild delete event with unavailable flag")
return
}
user.log.Info().Str("guild_id", g.ID).Msg("Got guild delete event")
user.MarkNotInPortal(g.ID)
guild := user.bridge.GetGuildByID(g.ID, false)
@@ -911,12 +1045,36 @@ func (user *User) guildDeleteHandler(_ *discordgo.Session, g *discordgo.GuildDel
}
}
func (user *User) guildUpdateHandler(_ *discordgo.Session, g *discordgo.GuildUpdate) {
func (user *User) guildUpdateHandler(g *discordgo.GuildUpdate) {
user.log.Debug().Str("guild_id", g.ID).Msg("Got guild update event")
user.handleGuild(g.Guild, time.Now(), user.IsInSpace(g.ID))
}
func (user *User) channelCreateHandler(_ *discordgo.Session, c *discordgo.ChannelCreate) {
func (user *User) threadListSyncHandler(t *discordgo.ThreadListSync) {
for _, meta := range t.Threads {
log := user.log.With().
Str("action", "thread list sync").
Str("guild_id", t.GuildID).
Str("parent_id", meta.ParentID).
Str("thread_id", meta.ID).
Logger()
ctx := log.WithContext(context.Background())
thread := user.bridge.GetThreadByID(meta.ID, nil)
if thread == nil {
msg := user.bridge.DB.Message.GetByDiscordID(database.NewPortalKey(meta.ParentID, ""), meta.ID)
if len(msg) == 0 {
log.Debug().Msg("Found unknown thread in thread list sync and don't have message")
} else {
log.Debug().Msg("Found unknown thread in thread list sync for existing message, creating thread")
user.bridge.threadFound(ctx, user, msg[0], meta.ID, meta)
}
} else {
thread.Parent.ForwardBackfillMissed(user, meta.LastMessageID, thread)
}
}
}
func (user *User) channelCreateHandler(c *discordgo.ChannelCreate) {
if user.getGuildBridgingMode(c.GuildID) < database.GuildBridgeEverything {
user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
@@ -946,7 +1104,7 @@ func (user *User) channelCreateHandler(_ *discordgo.Session, c *discordgo.Channe
}
}
func (user *User) channelDeleteHandler(_ *discordgo.Session, c *discordgo.ChannelDelete) {
func (user *User) channelDeleteHandler(c *discordgo.ChannelDelete) {
portal := user.GetExistingPortalByID(c.ID)
if portal == nil {
user.log.Debug().
@@ -967,11 +1125,7 @@ func (user *User) channelDeleteHandler(_ *discordgo.Session, c *discordgo.Channe
Msg("Completed cleaning up channel")
}
func (user *User) channelPinsUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
user.log.Debug().Msg("channel pins update")
}
func (user *User) channelUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelUpdate) {
func (user *User) channelUpdateHandler(c *discordgo.ChannelUpdate) {
portal := user.GetPortalByMeta(c.Channel)
if c.GuildID == "" {
user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String()))
@@ -980,6 +1134,20 @@ func (user *User) channelUpdateHandler(_ *discordgo.Session, c *discordgo.Channe
}
}
func (user *User) channelRecipientAdd(c *discordgo.ChannelRecipientAdd) {
portal := user.GetExistingPortalByID(c.ChannelID)
if portal != nil {
portal.syncParticipant(user, c.User, false)
}
}
func (user *User) channelRecipientRemove(c *discordgo.ChannelRecipientRemove) {
portal := user.GetExistingPortalByID(c.ChannelID)
if portal != nil {
portal.syncParticipant(user, c.User, true)
}
}
func (user *User) findPortal(channelID string) (*Portal, *Thread) {
portal := user.GetExistingPortalByID(channelID)
if portal != nil {
@@ -1037,31 +1205,21 @@ func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildI
return
}
portal.discordMessages <- portalDiscordMessage{
wrappedMsg := portalDiscordMessage{
msg: msg,
user: user,
thread: thread,
}
}
func (user *User) messageCreateHandler(_ *discordgo.Session, m *discordgo.MessageCreate) {
user.pushPortalMessage(m, "message create", m.ChannelID, m.GuildID)
}
func (user *User) messageDeleteHandler(_ *discordgo.Session, m *discordgo.MessageDelete) {
user.pushPortalMessage(m, "message delete", m.ChannelID, m.GuildID)
}
func (user *User) messageUpdateHandler(_ *discordgo.Session, m *discordgo.MessageUpdate) {
user.pushPortalMessage(m, "message update", m.ChannelID, m.GuildID)
}
func (user *User) reactionAddHandler(_ *discordgo.Session, m *discordgo.MessageReactionAdd) {
user.pushPortalMessage(m, "reaction add", m.ChannelID, m.GuildID)
}
func (user *User) reactionRemoveHandler(_ *discordgo.Session, m *discordgo.MessageReactionRemove) {
user.pushPortalMessage(m, "reaction remove", m.ChannelID, m.GuildID)
select {
case portal.discordMessages <- wrappedMsg:
default:
user.log.Warn().
Str("discord_event", typeName).
Str("guild_id", guildID).
Str("channel_id", channelID).
Msg("Portal message buffer is full")
portal.discordMessages <- wrappedMsg
}
}
type CustomReadReceipt struct {
@@ -1088,7 +1246,7 @@ func (user *User) makeReadMarkerContent(eventID id.EventID) *CustomReadMarkers {
}
}
func (user *User) messageAckHandler(_ *discordgo.Session, m *discordgo.MessageAck) {
func (user *User) messageAckHandler(m *discordgo.MessageAck) {
portal := user.GetExistingPortalByID(m.ChannelID)
if portal == nil || portal.MXID == "" {
return
@@ -1121,15 +1279,22 @@ func (user *User) messageAckHandler(_ *discordgo.Session, m *discordgo.MessageAc
}
}
func (user *User) typingStartHandler(_ *discordgo.Session, t *discordgo.TypingStart) {
func (user *User) typingStartHandler(t *discordgo.TypingStart) {
if t.UserID == user.DiscordID {
return
}
portal := user.GetExistingPortalByID(t.ChannelID)
if portal == nil || portal.MXID == "" {
return
}
targetUser := user.bridge.GetCachedUserByID(t.UserID)
if targetUser != nil {
return
}
portal.handleDiscordTyping(t)
}
func (user *User) interactionSuccessHandler(_ *discordgo.Session, s *discordgo.InteractionSuccess) {
func (user *User) interactionSuccessHandler(s *discordgo.InteractionSuccess) {
user.pendingInteractionsLock.Lock()
defer user.pendingInteractionsLock.Unlock()
ce, ok := user.pendingInteractions[s.Nonce]
@@ -1142,10 +1307,16 @@ func (user *User) interactionSuccessHandler(_ *discordgo.Session, s *discordgo.I
}
}
func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) bool {
func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect, ignoreCache bool) bool {
if roomID == "" {
return false
}
if intent == nil {
intent = user.bridge.Bot
}
if !ignoreCache && intent.StateStore.IsInvited(roomID, user.MXID) {
return true
}
ret := false
inviteContent := event.Content{
@@ -1284,6 +1455,8 @@ func (user *User) bridgeGuild(guildID string, everything bool) error {
}
if everything {
guild.BridgingMode = database.GuildBridgeEverything
} else {
guild.BridgingMode = database.GuildBridgeCreateOnMessage
}
guild.Update()