From 64c92ca78332f1688ff58739f237b9c120afafee Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 15 Aug 2024 15:51:22 +0300 Subject: [PATCH 01/99] Add icon for IDEA --- .idea/icon.svg | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .idea/icon.svg diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000..87eeadb --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,16 @@ + + + + + From 0a7b8bf41b9bfa27cf3ef1c70ae59d32f1800eda Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 15 Aug 2024 16:43:13 +0300 Subject: [PATCH 02/99] all: init v2 and delete old bridge --- .github/workflows/go.yml | 7 +- .gitlab-ci.yml | 2 +- .pre-commit-config.yaml | 8 +- CHANGELOG.md | 8 + Dockerfile | 9 +- Dockerfile.ci | 7 +- Dockerfile.dev | 18 - LICENSE.exceptions | 12 + ROADMAP.md | 79 +- attachments.go | 348 --- backfill.go | 380 --- build.sh | 4 +- cmd/mautrix-discord/main.go | 43 + commands.go | 900 ------ commands_botinteraction.go | 318 --- config/bridge.go | 237 -- config/upgrade.go | 149 - custompuppet.go | 72 - database/database.go | 76 - database/file.go | 138 - database/guild.go | 194 -- database/message.go | 258 -- database/portal.go | 210 -- database/puppet.go | 151 - database/reaction.go | 124 - database/role.go | 112 - database/thread.go | 111 - database/upgrades/00-latest-revision.sql | 179 -- database/upgrades/02-column-renames.sql | 53 - database/upgrades/03-spaces.sql | 73 - .../upgrades/04-attachment-fix.postgres.sql | 20 - .../upgrades/04-attachment-fix.sqlite.sql | 45 - database/upgrades/05-reaction-fkey-fix.sql | 8 - .../upgrades/06-user-read-state-version.sql | 2 - database/upgrades/07-store-role-info.sql | 19 - database/upgrades/08-channel-plain-name.sql | 9 - database/upgrades/09-more-thread-data.sql | 9 - .../10-remove-broken-double-puppets.sql | 2 - .../upgrades/11-cache-reuploaded-files.sql | 18 - database/upgrades/12-file-cache-mime-type.sql | 4 - .../13-merge-emoji-and-file.postgres.sql | 4 - .../13-merge-emoji-and-file.sqlite.sql | 24 - database/upgrades/14-guild-bridging-mode.sql | 7 - database/upgrades/15-portal-relay-webhook.sql | 3 - database/upgrades/16-add-contact-info.sql | 3 - .../upgrades/17-dm-portal-friend-nick.sql | 2 - database/upgrades/18-extra-ghost-metadata.sql | 4 - .../upgrades/19-message-edit-ts.postgres.sql | 15 - .../upgrades/19-message-edit-ts.sqlite.sql | 48 - database/upgrades/20-message-sender-mxid.sql | 2 - database/upgrades/21-more-puppet-info.sql | 3 - .../upgrades/22-file-cache-duplicate-mxc.sql | 26 - .../upgrades/23-puppet-is-application.sql | 2 - database/user.go | 101 - database/userportal.go | 140 - directmedia.go | 658 ----- directmedia_id.go | 287 -- discord.go | 52 - docker-run.sh | 2 +- example-config.yaml | 370 --- formatter.go | 246 -- formatter_everyone.go | 110 - formatter_tag.go | 343 --- formatter_test.go | 57 - go.mod | 40 +- go.sum | 61 +- guildportal.go | 334 --- main.go | 206 -- pkg/connector/chatinfo.go | 28 + pkg/connector/client.go | 58 + pkg/connector/config.go | 26 + pkg/connector/connector.go | 48 + .../upgrades.go => pkg/connector/dbmeta.go | 18 +- pkg/connector/handlematrix.go | 72 + config/config.go => pkg/connector/login.go | 24 +- pkg/connector/userinfo.go | 34 + {remoteauth => pkg/remoteauth}/README.md | 0 {remoteauth => pkg/remoteauth}/client.go | 0 .../remoteauth}/clientpackets.go | 0 .../remoteauth}/serverpackets.go | 3 +- {remoteauth => pkg/remoteauth}/user.go | 0 portal.go | 2494 ----------------- portal_convert.go | 732 ----- provisioning.go | 552 ---- puppet.go | 386 --- thread.go | 157 -- user.go | 1484 ---------- 87 files changed, 458 insertions(+), 13224 deletions(-) delete mode 100644 Dockerfile.dev create mode 100644 LICENSE.exceptions delete mode 100644 attachments.go delete mode 100644 backfill.go create mode 100644 cmd/mautrix-discord/main.go delete mode 100644 commands.go delete mode 100644 commands_botinteraction.go delete mode 100644 config/bridge.go delete mode 100644 config/upgrade.go delete mode 100644 custompuppet.go delete mode 100644 database/database.go delete mode 100644 database/file.go delete mode 100644 database/guild.go delete mode 100644 database/message.go delete mode 100644 database/portal.go delete mode 100644 database/puppet.go delete mode 100644 database/reaction.go delete mode 100644 database/role.go delete mode 100644 database/thread.go delete mode 100644 database/upgrades/00-latest-revision.sql delete mode 100644 database/upgrades/02-column-renames.sql delete mode 100644 database/upgrades/03-spaces.sql delete mode 100644 database/upgrades/04-attachment-fix.postgres.sql delete mode 100644 database/upgrades/04-attachment-fix.sqlite.sql delete mode 100644 database/upgrades/05-reaction-fkey-fix.sql delete mode 100644 database/upgrades/06-user-read-state-version.sql delete mode 100644 database/upgrades/07-store-role-info.sql delete mode 100644 database/upgrades/08-channel-plain-name.sql delete mode 100644 database/upgrades/09-more-thread-data.sql delete mode 100644 database/upgrades/10-remove-broken-double-puppets.sql delete mode 100644 database/upgrades/11-cache-reuploaded-files.sql delete mode 100644 database/upgrades/12-file-cache-mime-type.sql delete mode 100644 database/upgrades/13-merge-emoji-and-file.postgres.sql delete mode 100644 database/upgrades/13-merge-emoji-and-file.sqlite.sql delete mode 100644 database/upgrades/14-guild-bridging-mode.sql delete mode 100644 database/upgrades/15-portal-relay-webhook.sql delete mode 100644 database/upgrades/16-add-contact-info.sql delete mode 100644 database/upgrades/17-dm-portal-friend-nick.sql delete mode 100644 database/upgrades/18-extra-ghost-metadata.sql delete mode 100644 database/upgrades/19-message-edit-ts.postgres.sql delete mode 100644 database/upgrades/19-message-edit-ts.sqlite.sql delete mode 100644 database/upgrades/20-message-sender-mxid.sql delete mode 100644 database/upgrades/21-more-puppet-info.sql delete mode 100644 database/upgrades/22-file-cache-duplicate-mxc.sql delete mode 100644 database/upgrades/23-puppet-is-application.sql delete mode 100644 database/user.go delete mode 100644 database/userportal.go delete mode 100644 directmedia.go delete mode 100644 directmedia_id.go delete mode 100644 discord.go delete mode 100644 example-config.yaml delete mode 100644 formatter.go delete mode 100644 formatter_everyone.go delete mode 100644 formatter_tag.go delete mode 100644 formatter_test.go delete mode 100644 guildportal.go delete mode 100644 main.go create mode 100644 pkg/connector/chatinfo.go create mode 100644 pkg/connector/client.go create mode 100644 pkg/connector/config.go create mode 100644 pkg/connector/connector.go rename database/upgrades/upgrades.go => pkg/connector/dbmeta.go (78%) create mode 100644 pkg/connector/handlematrix.go rename config/config.go => pkg/connector/login.go (63%) create mode 100644 pkg/connector/userinfo.go rename {remoteauth => pkg/remoteauth}/README.md (100%) rename {remoteauth => pkg/remoteauth}/client.go (100%) rename {remoteauth => pkg/remoteauth}/clientpackets.go (100%) rename {remoteauth => pkg/remoteauth}/serverpackets.go (98%) rename {remoteauth => pkg/remoteauth}/user.go (100%) delete mode 100644 portal.go delete mode 100644 portal_convert.go delete mode 100644 provisioning.go delete mode 100644 puppet.go delete mode 100644 thread.go delete mode 100644 user.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3d392af..f1eeec1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,8 +8,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.21", "1.22"] - name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }} + go-version: ["1.22", "1.23"] + name: Lint ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }} steps: - uses: actions/checkout@v4 @@ -23,9 +23,10 @@ jobs: - name: Install libolm run: sudo apt-get install libolm-dev libolm3 - - name: Install goimports + - name: Install dependencies run: | go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest export PATH="$HOME/go/bin:$PATH" - name: Install pre-commit diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9037dbd..2fa759a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,3 @@ include: - project: 'mautrix/ci' - file: '/go.yml' + file: '/gov2-as-default.yml' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6da4e37..a6d0bdd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace exclude_types: [markdown] @@ -12,9 +12,15 @@ repos: rev: v1.0.0-rc.1 hooks: - id: go-imports-repo + args: + - "-local" + - "go.mau.fi/mautrix-discord" + - "-w" - id: go-vet-repo-mod + - id: go-staticcheck-repo-mod - repo: https://github.com/beeper/pre-commit-go rev: v0.3.1 hooks: - id: zerolog-ban-msgf + - id: zerolog-use-stringer diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0716e..a63f551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v0.8.0 (unreleased) + +* Bumped minimum Go version to 1.22. +* Rewrote bridge using bridgev2 architecture. + * It is recommended to check the config file after upgrading. If you have + prevented the bridge from writing to the config, you should update it + manually. + # v0.7.0 (2024-07-16) * Bumped minimum Go version to 1.21. diff --git a/Dockerfile b/Dockerfile index 1287e60..34b55df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie +FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19 AS lottie -FROM golang:1-alpine3.18 AS builder +FROM golang:1-alpine3.20 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev @@ -8,18 +8,17 @@ COPY . /build WORKDIR /build RUN go build -o /usr/bin/mautrix-discord -FROM alpine:3.18 +FROM alpine:3.20 ENV UID=1337 \ GID=1337 -RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \ +RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq-go curl \ zlib libpng giflib libstdc++ libgcc COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord -COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data diff --git a/Dockerfile.ci b/Dockerfile.ci index 0237eec..b435551 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,18 +1,17 @@ -FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie +FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19 AS lottie -FROM alpine:3.18 +FROM alpine:3.20 ENV UID=1337 \ GID=1337 -RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \ +RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go \ zlib libpng giflib libstdc++ libgcc COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter ARG EXECUTABLE=./mautrix-discord COPY $EXECUTABLE /usr/bin/mautrix-discord -COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./docker-run.sh /docker-run.sh VOLUME /data diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 11901df..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,18 +0,0 @@ -FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie - -FROM golang:1-alpine3.18 AS builder - -RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \ - zlib libpng giflib libstdc++ libgcc - -COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ -COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter -COPY . /build -WORKDIR /build -RUN go build -o /usr/bin/mautrix-discord - -# Setup development stack using gow -RUN go install github.com/mitranim/gow@latest -RUN echo 'gow run /build $@' > /usr/bin/mautrix-discord \ - && chmod +x /usr/bin/mautrix-discord -VOLUME /data diff --git a/LICENSE.exceptions b/LICENSE.exceptions new file mode 100644 index 0000000..2754eb3 --- /dev/null +++ b/LICENSE.exceptions @@ -0,0 +1,12 @@ +The mautrix-discord developers grant the following special exceptions: + +* to Beeper the right to embed the program in the Beeper clients and servers, + and use and distribute the collective work without applying the license to + the whole. +* to Element the right to distribute compiled binaries of the program as a part + of the Element Server Suite and other server bundles without applying the + license. + +All exceptions are only valid under the condition that any modifications to +the source code of mautrix-discord remain publicly available under the terms +of the GNU AGPL version 3 or later. diff --git a/ROADMAP.md b/ROADMAP.md index aab2680..4e30a1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,24 +1,24 @@ # Features & roadmap * Matrix → Discord * [ ] Message content - * [x] Plain text - * [x] Formatted messages - * [x] Media/files - * [x] Replies - * [x] Threads + * [ ] Plain text + * [ ] Formatted messages + * [ ] Media/files + * [ ] Replies + * [ ] Threads * [ ] Custom emojis - * [x] Message redactions - * [x] Reactions - * [x] Unicode emojis + * [ ] Message redactions + * [ ] Reactions + * [ ] Unicode emojis * [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works) * [ ] Executing Discord bot commands - * [x] Basic arguments and subcommands + * [ ] Basic arguments and subcommands * [ ] Subcommand groups * [ ] Mention arguments * [ ] Attachment arguments * [ ] Presence - * [x] Typing notifications - * [x] Own read status + * [ ] Typing notifications + * [ ] Own read status * [ ] Power level * [ ] Membership actions * [ ] Invite @@ -31,37 +31,37 @@ * [ ] Initial room metadata * Discord → Matrix * [ ] Message content - * [x] Plain text - * [x] Formatted messages - * [x] Media/files - * [x] Replies - * [x] Threads - * [x] Auto-joining threads when opening + * [ ] Plain text + * [ ] Formatted messages + * [ ] Media/files + * [ ] Replies + * [ ] Threads + * [ ] Auto-joining threads when opening * [ ] Backfilling threads after joining - * [x] Custom emojis - * [x] Embeds + * [ ] Custom emojis + * [ ] Embeds * [ ] Interactive components - * [x] Interactions (commands) - * [x] @everyone/@here mentions into @room - * [x] Message deletions - * [x] Reactions - * [x] Unicode emojis - * [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027)) - * [x] Avatars + * [ ] Interactions (commands) + * [ ] @everyone/@here mentions into @room + * [ ] Message deletions + * [ ] Reactions + * [ ] Unicode emojis + * [ ] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027)) + * [ ] Avatars * [ ] Presence * [ ] Typing notifications (currently partial support: DMs work after you type in them) - * [x] Own read status + * [ ] Own read status * [ ] Role permissions * [ ] Membership actions * [ ] Invite * [ ] Join * [ ] Leave * [ ] Kick - * [x] Channel/group DM metadata changes - * [x] Title - * [x] Avatar - * [x] Description - * [x] Initial channel/group DM metadata + * [ ] Channel/group DM metadata changes + * [ ] Title + * [ ] Avatar + * [ ] Description + * [ ] Initial channel/group DM metadata * [ ] User metadata changes * [ ] Display name * [ ] Avatar @@ -69,11 +69,12 @@ * [ ] Display name * [ ] Avatar * Misc - * [x] Login methods - * [x] QR scan from mobile - * [x] Manually providing access token - * [x] Automatic portal creation - * [x] After login - * [x] When receiving DM + * [ ] Login methods + * [ ] QR scan from mobile + * [ ] Username/password + * [ ] Manually providing access token + * [ ] Automatic portal creation + * [ ] After login + * [ ] When receiving DM * [ ] Private chat creation by inviting Matrix puppet of Discord user to new room - * [x] Option to use own Matrix account for messages sent from other Discord clients + * [ ] Option to use own Matrix account for messages sent from other Discord clients diff --git a/attachments.go b/attachments.go deleted file mode 100644 index d710849..0000000 --- a/attachments.go +++ /dev/null @@ -1,348 +0,0 @@ -package main - -import ( - "bytes" - "context" - "errors" - "fmt" - "image" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gabriel-vasile/mimetype" - "go.mau.fi/util/exsync" - "go.mau.fi/util/ffmpeg" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/database" -) - -func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - for key, value := range discordgo.DroidDownloadHeaders { - req.Header.Set(key, value) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode > 300 { - data, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data) - } - if resp.Header.Get("Content-Length") != "" { - length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse content length: %w", err) - } else if length > maxSize { - return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize) - } - return io.ReadAll(resp.Body) - } else { - var mbe *http.MaxBytesError - data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize)) - if err != nil && errors.As(err, &mbe) { - return nil, fmt.Errorf("attachment too large (over %d)", maxSize) - } - return data, err - } -} - -func uploadDiscordAttachment(url string, data []byte) error { - req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) - if err != nil { - return err - } - for key, value := range discordgo.DroidFetchHeaders { - req.Header.Set(key, value) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode > 300 { - respData, _ := io.ReadAll(resp.Body) - return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData) - } - return nil -} - -func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) { - var file *event.EncryptedFileInfo - rawMXC := content.URL - - if content.File != nil { - file = content.File - rawMXC = file.URL - } - - mxc, err := rawMXC.Parse() - if err != nil { - return nil, err - } - - data, err := intent.DownloadBytes(mxc) - if err != nil { - return nil, err - } - - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return nil, err - } - } - - return data, nil -} - -func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) { - dbFile := br.DB.File.New() - dbFile.Timestamp = time.Now() - dbFile.URL = url - dbFile.ID = meta.AttachmentID - dbFile.EmojiName = meta.EmojiName - dbFile.Size = len(data) - dbFile.MimeType = mimetype.Detect(data).String() - if meta.MimeType == "" { - meta.MimeType = dbFile.MimeType - } - if strings.HasPrefix(meta.MimeType, "image/") { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) - dbFile.Width = cfg.Width - dbFile.Height = cfg.Height - } - - uploadMime := meta.MimeType - if encrypt { - dbFile.Encrypted = true - dbFile.DecryptionInfo = attachment.NewEncryptedFile() - dbFile.DecryptionInfo.EncryptInPlace(data) - uploadMime = "application/octet-stream" - } - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: uploadMime, - } - if br.Config.Homeserver.AsyncMedia { - resp, err := intent.CreateMXC() - if err != nil { - return nil, err - } - dbFile.MXC = resp.ContentURI - req.MXC = resp.ContentURI - req.UnstableUploadURL = resp.UnstableUploadURL - semaWg.Add(1) - go func() { - defer semaWg.Done() - _, err = intent.UploadMedia(req) - if err != nil { - br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err) - dbFile.Delete() - } - }() - } else { - uploaded, err := intent.UploadMedia(req) - if err != nil { - return nil, err - } - dbFile.MXC = uploaded.ContentURI - } - return dbFile, nil -} - -type AttachmentMeta struct { - AttachmentID string - MimeType string - EmojiName string - CopyIfMissing bool - Converter func([]byte) ([]byte, string, error) -} - -var NoMeta = AttachmentMeta{} - -type attachmentKey struct { - URL string - Encrypt bool -} - -func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) { - fps := br.Config.Bridge.AnimatedSticker.Args.FPS - width := br.Config.Bridge.AnimatedSticker.Args.Width - height := br.Config.Bridge.AnimatedSticker.Args.Height - target := br.Config.Bridge.AnimatedSticker.Target - var lottieTarget, outputMime string - switch target { - case "png": - lottieTarget = "png" - outputMime = "image/png" - fps = 1 - case "gif": - lottieTarget = "gif" - outputMime = "image/gif" - case "webm": - lottieTarget = "pngs" - outputMime = "video/webm" - case "webp": - lottieTarget = "pngs" - outputMime = "image/webp" - case "disable": - return data, "application/json", nil - default: - return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target) - } - - ctx := context.Background() - tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_") - if err != nil { - return nil, "", fmt.Errorf("failed to create temp dir: %w", err) - } - defer func() { - removErr := os.RemoveAll(tempdir) - if removErr != nil { - br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr) - } - }() - - lottieOutput := filepath.Join(tempdir, "out_") - if lottieTarget != "pngs" { - lottieOutput = filepath.Join(tempdir, "output."+lottieTarget) - } - cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps)) - cmd.Stdin = bytes.NewReader(data) - err = cmd.Run() - if err != nil { - return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err) - } - var path string - if lottieTarget == "pngs" { - var videoCodec string - outputExtension := "." + target - if target == "webm" { - videoCodec = "libvpx-vp9" - } else if target == "webp" { - videoCodec = "libwebp_anim" - } else { - panic(fmt.Errorf("impossible case: unknown target %q", target)) - } - path, err = ffmpeg.ConvertPath( - ctx, lottieOutput+"*.png", outputExtension, - []string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"}, - []string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target}, - false, - ) - if err != nil { - return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err) - } - } else { - path = lottieOutput - } - data, err = os.ReadFile(path) - if err != nil { - return nil, "", fmt.Errorf("failed to read converted file: %w", err) - } - return data, outputMime, nil -} - -func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) { - isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt) - returnDBFile = br.DB.File.Get(url, encrypt) - if returnDBFile == nil { - transferKey := attachmentKey{url, encrypt} - once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{}) - returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) { - if isCacheable { - onceDBFile = br.DB.File.Get(url, encrypt) - if onceDBFile != nil { - return - } - } - - const attachmentSizeVal = 1 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal) - cancel() - if onceErr != nil { - br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore") - onceErr = fmt.Errorf("reuploading timed out") - return - } - var semaWg sync.WaitGroup - semaWg.Add(1) - defer semaWg.Done() - go func() { - semaWg.Wait() - br.parallelAttachmentSemaphore.Release(attachmentSizeVal) - }() - - var data []byte - data, onceErr = downloadDiscordAttachment(url, br.MediaConfig.UploadSize) - if onceErr != nil { - return - } - - if meta.Converter != nil { - data, meta.MimeType, onceErr = meta.Converter(data) - if onceErr != nil { - onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr) - return - } - } - - onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg) - if onceErr != nil { - return - } - if isCacheable { - onceDBFile.Insert(nil) - } - br.attachmentTransfers.Delete(transferKey) - return - }) - } - return -} - -func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI { - mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated) - if !mxc.IsEmpty() { - return mxc - } - var url, mimeType string - if animated { - url = discordgo.EndpointEmojiAnimated(emojiID) - mimeType = "image/gif" - } else { - url = discordgo.EndpointEmoji(emojiID) - mimeType = "image/png" - } - dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{ - AttachmentID: emojiID, - MimeType: mimeType, - EmojiName: name, - }) - if err != nil { - portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix") - return id.ContentURI{} - } - return dbFile.MXC -} diff --git a/backfill.go b/backfill.go deleted file mode 100644 index 6e0f0a1..0000000 --- a/backfill.go +++ /dev/null @@ -1,380 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "sort" - - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/database" -) - -func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) { - log := portal.log - defer func() { - log.Debug().Msg("Forward backfill finished, unlocking lock") - portal.forwardBackfillLock.Unlock() - }() - // This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room. - if portal.forwardBackfillLock.TryLock() { - panic("forwardBackfillInitial() called without locking forwardBackfillLock") - } - - limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel - if portal.GuildID == "" { - limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM - if thread != nil { - limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread - thread.initialBackfillAttempted = true - } - } - if limit == 0 { - return - } - - with := log.With(). - Str("action", "initial backfill"). - Str("room_id", portal.MXID.String()). - Int("limit", limit) - if thread != nil { - with = with.Str("thread_id", thread.ID) - } - log = with.Logger() - - portal.backfillLimited(log, source, limit, "", thread) -} - -func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) { - if portal.MXID == "" { - return - } - - limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel - if portal.GuildID == "" { - limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM - if thread != nil { - limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread - } - } - if limit == 0 { - return - } - with := portal.log.With(). - Str("action", "missed event backfill"). - Str("room_id", portal.MXID.String()). - Int("limit", limit) - if thread != nil { - with = with.Str("thread_id", thread.ID) - } - log := with.Logger() - - portal.forwardBackfillLock.Lock() - defer portal.forwardBackfillLock.Unlock() - - var lastMessage *database.Message - if thread != nil { - lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID) - } else { - lastMessage = portal.bridge.DB.Message.GetLast(portal.Key) - } - if lastMessage == nil || serverLastMessageID == "" { - log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata") - return - } else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) { - log.Debug(). - Str("last_bridged_message", lastMessage.DiscordID). - Str("last_server_message", serverLastMessageID). - Msg("Not backfilling, last message in database is newer than last message in metadata") - return - } - log.Debug(). - Str("last_bridged_message", lastMessage.DiscordID). - Str("last_server_message", serverLastMessageID). - Msg("Backfilling missed messages") - if limit < 0 { - portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread) - } else { - portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread) - } -} - -const messageFetchChunkSize = 50 - -func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) { - var messages []*discordgo.Message - var before string - var foundAll bool - protoChannelID := portal.Key.ChannelID - if thread != nil { - protoChannelID = thread.ID - } - for { - log.Debug().Str("before_id", before).Msg("Fetching messages for backfill") - newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "") - if err != nil { - return nil, false, err - } - if until != "" { - for i, msg := range newMessages { - if compareMessageIDs(msg.ID, until) <= 0 { - log.Debug(). - Str("message_id", msg.ID). - Str("until_id", until). - Msg("Found message that was already bridged") - newMessages = newMessages[:i] - foundAll = true - break - } - } - } - messages = append(messages, newMessages...) - log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection") - if len(newMessages) < messageFetchChunkSize || len(messages) >= limit { - break - } - before = newMessages[len(newMessages)-1].ID - } - if len(messages) > limit { - foundAll = false - messages = messages[:limit] - } - return messages, foundAll, nil -} - -func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) { - messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread) - if err != nil { - log.Err(err).Msg("Error collecting messages to forward backfill") - return - } - log.Info(). - Int("count", len(messages)). - Bool("found_all", foundAll). - Msg("Collected messages to backfill") - sort.Sort(MessageSlice(messages)) - if !foundAll && after != "" { - _, err = portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Some messages may have been missed here while the bridge was offline.", - }, nil, 0) - if err != nil { - log.Warn().Err(err).Msg("Failed to send missed message warning") - } else { - log.Debug().Msg("Sent warning about possibly missed messages") - } - } - portal.sendBackfillBatch(log, source, messages, thread) -} - -func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) { - protoChannelID := portal.Key.ChannelID - if thread != nil { - protoChannelID = thread.ID - } - for { - log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill") - messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "") - if err != nil { - log.Err(err).Msg("Error fetching chunk of messages to forward backfill") - return - } - log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill") - sort.Sort(MessageSlice(messages)) - - portal.sendBackfillBatch(log, source, messages, thread) - - if len(messages) < messageFetchChunkSize { - // Assume that was all the missing messages - log.Debug().Msg("Chunk had less than 50 messages, stopping backfill") - return - } - after = messages[len(messages)-1].ID - } -} - -func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) { - if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint") - portal.forwardBatchSend(log, source, messages, thread) - } else { - log.Debug().Msg("Not using hungryserv, sending messages one by one") - for _, msg := range messages { - portal.handleDiscordMessageCreate(source, msg, thread) - } - } -} - -func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) { - evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread) - if len(evts) == 0 { - log.Warn().Msg("Didn't get any events to backfill") - return - } - log.Info().Int("events", len(evts)).Msg("Converted messages to backfill") - resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{ - Forward: true, - Events: evts, - }) - if err != nil { - log.Err(err).Msg("Error sending backfill batch") - return - } - for i, evtID := range resp.EventIDs { - dbMessages[i].MXID = evtID - if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread { - // TODO proper context - ctx := log.WithContext(context.Background()) - portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread) - } - } - portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages) -} - -func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) { - var discordThreadID string - var threadRootEvent, lastThreadEvent id.EventID - if thread != nil { - discordThreadID = thread.ID - threadRootEvent = thread.RootMXID - lastThreadEvent = threadRootEvent - lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID) - if lastInThread != nil { - lastThreadEvent = lastInThread.MXID - } - } - - evts := make([]*event.Event, 0, len(messages)) - dbMessages := make([]database.Message, 0, len(messages)) - metas := make([]*discordgo.Message, 0, len(messages)) - ctx := context.Background() - for _, msg := range messages { - for _, mention := range msg.Mentions { - puppet := portal.bridge.GetPuppetByID(mention.ID) - puppet.UpdateInfo(nil, mention, nil) - } - - puppet := portal.bridge.GetPuppetByID(msg.Author.ID) - puppet.UpdateInfo(source, msg.Author, msg) - intent := puppet.IntentFor(portal) - replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true) - mentions := portal.convertDiscordMentions(msg, false) - - ts, _ := discordgo.SnowflakeTimestamp(msg.ID) - log := log.With(). - Str("message_id", msg.ID). - Int("message_type", int(msg.Type)). - Str("author_id", msg.Author.ID). - Logger() - parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg) - for i, part := range parts { - if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil { - part.Content.RelatesTo = &event.RelatesTo{} - } - if threadRootEvent != "" { - part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent) - } - if replyTo != nil { - part.Content.RelatesTo.SetReplyTo(replyTo.EventID) - // Only set reply for first event - replyTo = nil - } - - part.Content.Mentions = mentions - // Only set mentions for first event, but keep empty object for rest - mentions = &event.Mentions{} - - partName := part.AttachmentID - // Always use blank part name for first part so that replies and other things - // can reference it without knowing about attachments. - if i == 0 { - partName = "" - } - evt := &event.Event{ - ID: portal.deterministicEventID(msg.ID, partName), - Type: part.Type, - Sender: intent.UserID, - Timestamp: ts.UnixMilli(), - Content: event.Content{ - Parsed: part.Content, - Raw: part.Extra, - }, - } - var err error - evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type) - if err != nil { - log.Err(err).Msg("Failed to encrypt event") - continue - } - intent.AddDoublePuppetValue(&evt.Content) - evts = append(evts, evt) - dbMessages = append(dbMessages, database.Message{ - Channel: portal.Key, - DiscordID: msg.ID, - SenderID: msg.Author.ID, - Timestamp: ts, - AttachmentID: part.AttachmentID, - SenderMXID: intent.UserID, - }) - if i == 0 { - metas = append(metas, msg) - } else { - metas = append(metas, nil) - } - lastThreadEvent = evt.ID - } - } - return evts, metas, dbMessages -} - -func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID { - data := fmt.Sprintf("%s/discord/%s/%s", portal.MXID, messageID, partName) - sum := sha256.Sum256([]byte(data)) - return id.EventID(fmt.Sprintf("$%s:discord.com", base64.RawURLEncoding.EncodeToString(sum[:]))) -} - -// compareMessageIDs compares two Discord message IDs. -// -// If the first ID is lower, -1 is returned. -// If the second ID is lower, 1 is returned. -// If the IDs are equal, 0 is returned. -func compareMessageIDs(id1, id2 string) int { - if id1 == id2 { - return 0 - } - if len(id1) < len(id2) { - return -1 - } else if len(id2) < len(id1) { - return 1 - } - if id1 < id2 { - return -1 - } - return 1 -} - -func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool { - return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1 -} - -type MessageSlice []*discordgo.Message - -var _ sort.Interface = (MessageSlice)(nil) - -func (a MessageSlice) Len() int { - return len(a) -} - -func (a MessageSlice) Swap(i, j int) { - a[i], a[j] = a[j], a[i] -} - -func (a MessageSlice) Less(i, j int) bool { - return compareMessageIDs(a[i].ID, a[j].ID) == -1 -} diff --git a/build.sh b/build.sh index 2409c5b..aa6d009 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,4 @@ #!/bin/sh -go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@" +MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') +GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@" diff --git a/cmd/mautrix-discord/main.go b/cmd/mautrix-discord/main.go new file mode 100644 index 0000000..641aa24 --- /dev/null +++ b/cmd/mautrix-discord/main.go @@ -0,0 +1,43 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + + "go.mau.fi/mautrix-discord/pkg/connector" +) + +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +var c = &connector.DiscordConnector{} +var m = mxmain.BridgeMain{ + Name: "mautrix-discord", + Description: "A Matrix-Discord puppeting bridge", + URL: "https://github.com/mautrix/discord", + Version: "0.8.0", + Connector: c, +} + +func main() { + m.InitVersion(Tag, Commit, BuildTime) + m.Run() +} diff --git a/commands.go b/commands.go deleted file mode 100644 index ab407ed..0000000 --- a/commands.go +++ /dev/null @@ -1,900 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "fmt" - "html" - "net/http" - "strconv" - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/skip2/go-qrcode" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/database" - "go.mau.fi/mautrix-discord/remoteauth" -) - -type WrappedCommandEvent struct { - *commands.Event - Bridge *DiscordBridge - User *User - Portal *Portal -} - -var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} - -func (br *DiscordBridge) RegisterCommands() { - proc := br.CommandProcessor.(*commands.Processor) - proc.AddHandlers( - cmdLoginToken, - cmdLoginQR, - cmdLogout, - cmdPing, - cmdReconnect, - cmdDisconnect, - cmdBridge, - cmdUnbridge, - cmdDeletePortal, - cmdCreatePortal, - cmdSetRelay, - cmdUnsetRelay, - cmdGuilds, - cmdRejoinSpace, - cmdDeleteAllPortals, - cmdExec, - cmdCommands, - ) -} - -func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { - return func(ce *commands.Event) { - user := ce.User.(*User) - var portal *Portal - if ce.Portal != nil { - portal = ce.Portal.(*Portal) - } - br := ce.Bridge.Child.(*DiscordBridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) - } -} - -var cmdLoginToken = &commands.FullHandler{ - Func: wrapCommand(fnLoginToken), - Name: "login-token", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Link the bridge to your Discord account by extracting the access token manually.", - Args: " <_token_>", - }, -} - -const discordTokenEpoch = 1293840000 - -func decodeToken(token string) (userID int64, err error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - err = fmt.Errorf("invalid number of parts in token") - return - } - var userIDStr []byte - userIDStr, err = base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - err = fmt.Errorf("invalid base64 in user ID part: %w", err) - return - } - _, err = base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - err = fmt.Errorf("invalid base64 in random part: %w", err) - return - } - _, err = base64.RawURLEncoding.DecodeString(parts[2]) - if err != nil { - err = fmt.Errorf("invalid base64 in checksum part: %w", err) - return - } - userID, err = strconv.ParseInt(string(userIDStr), 10, 64) - if err != nil { - err = fmt.Errorf("invalid number in decoded user ID part: %w", err) - return - } - return -} - -func fnLoginToken(ce *WrappedCommandEvent) { - if len(ce.Args) != 2 { - ce.Reply("**Usage**: `$cmdprefix login-token `") - return - } - ce.MarkRead() - defer ce.Redact() - if ce.User.IsLoggedIn() { - ce.Reply("You're already logged in") - return - } - token := ce.Args[1] - userID, err := decodeToken(token) - if err != nil { - ce.Reply("Invalid token") - return - } - switch strings.ToLower(ce.Args[0]) { - case "user": - // Token is used as-is - case "bot": - token = "Bot " + token - case "oauth": - token = "Bearer " + token - default: - ce.Reply("Token type must be `user`, `bot` or `oauth`") - return - } - ce.Reply("Connecting to Discord as user ID %d", userID) - if err = ce.User.Login(token); err != nil { - ce.Reply("Error connecting to Discord: %v", err) - return - } - ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username) -} - -var cmdLoginQR = &commands.FullHandler{ - Func: wrapCommand(fnLoginQR), - Name: "login-qr", - Aliases: []string{"login"}, - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Link the bridge to your Discord account by scanning a QR code.", - }, -} - -func fnLoginQR(ce *WrappedCommandEvent) { - if ce.User.IsLoggedIn() { - ce.Reply("You're already logged in") - return - } - - client, err := remoteauth.New() - if err != nil { - ce.Reply("Failed to prepare login: %v", err) - return - } - - qrChan := make(chan string) - doneChan := make(chan struct{}) - - var qrCodeEvent id.EventID - - go func() { - code := <-qrChan - resp := sendQRCode(ce, code) - qrCodeEvent = resp - }() - - ctx := context.Background() - - if err = client.Dial(ctx, qrChan, doneChan); err != nil { - close(qrChan) - close(doneChan) - ce.Reply("Error connecting to login websocket: %v", err) - return - } - - <-doneChan - - if qrCodeEvent != "" { - _, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent) - } - - user, err := client.Result() - if err != nil || len(user.Token) == 0 { - if restErr := (&discordgo.RESTError{}); errors.As(err, &restErr) && - restErr.Response.StatusCode == http.StatusBadRequest && - bytes.Contains(restErr.ResponseBody, []byte("captcha-required")) { - ce.Reply("Error logging in: %v\n\nCAPTCHAs are currently not supported - use token login instead", err) - } else { - ce.Reply("Error logging in: %v", err) - } - return - } else if err = ce.User.Login(user.Token); err != nil { - ce.Reply("Error connecting after login: %v", err) - return - } - ce.User.Lock() - ce.User.DiscordID = user.UserID - ce.User.Update() - ce.User.Unlock() - ce.Reply("Successfully logged in as @%s", user.Username) -} - -func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID { - url, ok := uploadQRCode(ce, code) - if !ok { - return "" - } - - content := event.MessageEventContent{ - MsgType: event.MsgImage, - Body: code, - URL: url.CUString(), - } - - resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content) - if err != nil { - ce.Log.Errorfln("Failed to send QR code: %v", err) - return "" - } - - return resp.EventID -} - -func uploadQRCode(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) { - qrCode, err := qrcode.Encode(code, qrcode.Low, 256) - if err != nil { - ce.Log.Errorln("Failed to encode QR code:", err) - ce.Reply("Failed to encode QR code: %v", err) - return id.ContentURI{}, false - } - - resp, err := ce.Bot.UploadBytes(qrCode, "image/png") - if err != nil { - ce.Log.Errorln("Failed to upload QR code:", err) - ce.Reply("Failed to upload QR code: %v", err) - return id.ContentURI{}, false - } - - return resp.ContentURI, true -} - -var cmdLogout = &commands.FullHandler{ - Func: wrapCommand(fnLogout), - Name: "logout", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Forget the stored Discord auth token.", - }, -} - -func fnLogout(ce *WrappedCommandEvent) { - wasLoggedIn := ce.User.DiscordID != "" - ce.User.Logout(false) - if wasLoggedIn { - ce.Reply("Logged out successfully.") - } else { - ce.Reply("You weren't logged in, but data was re-cleared just to be safe.") - } -} - -var cmdPing = &commands.FullHandler{ - Func: wrapCommand(fnPing), - Name: "ping", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Check your connection to Discord", - }, -} - -func fnPing(ce *WrappedCommandEvent) { - if ce.User.Session == nil { - if ce.User.DiscordToken == "" { - ce.Reply("You're not logged in") - } else { - ce.Reply("You have a Discord token stored, but are not connected for some reason 🤔") - } - } else if ce.User.wasDisconnected { - ce.Reply("You're logged in, but the Discord connection seems to be dead 💥") - } else { - ce.Reply("You're logged in as @%s (`%s`)", ce.User.Session.State.User.Username, ce.User.DiscordID) - } -} - -var cmdDisconnect = &commands.FullHandler{ - Func: wrapCommand(fnDisconnect), - Name: "disconnect", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Disconnect from Discord (without logging out)", - }, - RequiresLogin: true, -} - -func fnDisconnect(ce *WrappedCommandEvent) { - if !ce.User.Connected() { - ce.Reply("You're already not connected") - } else if err := ce.User.Disconnect(); err != nil { - ce.Reply("Error while disconnecting: %v", err) - } else { - ce.Reply("Successfully disconnected") - } -} - -var cmdReconnect = &commands.FullHandler{ - Func: wrapCommand(fnReconnect), - Name: "reconnect", - Aliases: []string{"connect"}, - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Reconnect to Discord after disconnecting", - }, - RequiresLogin: true, -} - -func fnReconnect(ce *WrappedCommandEvent) { - if ce.User.Connected() { - ce.Reply("You're already connected") - } else if err := ce.User.Connect(); err != nil { - ce.Reply("Error while reconnecting: %v", err) - } else { - ce.Reply("Successfully reconnected") - } -} - -var cmdRejoinSpace = &commands.FullHandler{ - Func: wrapCommand(fnRejoinSpace), - Name: "rejoin-space", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Ask the bridge for an invite to a space you left", - Args: "<_guild ID_/main/dms>", - }, - RequiresLogin: true, -} - -func fnRejoinSpace(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage**: `$cmdprefix rejoin-space `") - return - } - user := ce.User - if ce.Args[0] == "main" { - 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, 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") - } else { - ce.Reply("**Usage**: `$cmdprefix rejoin-space `") - return - } -} - -var roomModerator = event.Type{Type: "fi.mau.discord.admin", Class: event.StateEventType} - -var cmdSetRelay = &commands.FullHandler{ - Func: wrapCommand(fnSetRelay), - Name: "set-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Create or set a relay webhook for a portal", - Args: "[room ID] <​--url URL> OR <​--create [name]>", - }, - RequiresLogin: true, - RequiresEventLevel: roomModerator, -} - -const webhookURLFormat = "https://discord.com/api/webhooks/%d/%s" - -const selectRelayHelp = "Usage: `$cmdprefix [room ID] <​--url URL> OR <​--create [name]>`" - -func fnSetRelay(ce *WrappedCommandEvent) { - portal := ce.Portal - if len(ce.Args) > 0 && strings.HasPrefix(ce.Args[0], "!") { - portal = ce.Bridge.GetPortalByMXID(id.RoomID(ce.Args[0])) - if portal == nil { - ce.Reply("Portal with room ID %s not found", ce.Args[0]) - return - } - if ce.User.PermissionLevel < bridgeconfig.PermissionLevelAdmin { - levels, err := portal.MainIntent().PowerLevels(ce.RoomID) - if err != nil { - ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels") - ce.Reply("Failed to get room power levels to see if you're allowed to use that command") - return - } else if levels.GetUserLevel(ce.User.GetMXID()) < levels.GetEventLevel(roomModerator) { - ce.Reply("You don't have admin rights in that room") - return - } - } - ce.Args = ce.Args[1:] - } else if portal == nil { - ce.Reply("You must either run the command in a portal, or specify an internal room ID as the first parameter") - return - } - log := ce.ZLog.With().Str("channel_id", portal.Key.ChannelID).Logger() - if portal.GuildID == "" { - ce.Reply("Only guild channels can have relays") - return - } else if portal.RelayWebhookID != "" { - webhookMeta, err := relayClient.WebhookWithToken(portal.RelayWebhookID, portal.RelayWebhookSecret) - if err != nil { - log.Warn().Err(err).Msg("Failed to get existing webhook info") - ce.Reply("This channel has a relay webhook set, but getting its info failed: %v", err) - return - } - ce.Reply("This channel already has a relay webhook %s (%s)", webhookMeta.Name, webhookMeta.ID) - return - } else if len(ce.Args) == 0 { - ce.Reply(selectRelayHelp) - return - } - createType := strings.ToLower(strings.TrimLeft(ce.Args[0], "-")) - var webhookMeta *discordgo.Webhook - switch createType { - case "url": - if len(ce.Args) < 2 { - ce.Reply("Usage: `$cmdprefix [room ID] --url ") - return - } - ce.Redact() - var webhookID int64 - var webhookSecret string - _, err := fmt.Sscanf(ce.Args[1], webhookURLFormat, &webhookID, &webhookSecret) - if err != nil { - log.Warn().Str("webhook_url", ce.Args[1]).Err(err).Msg("Failed to parse provided webhook URL") - ce.Reply("Invalid webhook URL") - return - } - webhookMeta, err = relayClient.WebhookWithToken(strconv.FormatInt(webhookID, 10), webhookSecret) - if err != nil { - log.Warn().Err(err).Msg("Failed to get webhook info") - ce.Reply("Failed to get webhook info: %v", err) - return - } - case "create": - perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID) - if err != nil { - log.Warn().Err(err).Msg("Failed to check user permissions") - ce.Reply("Failed to check if you have permission to create webhooks") - return - } else if perms&discordgo.PermissionManageWebhooks == 0 { - log.Debug().Int64("perms", perms).Msg("User doesn't have permissions to manage webhooks in channel") - ce.Reply("You don't have permission to manage webhooks in that channel") - return - } - name := "mautrix" - if len(ce.Args) > 1 { - name = strings.Join(ce.Args[1:], " ") - } - log.Debug().Str("webhook_name", name).Msg("Creating webhook") - webhookMeta, err = ce.User.Session.WebhookCreate(portal.Key.ChannelID, name, "") - if err != nil { - log.Warn().Err(err).Msg("Failed to create webhook") - ce.Reply("Failed to create webhook: %v", err) - return - } - default: - ce.Reply(selectRelayHelp) - return - } - if portal.Key.ChannelID != webhookMeta.ChannelID { - log.Debug(). - Str("portal_channel_id", portal.Key.ChannelID). - Str("webhook_channel_id", webhookMeta.ChannelID). - Msg("Provided webhook is for wrong channel") - ce.Reply("That webhook is not for the right channel (expected %s, webhook is for %s)", portal.Key.ChannelID, webhookMeta.ChannelID) - return - } - log.Debug().Str("webhook_id", webhookMeta.ID).Msg("Setting portal relay webhook") - portal.RelayWebhookID = webhookMeta.ID - portal.RelayWebhookSecret = webhookMeta.Token - portal.Update() - ce.Reply("Saved webhook %s (%s) as portal relay webhook", webhookMeta.Name, portal.RelayWebhookID) -} - -var cmdUnsetRelay = &commands.FullHandler{ - Func: wrapCommand(fnUnsetRelay), - Name: "unset-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Disable the relay webhook and optionally delete it on Discord", - Args: "[--delete]", - }, - RequiresPortal: true, - RequiresEventLevel: roomModerator, -} - -func fnUnsetRelay(ce *WrappedCommandEvent) { - if ce.Portal.RelayWebhookID == "" { - ce.Reply("This portal doesn't have a relay webhook") - return - } - if len(ce.Args) > 0 && ce.Args[0] == "--delete" { - err := relayClient.WebhookDeleteWithToken(ce.Portal.RelayWebhookID, ce.Portal.RelayWebhookSecret) - if err != nil { - ce.Reply("Failed to delete webhook: %v", err) - return - } else { - ce.Reply("Successfully deleted webhook") - } - } else { - ce.Reply("Relay webhook disabled") - } - ce.Portal.RelayWebhookID = "" - ce.Portal.RelayWebhookSecret = "" - ce.Portal.Update() -} - -var cmdGuilds = &commands.FullHandler{ - Func: wrapCommand(fnGuilds), - Name: "guilds", - Aliases: []string{"servers", "guild", "server"}, - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Guild bridging management", - Args: " [_guild ID_] [...]", - }, - RequiresLogin: true, -} - -const smallGuildsHelp = "**Usage**: `$cmdprefix guilds [guild ID] [...]`" - -const fullGuildsHelp = smallGuildsHelp + ` - -* **help** - View this help message. -* **status** - View the list of guilds and their bridging status. -* **bridge <_guild ID_> [--entire]** - Enable bridging for a guild. The --entire flag auto-creates portals for all channels. -* **bridging-mode <_guild ID_> <_mode_>** - Set the mode for bridging messages and new channels in a guild. -* **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.` - -func fnGuilds(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply(fullGuildsHelp) - return - } - subcommand := strings.ToLower(ce.Args[0]) - ce.Args = ce.Args[1:] - switch subcommand { - case "status", "list": - fnListGuilds(ce) - case "bridge": - fnBridgeGuild(ce) - case "unbridge", "delete": - fnUnbridgeGuild(ce) - case "bridging-mode", "mode": - fnGuildBridgingMode(ce) - case "help": - ce.Reply(fullGuildsHelp) - default: - ce.Reply("Unknown subcommand `%s`\n\n"+smallGuildsHelp, subcommand) - } -} - -func fnListGuilds(ce *WrappedCommandEvent) { - var items []string - for _, userGuild := range ce.User.GetPortals() { - guild := ce.Bridge.GetGuildByID(userGuild.DiscordID, false) - if guild == nil { - continue - } - var avatarHTML string - if !guild.AvatarURL.IsEmpty() { - avatarHTML = fmt.Sprintf(` `, guild.AvatarURL.String()) - } - items = append(items, fmt.Sprintf("
  • %s%s (%s) - %s
  • ", avatarHTML, html.EscapeString(guild.Name), guild.ID, guild.BridgingMode.Description())) - } - if len(items) == 0 { - ce.Reply("No guilds found") - } else { - ce.ReplyAdvanced(fmt.Sprintf("

    List of guilds:

      %s
    ", strings.Join(items, "")), false, true) - } -} - -func fnBridgeGuild(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 || len(ce.Args) > 2 { - ce.Reply("**Usage**: `$cmdprefix guilds bridge [--entire]") - } else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil { - ce.Reply("Error bridging guild: %v", err) - } else { - ce.Reply("Successfully bridged guild") - } -} - -func fnUnbridgeGuild(ce *WrappedCommandEvent) { - if len(ce.Args) != 1 { - ce.Reply("**Usage**: `$cmdprefix guilds unbridge ") - } else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil { - ce.Reply("Error unbridging guild: %v", err) - } else { - ce.Reply("Successfully unbridged guild") - } -} - -const availableModes = "Available modes:\n" + - "* `nothing` to never bridge any messages (default when unbridged)\n" + - "* `if-portal-exists` to bridge messages in existing portals, but drop messages in unbridged channels\n" + - "* `create-on-message` to bridge all messages and create portals if necessary on incoming messages (default after bridging)\n" + - "* `everything` to bridge all messages and create portals proactively on bridge startup (default if bridged with `--entire`)\n" - -func fnGuildBridgingMode(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 || len(ce.Args) > 2 { - ce.Reply("**Usage**: `$cmdprefix guilds bridging-mode [mode]`\n\n" + availableModes) - return - } - guild := ce.Bridge.GetGuildByID(ce.Args[0], false) - if guild == nil { - ce.Reply("Guild not found") - return - } - if len(ce.Args) == 1 { - ce.Reply("%s (%s) is currently set to %s (`%s`)\n\n%s", guild.PlainName, guild.ID, guild.BridgingMode.Description(), guild.BridgingMode.String(), availableModes) - return - } - mode := database.ParseGuildBridgingMode(ce.Args[1]) - if mode == database.GuildBridgeInvalid { - ce.Reply("Invalid guild bridging mode `%s`", ce.Args[1]) - return - } - guild.BridgingMode = mode - guild.Update() - ce.Reply("Set guild bridging mode to %s", mode.Description()) -} - -var cmdBridge = &commands.FullHandler{ - Func: wrapCommand(fnBridge), - Name: "bridge", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Bridge this room to a specific Discord channel", - Args: "[--replace[=delete]] <_channel ID_>", - }, - RequiresEventLevel: roomModerator, -} - -func isNumber(str string) bool { - for _, chr := range str { - if chr < '0' || chr > '9' { - return false - } - } - return true -} - -func fnBridge(ce *WrappedCommandEvent) { - if ce.Portal != nil { - ce.Reply("This is already a portal room. Unbridge with `$cmdprefix unbridge` first if you want to link it to a different channel.") - return - } - var channelID string - var unbridgeOld, deleteOld bool - fail := true - for _, arg := range ce.Args { - arg = strings.ToLower(arg) - if arg == "--replace" { - unbridgeOld = true - } else if arg == "--replace=delete" { - unbridgeOld = true - deleteOld = true - } else if channelID == "" && isNumber(arg) { - channelID = arg - fail = false - } else { - fail = true - break - } - } - if fail { - ce.Reply("**Usage**: `$cmdprefix bridge [--replace[=delete]] `") - return - } - portal := ce.User.GetExistingPortalByID(channelID) - if portal == nil { - ce.Reply("Channel not found") - return - } - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - hasUnbridgePermission := ce.User.PermissionLevel >= bridgeconfig.PermissionLevelAdmin - if !hasUnbridgePermission { - levels, err := portal.MainIntent().PowerLevels(portal.MXID) - if errors.Is(err, mautrix.MNotFound) { - ce.ZLog.Debug().Err(err).Msg("Got M_NOT_FOUND trying to get power levels to check if user can unbridge it, assuming the room is gone") - hasUnbridgePermission = true - } else if err != nil { - ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels") - ce.Reply("Failed to get power levels in old room to see if you're allowed to unbridge it") - return - } else { - hasUnbridgePermission = levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(roomModerator) - } - } - if !unbridgeOld || !hasUnbridgePermission { - extraHelp := "Rerun the command with `--replace` or `--replace=delete` to unbridge the old room." - if !hasUnbridgePermission { - extraHelp = "Additionally, you do not have the permissions to unbridge the old room." - } - ce.Reply("That channel is already bridged to [%s](https://matrix.to/#/%s). %s", portal.Name, portal.MXID, extraHelp) - return - } - ce.ZLog.Debug(). - Str("old_room_id", portal.MXID.String()). - Bool("delete", deleteOld). - Msg("Unbridging old room") - portal.removeFromSpace() - portal.cleanup(!deleteOld) - portal.RemoveMXID() - ce.ZLog.Info(). - Str("old_room_id", portal.MXID.String()). - Bool("delete", deleteOld). - Msg("Unbridged old room to make space for new bridge") - } - if portal.Guild != nil && portal.Guild.BridgingMode < database.GuildBridgeIfPortalExists { - ce.ZLog.Debug().Str("guild_id", portal.Guild.ID).Msg("Bumping bridging mode of portal guild to if-portal-exists") - portal.Guild.BridgingMode = database.GuildBridgeIfPortalExists - portal.Guild.Update() - } - ce.ZLog.Debug().Str("channel_id", portal.Key.ChannelID).Msg("Bridging room") - portal.MXID = ce.RoomID - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - portal.updateRoomName() - portal.updateRoomAvatar() - portal.updateRoomTopic() - portal.updateSpace(ce.User) - portal.UpdateBridgeInfo() - state, err := portal.MainIntent().State(portal.MXID) - if err != nil { - ce.ZLog.Error().Err(err).Msg("Failed to update state cache for room") - } else { - encryptionEvent, isEncrypted := state[event.StateEncryption][""] - portal.Encrypted = isEncrypted && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1 - } - portal.Update() - ce.Reply("Room successfully bridged") - ce.ZLog.Info(). - Str("channel_id", portal.Key.ChannelID). - Bool("encrypted", portal.Encrypted). - Msg("Manual bridging complete") -} - -var cmdUnbridge = &commands.FullHandler{ - Func: wrapCommand(fnUnbridge), - Name: "unbridge", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Unbridge this room from the linked Discord channel", - }, - RequiresPortal: true, - RequiresEventLevel: roomModerator, -} - -var cmdCreatePortal = &commands.FullHandler{ - Func: wrapCommand(fnCreatePortal), - Name: "create-portal", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Create a portal for a specific channel", - Args: "<_channel ID_>", - }, - RequiresLogin: true, -} - -func fnCreatePortal(ce *WrappedCommandEvent) { - meta, err := ce.User.Session.Channel(ce.Args[0]) - if err != nil { - ce.Reply("Failed to get channel info: %v", err) - return - } else if meta == nil { - ce.Reply("Channel not found") - return - } else if !ce.User.channelIsBridgeable(meta) { - ce.Reply("That channel can't be bridged") - return - } - portal := ce.User.GetPortalByMeta(meta) - if portal.Guild != nil && portal.Guild.BridgingMode == database.GuildBridgeNothing { - ce.Reply("That guild is set to not bridge any messages. Bridge the guild with `$cmdprefix guilds bridge %s` first", portal.Guild.ID) - return - } else if portal.MXID != "" { - ce.Reply("That channel is already bridged: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL()) - return - } - err = portal.CreateMatrixRoom(ce.User, meta) - if err != nil { - ce.Reply("Failed to create portal: %v", err) - } else { - ce.Reply("Portal created: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL()) - } -} - -var cmdDeletePortal = &commands.FullHandler{ - Func: wrapCommand(fnUnbridge), - Name: "delete-portal", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Unbridge this room and kick all Matrix users", - }, - RequiresPortal: true, - RequiresEventLevel: roomModerator, -} - -func fnUnbridge(ce *WrappedCommandEvent) { - ce.Portal.roomCreateLock.Lock() - defer ce.Portal.roomCreateLock.Unlock() - ce.Portal.removeFromSpace() - ce.Portal.cleanup(ce.Command == "unbridge") - ce.Portal.RemoveMXID() -} - -var cmdDeleteAllPortals = &commands.FullHandler{ - Func: wrapCommand(fnDeleteAllPortals), - Name: "delete-all-portals", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAdmin, - Description: "Delete all portals.", - }, - RequiresAdmin: true, -} - -func fnDeleteAllPortals(ce *WrappedCommandEvent) { - portals := ce.Bridge.GetAllPortals() - guilds := ce.Bridge.GetAllGuilds() - if len(portals) == 0 && len(guilds) == 0 { - ce.Reply("Didn't find any portals") - return - } - - leave := func(mxid id.RoomID, intent *appservice.IntentAPI) { - if len(mxid) > 0 { - _, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{ - Reason: "Deleting portal", - UserID: ce.User.MXID, - }) - } - } - customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - intent := customPuppet.CustomIntent() - leave = func(mxid id.RoomID, _ *appservice.IntentAPI) { - if len(mxid) > 0 { - _, _ = intent.LeaveRoom(mxid) - _, _ = intent.ForgetRoom(mxid) - } - } - } - ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds)) - for _, portal := range portals { - portal.Delete() - leave(portal.MXID, portal.MainIntent()) - } - for _, guild := range guilds { - guild.Delete() - leave(guild.MXID, ce.Bot) - } - ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. You'll have to restart the bridge or relogin before rooms can be bridged again.") - - go func() { - for _, portal := range portals { - portal.cleanup(false) - } - ce.Reply("Finished background cleanup of deleted portal rooms.") - }() -} diff --git a/commands_botinteraction.go b/commands_botinteraction.go deleted file mode 100644 index 28a1340..0000000 --- a/commands_botinteraction.go +++ /dev/null @@ -1,318 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/google/shlex" - - "maunium.net/go/mautrix/bridge/commands" -) - -var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30} - -var cmdCommands = &commands.FullHandler{ - Func: wrapCommand(fnCommands), - Name: "commands", - Aliases: []string{"cmds", "cs"}, - Help: commands.HelpMeta{ - Section: HelpSectionDiscordBots, - Description: "View parameters of bot interaction commands on Discord", - Args: "search <_query_> OR help <_command_>", - }, - RequiresPortal: true, - RequiresLogin: true, -} - -var cmdExec = &commands.FullHandler{ - Func: wrapCommand(fnExec), - Name: "exec", - Aliases: []string{"command", "cmd", "c", "exec", "e"}, - Help: commands.HelpMeta{ - Section: HelpSectionDiscordBots, - Description: "Run bot interaction commands on Discord", - Args: "<_command_> [_arg=value ..._]", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func (portal *Portal) getCommand(user *User, command string) (*discordgo.ApplicationCommand, error) { - portal.commandsLock.Lock() - defer portal.commandsLock.Unlock() - cmd, ok := portal.commands[command] - if !ok { - results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command) - if err != nil { - return nil, err - } - for _, result := range results { - if result.Name == command { - portal.commands[result.Name] = result - cmd = result - break - } - } - if cmd == nil { - return nil, nil - } - } - return cmd, nil -} - -func getCommandOptionTypeName(optType discordgo.ApplicationCommandOptionType) string { - switch optType { - case discordgo.ApplicationCommandOptionSubCommand: - return "subcommand" - case discordgo.ApplicationCommandOptionSubCommandGroup: - return "subcommand group (unsupported)" - case discordgo.ApplicationCommandOptionString: - return "string" - case discordgo.ApplicationCommandOptionInteger: - return "integer" - case discordgo.ApplicationCommandOptionBoolean: - return "boolean" - case discordgo.ApplicationCommandOptionUser: - return "user (unsupported)" - case discordgo.ApplicationCommandOptionChannel: - return "channel (unsupported)" - case discordgo.ApplicationCommandOptionRole: - return "role (unsupported)" - case discordgo.ApplicationCommandOptionMentionable: - return "mentionable (unsupported)" - case discordgo.ApplicationCommandOptionNumber: - return "number" - case discordgo.ApplicationCommandOptionAttachment: - return "attachment (unsupported)" - default: - return fmt.Sprintf("unknown type %d", optType) - } -} - -func parseCommandOptionValue(optType discordgo.ApplicationCommandOptionType, value string) (any, error) { - switch optType { - case discordgo.ApplicationCommandOptionSubCommandGroup: - return nil, fmt.Errorf("subcommand groups aren't supported") - case discordgo.ApplicationCommandOptionString: - return value, nil - case discordgo.ApplicationCommandOptionInteger: - return strconv.ParseInt(value, 10, 64) - case discordgo.ApplicationCommandOptionBoolean: - return strconv.ParseBool(value) - case discordgo.ApplicationCommandOptionUser: - return nil, fmt.Errorf("user options aren't supported") - case discordgo.ApplicationCommandOptionChannel: - return nil, fmt.Errorf("channel options aren't supported") - case discordgo.ApplicationCommandOptionRole: - return nil, fmt.Errorf("role options aren't supported") - case discordgo.ApplicationCommandOptionMentionable: - return nil, fmt.Errorf("mentionable options aren't supported") - case discordgo.ApplicationCommandOptionNumber: - return strconv.ParseFloat(value, 64) - case discordgo.ApplicationCommandOptionAttachment: - return nil, fmt.Errorf("attachment options aren't supported") - default: - return nil, fmt.Errorf("unknown option type %d", optType) - } -} - -func indent(text, with string) string { - split := strings.Split(text, "\n") - for i, part := range split { - split[i] = with + part - } - return strings.Join(split, "\n") -} - -func formatOption(opt *discordgo.ApplicationCommandOption) string { - argText := fmt.Sprintf("* `%s`: %s", opt.Name, getCommandOptionTypeName(opt.Type)) - if strings.ToLower(opt.Description) != opt.Name { - argText += fmt.Sprintf(" - %s", opt.Description) - } - if opt.Required { - argText += " (required)" - } - if len(opt.Options) > 0 { - subopts := make([]string, len(opt.Options)) - for i, subopt := range opt.Options { - subopts[i] = indent(formatOption(subopt), " ") - } - argText += "\n" + strings.Join(subopts, "\n") - } - return argText -} - -func formatCommand(cmd *discordgo.ApplicationCommand) string { - baseText := fmt.Sprintf("$cmdprefix exec %s", cmd.Name) - if len(cmd.Options) > 0 { - args := make([]string, len(cmd.Options)) - argPlaceholder := "[arg=value ...]" - for i, opt := range cmd.Options { - args[i] = formatOption(opt) - if opt.Required { - argPlaceholder = "" - } - } - baseText = fmt.Sprintf("`%s %s` - %s\n%s", baseText, argPlaceholder, cmd.Description, strings.Join(args, "\n")) - } else { - baseText = fmt.Sprintf("`%s` - %s", baseText, cmd.Description) - } - return baseText -} - -func parseCommandOptions(opts []*discordgo.ApplicationCommandOption, subcommands []string, namedArgs map[string]string) (res []*discordgo.ApplicationCommandOptionInput, err error) { - subcommandDone := false - for _, opt := range opts { - optRes := &discordgo.ApplicationCommandOptionInput{ - Type: opt.Type, - Name: opt.Name, - } - if opt.Type == discordgo.ApplicationCommandOptionSubCommand { - if !subcommandDone && len(subcommands) > 0 && subcommands[0] == opt.Name { - subcommandDone = true - optRes.Options, err = parseCommandOptions(opt.Options, subcommands[1:], namedArgs) - if err != nil { - err = fmt.Errorf("error parsing subcommand %s: %v", opt.Name, err) - break - } - subcommands = subcommands[1:] - } else { - continue - } - } else if argVal, ok := namedArgs[opt.Name]; ok { - optRes.Value, err = parseCommandOptionValue(opt.Type, argVal) - if err != nil { - err = fmt.Errorf("error parsing parameter %s: %v", opt.Name, err) - break - } - } else if opt.Required { - switch opt.Type { - case discordgo.ApplicationCommandOptionSubCommandGroup, discordgo.ApplicationCommandOptionUser, - discordgo.ApplicationCommandOptionChannel, discordgo.ApplicationCommandOptionRole, - discordgo.ApplicationCommandOptionMentionable, discordgo.ApplicationCommandOptionAttachment: - err = fmt.Errorf("missing required parameter %s (which is not supported by the bridge)", opt.Name) - default: - err = fmt.Errorf("missing required parameter %s", opt.Name) - } - break - } else { - continue - } - res = append(res, optRes) - } - if len(subcommands) > 0 { - err = fmt.Errorf("unparsed subcommands left over (did you forget quoting for parameters with spaces?)") - } - return -} - -func executeCommand(cmd *discordgo.ApplicationCommand, args []string) (res []*discordgo.ApplicationCommandOptionInput, err error) { - namedArgs := map[string]string{} - n := 0 - for _, arg := range args { - name, value, isNamed := strings.Cut(arg, "=") - if isNamed { - namedArgs[name] = value - } else { - args[n] = arg - n++ - } - } - return parseCommandOptions(cmd.Options, args[:n], namedArgs) -} - -func fnCommands(ce *WrappedCommandEvent) { - if len(ce.Args) < 2 { - ce.Reply("**Usage**: `$cmdprefix commands search <_query_>` OR `$cmdprefix commands help <_command_>`") - return - } - subcmd := strings.ToLower(ce.Args[0]) - if subcmd == "search" { - results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1]) - if err != nil { - ce.Reply("Error searching for commands: %v", err) - return - } - formatted := make([]string, len(results)) - ce.Portal.commandsLock.Lock() - for i, result := range results { - ce.Portal.commands[result.Name] = result - formatted[i] = indent(formatCommand(result), " ") - formatted[i] = "*" + formatted[i][1:] - } - ce.Portal.commandsLock.Unlock() - ce.Reply("Found results:\n" + strings.Join(formatted, "\n")) - } else if subcmd == "help" { - command := strings.ToLower(ce.Args[1]) - cmd, err := ce.Portal.getCommand(ce.User, command) - if err != nil { - ce.Reply("Error searching for commands: %v", err) - } else if cmd == nil { - ce.Reply("Command %q not found", command) - } else { - ce.Reply(formatCommand(cmd)) - } - } -} - -func fnExec(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage**: `$cmdprefix exec [arg=value ...]`") - return - } - args, err := shlex.Split(ce.RawArgs) - if err != nil { - ce.Reply("Error parsing args with shlex: %v", err) - return - } - command := strings.ToLower(args[0]) - cmd, err := ce.Portal.getCommand(ce.User, command) - if err != nil { - ce.Reply("Error searching for commands: %v", err) - } else if cmd == nil { - ce.Reply("Command %q not found", command) - } else if options, err := executeCommand(cmd, args[1:]); err != nil { - ce.Reply("Error parsing arguments: %v\n\n**Usage:** "+formatCommand(cmd), err) - } else { - nonce := generateNonce() - ce.User.pendingInteractionsLock.Lock() - ce.User.pendingInteractions[nonce] = ce - ce.User.pendingInteractionsLock.Unlock() - err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce) - if err != nil { - ce.Reply("Error sending interaction: %v", err) - ce.User.pendingInteractionsLock.Lock() - delete(ce.User.pendingInteractions, nonce) - ce.User.pendingInteractionsLock.Unlock() - } else { - go func() { - time.Sleep(10 * time.Second) - ce.User.pendingInteractionsLock.Lock() - if _, stillWaiting := ce.User.pendingInteractions[nonce]; stillWaiting { - delete(ce.User.pendingInteractions, nonce) - ce.Reply("Timed out waiting for interaction success") - } - ce.User.pendingInteractionsLock.Unlock() - }() - } - } -} diff --git a/config/bridge.go b/config/bridge.go deleted file mode 100644 index dd6ad4e..0000000 --- a/config/bridge.go +++ /dev/null @@ -1,237 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "errors" - "fmt" - "strings" - "text/template" - - "github.com/bwmarrin/discordgo" - - "maunium.net/go/mautrix/bridge/bridgeconfig" -) - -type BridgeConfig struct { - UsernameTemplate string `yaml:"username_template"` - DisplaynameTemplate string `yaml:"displayname_template"` - ChannelNameTemplate string `yaml:"channel_name_template"` - GuildNameTemplate string `yaml:"guild_name_template"` - PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` - PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"` - - PortalMessageBuffer int `yaml:"portal_message_buffer"` - - PublicAddress string `yaml:"public_address"` - AvatarProxyKey string `yaml:"avatar_proxy_key"` - - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageStatusEvents bool `yaml:"message_status_events"` - MessageErrorNotices bool `yaml:"message_error_notices"` - RestrictedRooms bool `yaml:"restricted_rooms"` - AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"` - EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"` - MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"` - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - CustomEmojiReactions bool `yaml:"custom_emoji_reactions"` - DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"` - DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"` - FederateRooms bool `yaml:"federate_rooms"` - PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"` - EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"` - UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"` - - CacheMedia string `yaml:"cache_media"` - DirectMedia DirectMedia `yaml:"direct_media"` - - AnimatedSticker struct { - Target string `yaml:"target"` - Args struct { - Width int `yaml:"width"` - Height int `yaml:"height"` - FPS int `yaml:"fps"` - } `yaml:"args"` - } `yaml:"animated_sticker"` - - DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` - - CommandPrefix string `yaml:"command_prefix"` - ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"` - - Backfill struct { - Limits struct { - Initial BackfillLimitPart `yaml:"initial"` - Missed BackfillLimitPart `yaml:"missed"` - } `yaml:"forward_limits"` - MaxGuildMembers int `yaml:"max_guild_members"` - } `yaml:"backfill"` - - Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"` - - Provisioning struct { - Prefix string `yaml:"prefix"` - SharedSecret string `yaml:"shared_secret"` - DebugEndpoints bool `yaml:"debug_endpoints"` - } `yaml:"provisioning"` - - Permissions bridgeconfig.PermissionConfig `yaml:"permissions"` - - usernameTemplate *template.Template `yaml:"-"` - displaynameTemplate *template.Template `yaml:"-"` - channelNameTemplate *template.Template `yaml:"-"` - guildNameTemplate *template.Template `yaml:"-"` -} - -type DirectMedia struct { - Enabled bool `yaml:"enabled"` - ServerName string `yaml:"server_name"` - WellKnownResponse string `yaml:"well_known_response"` - AllowProxy bool `yaml:"allow_proxy"` - ServerKey string `yaml:"server_key"` -} - -type BackfillLimitPart struct { - DM int `yaml:"dm"` - Channel int `yaml:"channel"` - Thread int `yaml:"thread"` -} - -func (bc *BridgeConfig) GetResendBridgeInfo() bool { - return bc.ResendBridgeInfo -} - -func (bc *BridgeConfig) EnableMessageStatusEvents() bool { - return bc.MessageStatusEvents -} - -func (bc *BridgeConfig) EnableMessageErrorNotices() bool { - return bc.MessageErrorNotices -} - -func boolToInt(val bool) int { - if val { - return 1 - } - return 0 -} - -func (bc *BridgeConfig) Validate() error { - _, hasWildcard := bc.Permissions["*"] - _, hasExampleDomain := bc.Permissions["example.com"] - _, hasExampleUser := bc.Permissions["@admin:example.com"] - exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) - if len(bc.Permissions) <= exampleLen { - return errors.New("bridge.permissions not configured") - } - return nil -} - -type umBridgeConfig BridgeConfig - -func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - err := unmarshal((*umBridgeConfig)(bc)) - if err != nil { - return err - } - - bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate) - if err != nil { - return err - } else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") { - return fmt.Errorf("username template is missing user ID placeholder") - } - bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate) - if err != nil { - return err - } - bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate) - if err != nil { - return err - } - bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate) - if err != nil { - return err - } - - return nil -} - -var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil) - -func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig { - return bc.DoublePuppetConfig -} - -func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { - return bc.Encryption -} - -func (bc BridgeConfig) GetCommandPrefix() string { - return bc.CommandPrefix -} - -func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { - return bc.ManagementRoomText -} - -func (bc BridgeConfig) FormatUsername(userID string) string { - var buffer strings.Builder - _ = bc.usernameTemplate.Execute(&buffer, userID) - return buffer.String() -} - -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, &DisplaynameParams{ - User: user, - Webhook: webhook, - Application: application, - }) - return buffer.String() -} - -type ChannelNameParams struct { - Name string - ParentName string - GuildName string - NSFW bool - Type discordgo.ChannelType -} - -func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string { - var buffer strings.Builder - _ = bc.channelNameTemplate.Execute(&buffer, params) - return buffer.String() -} - -type GuildNameParams struct { - Name string -} - -func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string { - var buffer strings.Builder - _ = bc.guildNameTemplate.Execute(&buffer, params) - return buffer.String() -} diff --git a/config/upgrade.go b/config/upgrade.go deleted file mode 100644 index f84d9ef..0000000 --- a/config/upgrade.go +++ /dev/null @@ -1,149 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - up "go.mau.fi/util/configupgrade" - "go.mau.fi/util/random" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/federation" -) - -func DoUpgrade(helper *up.Helper) { - bridgeconfig.Upgrader.DoUpgrade(helper) - - helper.Copy(up.Str, "bridge", "username_template") - helper.Copy(up.Str, "bridge", "displayname_template") - helper.Copy(up.Str, "bridge", "channel_name_template") - helper.Copy(up.Str, "bridge", "guild_name_template") - if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok { - updatedPrivateChatPortalMeta := "default" - if legacyPrivateChatPortalMeta == "true" { - updatedPrivateChatPortalMeta = "always" - } - helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta") - } else { - helper.Copy(up.Str, "bridge", "private_chat_portal_meta") - } - helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit") - helper.Copy(up.Str|up.Null, "bridge", "public_address") - if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" { - helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key") - } else { - helper.Copy(up.Str, "bridge", "avatar_proxy_key") - } - helper.Copy(up.Int, "bridge", "portal_message_buffer") - helper.Copy(up.Bool, "bridge", "delivery_receipts") - helper.Copy(up.Bool, "bridge", "message_status_events") - helper.Copy(up.Bool, "bridge", "message_error_notices") - helper.Copy(up.Bool, "bridge", "restricted_rooms") - helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open") - helper.Copy(up.Bool, "bridge", "embed_fields_as_tables") - helper.Copy(up.Bool, "bridge", "mute_channels_on_create") - helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") - helper.Copy(up.Bool, "bridge", "resend_bridge_info") - helper.Copy(up.Bool, "bridge", "custom_emoji_reactions") - helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete") - helper.Copy(up.Bool, "bridge", "delete_guild_on_leave") - helper.Copy(up.Bool, "bridge", "federate_rooms") - helper.Copy(up.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.Str, "bridge", "cache_media") - helper.Copy(up.Bool, "bridge", "direct_media", "enabled") - helper.Copy(up.Str, "bridge", "direct_media", "server_name") - helper.Copy(up.Str|up.Null, "bridge", "direct_media", "well_known_response") - helper.Copy(up.Bool, "bridge", "direct_media", "allow_proxy") - if serverKey, ok := helper.Get(up.Str, "bridge", "direct_media", "server_key"); !ok || serverKey == "generate" { - serverKey = federation.GenerateSigningKey().SynapseString() - helper.Set(up.Str, serverKey, "bridge", "direct_media", "server_key") - } else { - helper.Copy(up.Str, "bridge", "direct_media", "server_key") - } - helper.Copy(up.Str, "bridge", "animated_sticker", "target") - helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width") - helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height") - helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps") - helper.Copy(up.Map, "bridge", "double_puppet_server_map") - helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery") - helper.Copy(up.Map, "bridge", "login_shared_secret_map") - helper.Copy(up.Str, "bridge", "command_prefix") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected") - helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help") - helper.Copy(up.Bool, "bridge", "backfill", "enabled") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel") - helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread") - helper.Copy(up.Int, "bridge", "backfill", "max_guild_members") - helper.Copy(up.Bool, "bridge", "encryption", "allow") - helper.Copy(up.Bool, "bridge", "encryption", "default") - helper.Copy(up.Bool, "bridge", "encryption", "require") - helper.Copy(up.Bool, "bridge", "encryption", "appservice") - helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing") - helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share") - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation") - - helper.Copy(up.Str, "bridge", "provisioning", "prefix") - if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { - sharedSecret := random.String(64) - helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret") - } else { - helper.Copy(up.Str, "bridge", "provisioning", "shared_secret") - } - helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints") - - helper.Copy(up.Map, "bridge", "permissions") - //helper.Copy(up.Bool, "bridge", "relay", "enabled") - //helper.Copy(up.Bool, "bridge", "relay", "admin_only") - //helper.Copy(up.Map, "bridge", "relay", "message_formats") -} - -var SpacedBlocks = [][]string{ - {"homeserver", "software"}, - {"appservice"}, - {"appservice", "hostname"}, - {"appservice", "database"}, - {"appservice", "id"}, - {"appservice", "as_token"}, - {"bridge"}, - {"bridge", "command_prefix"}, - {"bridge", "management_room_text"}, - {"bridge", "encryption"}, - {"bridge", "provisioning"}, - {"bridge", "permissions"}, - //{"bridge", "relay"}, - {"logging"}, -} diff --git a/custompuppet.go b/custompuppet.go deleted file mode 100644 index f1c1f05..0000000 --- a/custompuppet.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "maunium.net/go/mautrix/id" -) - -func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error { - puppet.CustomMXID = mxid - puppet.AccessToken = accessToken - puppet.Update() - err := puppet.StartCustomMXID(false) - if err != nil { - return err - } - // TODO leave rooms with default puppet - return nil -} - -func (puppet *Puppet) ClearCustomMXID() { - save := puppet.CustomMXID != "" || puppet.AccessToken != "" - puppet.bridge.puppetsLock.Lock() - if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet { - delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID) - } - puppet.bridge.puppetsLock.Unlock() - puppet.CustomMXID = "" - puppet.AccessToken = "" - puppet.customIntent = nil - puppet.customUser = nil - if save { - puppet.Update() - } -} - -func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error { - newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail) - if err != nil { - puppet.ClearCustomMXID() - return err - } - puppet.bridge.puppetsLock.Lock() - puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet - puppet.bridge.puppetsLock.Unlock() - if puppet.AccessToken != newAccessToken { - puppet.AccessToken = newAccessToken - puppet.Update() - } - puppet.customIntent = newIntent - puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID) - return nil -} - -func (user *User) tryAutomaticDoublePuppeting() { - if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) { - return - } - user.log.Debug().Msg("Checking if double puppeting needs to be enabled") - puppet := user.bridge.GetPuppetByID(user.DiscordID) - if len(puppet.CustomMXID) > 0 { - user.log.Debug().Msg("User already has double-puppeting enabled") - // Custom puppet already enabled - return - } - puppet.CustomMXID = user.MXID - err := puppet.StartCustomMXID(true) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to login with shared secret") - } else { - // TODO leave rooms with default puppet - user.log.Debug().Msg("Successfully automatically enabled custom puppet") - } -} diff --git a/database/database.go b/database/database.go deleted file mode 100644 index a12bab6..0000000 --- a/database/database.go +++ /dev/null @@ -1,76 +0,0 @@ -package database - -import ( - _ "embed" - - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" - "go.mau.fi/util/dbutil" - "maunium.net/go/maulogger/v2" - - "go.mau.fi/mautrix-discord/database/upgrades" -) - -type Database struct { - *dbutil.Database - - User *UserQuery - Portal *PortalQuery - Puppet *PuppetQuery - Message *MessageQuery - Thread *ThreadQuery - Reaction *ReactionQuery - Guild *GuildQuery - Role *RoleQuery - File *FileQuery -} - -func New(baseDB *dbutil.Database, log maulogger.Logger) *Database { - db := &Database{Database: baseDB} - db.UpgradeTable = upgrades.Table - db.User = &UserQuery{ - db: db, - log: log.Sub("User"), - } - db.Portal = &PortalQuery{ - db: db, - log: log.Sub("Portal"), - } - db.Puppet = &PuppetQuery{ - db: db, - log: log.Sub("Puppet"), - } - db.Message = &MessageQuery{ - db: db, - log: log.Sub("Message"), - } - db.Thread = &ThreadQuery{ - db: db, - log: log.Sub("Thread"), - } - db.Reaction = &ReactionQuery{ - db: db, - log: log.Sub("Reaction"), - } - db.Guild = &GuildQuery{ - db: db, - log: log.Sub("Guild"), - } - db.Role = &RoleQuery{ - db: db, - log: log.Sub("Role"), - } - db.File = &FileQuery{ - db: db, - log: log.Sub("File"), - } - return db -} - -func strPtr[T ~string](val T) *string { - if val == "" { - return nil - } - valStr := string(val) - return &valStr -} diff --git a/database/file.go b/database/file.go deleted file mode 100644 index 2ee926f..0000000 --- a/database/file.go +++ /dev/null @@ -1,138 +0,0 @@ -package database - -import ( - "database/sql" - "encoding/json" - "errors" - "time" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/id" -) - -type FileQuery struct { - db *Database - log log.Logger -} - -// language=postgresql -const ( - fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file" - fileInsert = ` - INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ` -) - -func (fq *FileQuery) New() *File { - return &File{ - db: fq.db, - log: fq.log, - } -} - -func (fq *FileQuery) Get(url string, encrypted bool) *File { - query := fileSelect + " WHERE url=$1 AND encrypted=$2" - return fq.New().Scan(fq.db.QueryRow(query, url, encrypted)) -} - -func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File { - query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1" - return fq.New().Scan(fq.db.QueryRow(query, mxc.String())) -} - -type File struct { - db *Database - log log.Logger - - URL string - Encrypted bool - MXC id.ContentURI - - ID string - EmojiName string - - Size int - Width int - Height int - MimeType string - - DecryptionInfo *attachment.EncryptedFile - Timestamp time.Time -} - -func (f *File) Scan(row dbutil.Scannable) *File { - var fileID, emojiName, decryptionInfo sql.NullString - var width, height sql.NullInt32 - var timestamp int64 - var mxc string - err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - f.log.Errorln("Database scan failed:", err) - panic(err) - } - return nil - } - f.ID = fileID.String - f.EmojiName = emojiName.String - f.Timestamp = time.UnixMilli(timestamp).UTC() - f.Width = int(width.Int32) - f.Height = int(height.Int32) - f.MXC, err = id.ParseContentURI(mxc) - if err != nil { - f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err) - panic(err) - } - if decryptionInfo.Valid { - err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo) - if err != nil { - f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err) - panic(err) - } - } - return f -} - -func positiveIntToNullInt32(val int) (ptr sql.NullInt32) { - if val > 0 { - ptr.Valid = true - ptr.Int32 = int32(val) - } - return -} - -func (f *File) Insert(txn dbutil.Execable) { - if txn == nil { - txn = f.db - } - var decryptionInfoStr sql.NullString - if f.DecryptionInfo != nil { - decryptionInfo, err := json.Marshal(f.DecryptionInfo) - if err != nil { - f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err) - panic(err) - } - decryptionInfoStr.Valid = true - decryptionInfoStr.String = string(decryptionInfo) - } - _, err := txn.Exec(fileInsert, - f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size, - positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType, - decryptionInfoStr, f.Timestamp.UnixMilli(), - ) - if err != nil { - f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err) - panic(err) - } -} - -func (f *File) Delete() { - _, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted) - if err != nil { - f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err) - panic(err) - } -} diff --git a/database/guild.go b/database/guild.go deleted file mode 100644 index 70976a5..0000000 --- a/database/guild.go +++ /dev/null @@ -1,194 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type GuildBridgingMode int - -const ( - // GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists. - GuildBridgeNothing GuildBridgingMode = iota - // GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals. - GuildBridgeIfPortalExists - // GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received. - GuildBridgeCreateOnMessage - // GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications. - GuildBridgeEverything - - GuildBridgeInvalid GuildBridgingMode = -1 -) - -func ParseGuildBridgingMode(str string) GuildBridgingMode { - str = strings.ToLower(str) - str = strings.ReplaceAll(str, "-", "") - str = strings.ReplaceAll(str, "_", "") - switch str { - case "nothing", "0": - return GuildBridgeNothing - case "ifportalexists", "1": - return GuildBridgeIfPortalExists - case "createonmessage", "2": - return GuildBridgeCreateOnMessage - case "everything", "3": - return GuildBridgeEverything - default: - return GuildBridgeInvalid - } -} - -func (gbm GuildBridgingMode) String() string { - switch gbm { - case GuildBridgeNothing: - return "nothing" - case GuildBridgeIfPortalExists: - return "if-portal-exists" - case GuildBridgeCreateOnMessage: - return "create-on-message" - case GuildBridgeEverything: - return "everything" - default: - return "" - } -} - -func (gbm GuildBridgingMode) Description() string { - switch gbm { - case GuildBridgeNothing: - return "never bridge messages" - case GuildBridgeIfPortalExists: - return "bridge messages in existing portals" - case GuildBridgeCreateOnMessage: - return "bridge all messages and create portals on first message" - case GuildBridgeEverything: - return "bridge all messages and create portals proactively" - default: - return "" - } -} - -type GuildQuery struct { - db *Database - log log.Logger -} - -const ( - guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild" -) - -func (gq *GuildQuery) New() *Guild { - return &Guild{ - db: gq.db, - log: gq.log, - } -} - -func (gq *GuildQuery) GetByID(dcid string) *Guild { - query := guildSelect + " WHERE dcid=$1" - return gq.New().Scan(gq.db.QueryRow(query, dcid)) -} - -func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild { - query := guildSelect + " WHERE mxid=$1" - return gq.New().Scan(gq.db.QueryRow(query, mxid)) -} - -func (gq *GuildQuery) GetAll() []*Guild { - rows, err := gq.db.Query(guildSelect) - if err != nil { - gq.log.Errorln("Failed to query guilds:", err) - return nil - } - - var guilds []*Guild - for rows.Next() { - guild := gq.New().Scan(rows) - if guild != nil { - guilds = append(guilds, guild) - } - } - - return guilds -} - -type Guild struct { - db *Database - log log.Logger - - ID string - MXID id.RoomID - PlainName string - Name string - NameSet bool - Avatar string - AvatarURL id.ContentURI - AvatarSet bool - - BridgingMode GuildBridgingMode -} - -func (g *Guild) Scan(row dbutil.Scannable) *Guild { - var mxid sql.NullString - var avatarURL string - err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - g.log.Errorln("Database scan failed:", err) - panic(err) - } - - return nil - } - if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything { - panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID)) - } - g.MXID = id.RoomID(mxid.String) - g.AvatarURL, _ = id.ParseContentURI(avatarURL) - return g -} - -func (g *Guild) mxidPtr() *id.RoomID { - if g.MXID != "" { - return &g.MXID - } - return nil -} - -func (g *Guild) Insert() { - query := ` - INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ` - _, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode) - if err != nil { - g.log.Warnfln("Failed to insert %s: %v", g.ID, err) - panic(err) - } -} - -func (g *Guild) Update() { - query := ` - UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8 - WHERE dcid=$9 - ` - _, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID) - if err != nil { - g.log.Warnfln("Failed to update %s: %v", g.ID, err) - panic(err) - } -} - -func (g *Guild) Delete() { - _, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID) - if err != nil { - g.log.Warnfln("Failed to delete %s: %v", g.ID, err) - panic(err) - } -} diff --git a/database/message.go b/database/message.go deleted file mode 100644 index c38483c..0000000 --- a/database/message.go +++ /dev/null @@ -1,258 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type MessageQuery struct { - db *Database - log log.Logger -} - -const ( - 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 { - return &Message{ - db: mq.db, - log: mq.log, - } -} - -func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message { - if err != nil { - mq.log.Warnfln("Failed to query many messages: %v", err) - panic(err) - } else if rows == nil { - return nil - } - - var messages []*Message - for rows.Next() { - messages = append(messages, mq.New().Scan(rows)) - } - - return messages -} - -func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message { - query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 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 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 ORDER BY dc_attachment_id DESC LIMIT 1" - return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID)) -} - -func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message { - query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND timestamp<=$4 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1" - return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli())) -} - -func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message { - query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1" - return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID)) -} - -func (mq *MessageQuery) GetLast(key PortalKey) *Message { - query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 ORDER BY timestamp DESC LIMIT 1" - return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver)) -} - -func (mq *MessageQuery) DeleteAll(key PortalKey) { - query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2" - _, err := mq.db.Exec(query, key.ChannelID, key.Receiver) - if err != nil { - mq.log.Warnfln("Failed to delete messages of %s: %v", key, err) - panic(err) - } -} - -func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message { - query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3" - - row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid) - if row == nil { - return nil - } - - return mq.New().Scan(row) -} - -func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) { - if len(msgs) == 0 { - return - } - valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)" - if mq.db.Dialect == dbutil.SQLite { - valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?") - } - params := make([]interface{}, 2+len(msgs)*8) - placeholders := make([]string, len(msgs)) - params[0] = key.ChannelID - params[1] = key.Receiver - for i, msg := range msgs { - baseIndex := 2 + i*8 - params[baseIndex] = msg.DiscordID - params[baseIndex+1] = msg.AttachmentID - params[baseIndex+2] = msg.SenderID - params[baseIndex+3] = msg.Timestamp.UnixMilli() - params[baseIndex+4] = msg.editTimestampVal() - params[baseIndex+5] = msg.ThreadID - params[baseIndex+6] = msg.MXID - params[baseIndex+7] = msg.SenderMXID.String() - placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8) - } - _, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...) - if err != nil { - mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err) - panic(err) - } -} - -type Message struct { - db *Database - log log.Logger - - DiscordID string - AttachmentID string - Channel PortalKey - SenderID string - Timestamp time.Time - EditTimestamp time.Time - ThreadID string - - MXID id.EventID - SenderMXID id.UserID -} - -func (m *Message) DiscordProtoChannelID() string { - if m.ThreadID != "" { - return m.ThreadID - } else { - return m.Channel.ChannelID - } -} - -func (m *Message) Scan(row dbutil.Scannable) *Message { - var ts, editTS int64 - - 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) - panic(err) - } - - return nil - } - - if ts != 0 { - m.Timestamp = time.UnixMilli(ts).UTC() - } - if editTS != 0 { - m.EditTimestamp = time.Unix(0, editTS).UTC() - } - - return m -} - -const messageInsertQuery = ` - INSERT INTO message ( - 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, $10) -` - -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, $8)" - if m.db.Dialect == dbutil.SQLite { - valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?") - } - params := make([]interface{}, 8+len(msgs)*2) - placeholders := make([]string, len(msgs)) - params[0] = m.DiscordID - 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[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 { - m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err) - panic(err) - } -} - -func (m *Message) Insert() { - _, err := m.db.Exec(messageInsertQuery, - m.DiscordID, m.AttachmentID, m.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) - panic(err) - } -} - -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) - if err != nil { - m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err) - panic(err) - } -} diff --git a/database/portal.go b/database/portal.go deleted file mode 100644 index 3c6a8da..0000000 --- a/database/portal.go +++ /dev/null @@ -1,210 +0,0 @@ -package database - -import ( - "database/sql" - - "github.com/bwmarrin/discordgo" - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -// language=postgresql -const ( - portalSelect = ` - SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, - 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 - ` -) - -type PortalKey struct { - ChannelID string - Receiver string -} - -func NewPortalKey(channelID, receiver string) PortalKey { - return PortalKey{ - ChannelID: channelID, - Receiver: receiver, - } -} - -func (key PortalKey) String() string { - if key.Receiver == "" { - return key.ChannelID - } - return key.ChannelID + "-" + key.Receiver -} - -type PortalQuery struct { - db *Database - log log.Logger -} - -func (pq *PortalQuery) New() *Portal { - return &Portal{ - db: pq.db, - log: pq.log, - } -} - -func (pq *PortalQuery) GetAll() []*Portal { - return pq.getAll(portalSelect) -} - -func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal { - return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID) -} - -func (pq *PortalQuery) GetByID(key PortalKey) *Portal { - return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver) -} - -func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal { - return pq.get(portalSelect+" WHERE mxid=$1", mxid) -} - -func (pq *PortalQuery) 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) -} - -func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal { - query := portalSelect + " portal WHERE receiver=$1 AND type=$2;" - - return pq.getAll(query, receiver, discordgo.ChannelTypeDM) -} - -func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal { - rows, err := pq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - - var portals []*Portal - for rows.Next() { - portals = append(portals, pq.New().Scan(rows)) - } - - return portals -} - -func (pq *PortalQuery) get(query string, args ...interface{}) *Portal { - return pq.New().Scan(pq.db.QueryRow(query, args...)) -} - -type Portal struct { - db *Database - log log.Logger - - Key PortalKey - Type discordgo.ChannelType - OtherUserID string - ParentID string - GuildID string - - MXID id.RoomID - - PlainName string - Name string - NameSet bool - FriendNick bool - Topic string - TopicSet bool - Avatar string - AvatarURL id.ContentURI - AvatarSet bool - Encrypted bool - InSpace id.RoomID - - FirstEventID id.EventID - - RelayWebhookID string - RelayWebhookSecret string -} - -func (p *Portal) Scan(row dbutil.Scannable) *Portal { - var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString - var chanType int32 - var avatarURL string - - err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID, - &mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet, - &p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret) - - if err != nil { - if err != sql.ErrNoRows { - p.log.Errorln("Database scan failed:", err) - panic(err) - } - - return nil - } - - p.MXID = id.RoomID(mxid.String) - p.OtherUserID = otherUserID.String - p.GuildID = guildID.String - p.ParentID = parentID.String - p.Type = discordgo.ChannelType(chanType) - p.FirstEventID = id.EventID(firstEventID.String) - p.AvatarURL, _ = id.ParseContentURI(avatarURL) - p.RelayWebhookID = relayWebhookID.String - p.RelayWebhookSecret = relayWebhookSecret.String - - return p -} - -func (p *Portal) Insert() { - query := ` - INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, - plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set, - encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) - ` - _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type, - strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), - p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, - p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret)) - - if err != nil { - p.log.Warnfln("Failed to insert %s: %v", p.Key, err) - panic(err) - } -} - -func (p *Portal) Update() { - query := ` - UPDATE portal - SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5, - plain_name=$6, name=$7, name_set=$8, 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.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 { - p.log.Warnfln("Failed to update %s: %v", p.Key, err) - panic(err) - } -} - -func (p *Portal) Delete() { - query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2" - _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver) - if err != nil { - p.log.Warnfln("Failed to delete %s: %v", p.Key, err) - panic(err) - } -} diff --git a/database/puppet.go b/database/puppet.go deleted file mode 100644 index d6080c7..0000000 --- a/database/puppet.go +++ /dev/null @@ -1,151 +0,0 @@ -package database - -import ( - "database/sql" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -const ( - puppetSelect = "SELECT 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" + - " FROM puppet " -) - -type PuppetQuery struct { - db *Database - log log.Logger -} - -func (pq *PuppetQuery) New() *Puppet { - return &Puppet{ - db: pq.db, - log: pq.log, - } -} - -func (pq *PuppetQuery) Get(id string) *Puppet { - return pq.get(puppetSelect+" WHERE id=$1", id) -} - -func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet { - return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid) -} - -func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet { - return pq.New().Scan(pq.db.QueryRow(query, args...)) -} - -func (pq *PuppetQuery) GetAll() []*Puppet { - return pq.getAll(puppetSelect) -} - -func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet { - return pq.getAll(puppetSelect + " WHERE custom_mxid<>''") -} - -func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet { - rows, err := pq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - - var puppets []*Puppet - for rows.Next() { - puppets = append(puppets, pq.New().Scan(rows)) - } - - return puppets -} - -type Puppet struct { - db *Database - log log.Logger - - ID string - Name string - NameSet bool - Avatar string - AvatarURL id.ContentURI - AvatarSet bool - - ContactInfoSet bool - - GlobalName string - Username string - Discriminator string - IsBot bool - IsWebhook bool - IsApplication bool - - CustomMXID id.UserID - AccessToken string - NextBatch string -} - -func (p *Puppet) Scan(row dbutil.Scannable) *Puppet { - var avatarURL string - var customMXID, accessToken, nextBatch sql.NullString - - err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet, - &p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch) - - if err != nil { - if err != sql.ErrNoRows { - p.log.Errorln("Database scan failed:", err) - panic(err) - } - - return nil - } - - p.AvatarURL, _ = id.ParseContentURI(avatarURL) - p.CustomMXID = id.UserID(customMXID.String) - p.AccessToken = accessToken.String - p.NextBatch = nextBatch.String - - return p -} - -func (p *Puppet) Insert() { - query := ` - INSERT INTO puppet ( - id, name, name_set, avatar, avatar_url, avatar_set, 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, 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) - panic(err) - } -} - -func (p *Puppet) Update() { - query := ` - 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, 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) - panic(err) - } -} diff --git a/database/reaction.go b/database/reaction.go deleted file mode 100644 index 8727bb5..0000000 --- a/database/reaction.go +++ /dev/null @@ -1,124 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type ReactionQuery struct { - db *Database - log log.Logger -} - -const ( - reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction" -) - -func (rq *ReactionQuery) New() *Reaction { - return &Reaction{ - db: rq.db, - log: rq.log, - } -} - -func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction { - query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3" - - return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID) -} - -func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction { - rows, err := rq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - - var reactions []*Reaction - for rows.Next() { - reactions = append(reactions, rq.New().Scan(rows)) - } - - return reactions -} - -func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction { - query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3 AND dc_sender=$4 AND dc_emoji_name=$5" - - return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName) -} - -func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction { - query := reactionSelect + " WHERE mxid=$1" - - return rq.get(query, mxid) -} - -func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction { - row := rq.db.QueryRow(query, args...) - if row == nil { - return nil - } - - return rq.New().Scan(row) -} - -type Reaction struct { - db *Database - log log.Logger - - Channel PortalKey - MessageID string - Sender string - EmojiName string - ThreadID string - - MXID id.EventID - - FirstAttachmentID string -} - -func (r *Reaction) Scan(row dbutil.Scannable) *Reaction { - err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - r.log.Errorln("Database scan failed:", err) - panic(err) - } - return nil - } - - return r -} - -func (r *Reaction) DiscordProtoChannelID() string { - if r.ThreadID != "" { - return r.ThreadID - } else { - return r.Channel.ChannelID - } -} - -func (r *Reaction) Insert() { - query := ` - INSERT INTO reaction (dc_msg_id, dc_first_attachment_id, dc_sender, dc_emoji_name, dc_chan_id, dc_chan_receiver, dc_thread_id, mxid) - VALUES($1, $2, $3, $4, $5, $6, $7, $8) - ` - _, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID) - if err != nil { - r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err) - panic(err) - } -} - -func (r *Reaction) Delete() { - query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3" - _, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName) - if err != nil { - r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err) - panic(err) - } -} diff --git a/database/role.go b/database/role.go deleted file mode 100644 index 3696b51..0000000 --- a/database/role.go +++ /dev/null @@ -1,112 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "github.com/bwmarrin/discordgo" - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" -) - -type RoleQuery struct { - db *Database - log log.Logger -} - -// language=postgresql -const ( - roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role" - roleUpsert = ` - INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (dc_guild_id, dcid) DO UPDATE - SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed, - hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions - ` - roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2" -) - -func (rq *RoleQuery) New() *Role { - return &Role{ - db: rq.db, - log: rq.log, - } -} - -func (rq *RoleQuery) GetByID(guildID, dcid string) *Role { - query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2" - return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid)) -} - -func (rq *RoleQuery) DeleteByID(guildID, dcid string) { - _, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid) - if err != nil { - rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err) - panic(err) - } -} - -func (rq *RoleQuery) GetAll(guildID string) []*Role { - rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID) - if err != nil { - rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err) - return nil - } - - var roles []*Role - for rows.Next() { - role := rq.New().Scan(rows) - if role != nil { - roles = append(roles, role) - } - } - - return roles -} - -type Role struct { - db *Database - log log.Logger - - GuildID string - - discordgo.Role -} - -func (r *Role) Scan(row dbutil.Scannable) *Role { - var icon sql.NullString - err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - r.log.Errorln("Database scan failed:", err) - panic(err) - } - - return nil - } - r.Icon = icon.String - return r -} - -func (r *Role) Upsert(txn dbutil.Execable) { - if txn == nil { - txn = r.db - } - _, err := txn.Exec(roleUpsert, r.GuildID, r.ID, r.Name, strPtr(r.Icon), r.Mentionable, r.Managed, r.Hoist, r.Color, r.Position, r.Permissions) - if err != nil { - r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err) - panic(err) - } -} - -func (r *Role) Delete(txn dbutil.Execable) { - if txn == nil { - txn = r.db - } - _, err := txn.Exec(roleDelete, r.GuildID, r.Icon) - if err != nil { - r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err) - panic(err) - } -} diff --git a/database/thread.go b/database/thread.go deleted file mode 100644 index 87f4127..0000000 --- a/database/thread.go +++ /dev/null @@ -1,111 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type ThreadQuery struct { - db *Database - log log.Logger -} - -const ( - threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread" -) - -func (tq *ThreadQuery) New() *Thread { - return &Thread{ - db: tq.db, - log: tq.log, - } -} - -func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread { - query := threadSelect + " WHERE dcid=$1" - - row := tq.db.QueryRow(query, discordID) - if row == nil { - return nil - } - - return tq.New().Scan(row) -} - -func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread { - query := threadSelect + " WHERE root_msg_mxid=$1" - - row := tq.db.QueryRow(query, mxid) - if row == nil { - return nil - } - - return tq.New().Scan(row) -} - -func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread { - query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1" - - row := tq.db.QueryRow(query, mxid) - if row == nil { - return nil - } - - return tq.New().Scan(row) -} - -type Thread struct { - db *Database - log log.Logger - - ID string - ParentID string - - RootDiscordID string - RootMXID id.EventID - - CreationNoticeMXID id.EventID -} - -func (t *Thread) Scan(row dbutil.Scannable) *Thread { - err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - t.log.Errorln("Database scan failed:", err) - panic(err) - } - return nil - } - return t -} - -func (t *Thread) Insert() { - query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)" - _, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID) - if err != nil { - t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err) - panic(err) - } -} - -func (t *Thread) Update() { - query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1" - _, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID) - if err != nil { - t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err) - panic(err) - } -} - -func (t *Thread) Delete() { - query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2" - _, err := t.db.Exec(query, t.ID, t.ParentID) - if err != nil { - t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err) - panic(err) - } -} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql deleted file mode 100644 index 46fbb73..0000000 --- a/database/upgrades/00-latest-revision.sql +++ /dev/null @@ -1,179 +0,0 @@ --- v0 -> v23 (compatible with v19+): Latest revision - -CREATE TABLE guild ( - dcid TEXT PRIMARY KEY, - mxid TEXT UNIQUE, - plain_name TEXT NOT NULL, - name TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - avatar TEXT NOT NULL, - avatar_url TEXT NOT NULL, - avatar_set BOOLEAN NOT NULL, - - bridging_mode INTEGER NOT NULL -); - -CREATE TABLE portal ( - dcid TEXT, - receiver TEXT, - other_user_id TEXT, - type INTEGER NOT NULL, - - dc_guild_id TEXT, - dc_parent_id TEXT, - -- This is not accessed by the bridge, it's only used for the portal parent foreign key. - -- Only guild channels have parents, but only DMs have a receiver field. - dc_parent_receiver TEXT NOT NULL DEFAULT '', - - mxid TEXT UNIQUE, - plain_name TEXT NOT NULL, - name TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - friend_nick BOOLEAN NOT NULL, - topic TEXT NOT NULL, - topic_set BOOLEAN NOT NULL, - avatar TEXT NOT NULL, - avatar_url TEXT NOT NULL, - avatar_set BOOLEAN NOT NULL, - encrypted BOOLEAN NOT NULL, - in_space TEXT NOT NULL, - - first_event_id TEXT NOT NULL, - - relay_webhook_id TEXT, - relay_webhook_secret TEXT, - - PRIMARY KEY (dcid, receiver), - CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE, - CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE -); - -CREATE TABLE thread ( - dcid TEXT PRIMARY KEY, - parent_chan_id TEXT NOT NULL, - root_msg_dcid TEXT NOT NULL, - root_msg_mxid TEXT NOT NULL, - creation_notice_mxid TEXT NOT NULL, - -- This is also not accessed by the bridge. - receiver TEXT NOT NULL DEFAULT '', - - CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TABLE puppet ( - id TEXT PRIMARY KEY, - - name TEXT NOT NULL, - name_set BOOLEAN NOT NULL 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, - next_batch TEXT -); - -CREATE TABLE "user" ( - mxid TEXT PRIMARY KEY, - dcid TEXT UNIQUE, - - discord_token TEXT, - management_room TEXT, - space_room TEXT, - dm_space_room TEXT, - - read_state_version INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE user_portal ( - discord_id TEXT, - user_mxid TEXT, - type TEXT NOT NULL, - in_space BOOLEAN NOT NULL, - timestamp BIGINT NOT NULL, - - PRIMARY KEY (discord_id, user_mxid), - CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE -); - -CREATE TABLE message ( - dcid TEXT, - dc_attachment_id TEXT, - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_sender TEXT NOT NULL, - timestamp BIGINT NOT NULL, - dc_edit_timestamp BIGINT NOT NULL, - dc_thread_id TEXT NOT NULL, - - mxid TEXT NOT NULL UNIQUE, - sender_mxid TEXT NOT NULL DEFAULT '', - - PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE -); - -CREATE TABLE reaction ( - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_msg_id TEXT, - dc_sender TEXT, - dc_emoji_name TEXT, - dc_thread_id TEXT NOT NULL, - - dc_first_attachment_id TEXT NOT NULL, - - 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 -); - -CREATE TABLE role ( - dc_guild_id TEXT, - dcid TEXT, - - name TEXT NOT NULL, - icon TEXT, - - mentionable BOOLEAN NOT NULL, - managed BOOLEAN NOT NULL, - hoist BOOLEAN NOT NULL, - - color INTEGER NOT NULL, - position INTEGER NOT NULL, - permissions BIGINT NOT NULL, - - PRIMARY KEY (dc_guild_id, dcid), - CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE -); - -CREATE TABLE discord_file ( - url TEXT, - encrypted BOOLEAN, - mxc TEXT NOT NULL, - - 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) -); - -CREATE INDEX discord_file_mxc_idx ON discord_file (mxc); diff --git a/database/upgrades/02-column-renames.sql b/database/upgrades/02-column-renames.sql deleted file mode 100644 index 86b0cb0..0000000 --- a/database/upgrades/02-column-renames.sql +++ /dev/null @@ -1,53 +0,0 @@ --- v2: Rename columns in message-related tables - -ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id; -ALTER TABLE portal RENAME COLUMN channel_id TO dcid; - -ALTER TABLE "user" RENAME COLUMN id TO dcid; - -ALTER TABLE puppet DROP COLUMN enable_presence; -ALTER TABLE puppet DROP COLUMN enable_receipts; - -DROP TABLE message; -DROP TABLE reaction; -DROP TABLE attachment; - -CREATE TABLE message ( - dcid TEXT, - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_sender TEXT NOT NULL, - timestamp BIGINT NOT NULL, - - mxid TEXT NOT NULL UNIQUE, - - PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE -); - -CREATE TABLE reaction ( - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_msg_id TEXT, - dc_sender TEXT, - dc_emoji_name TEXT, - - mxid TEXT NOT NULL UNIQUE, - - PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), - CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE -); - -CREATE TABLE attachment ( - dcid TEXT, - dc_msg_id TEXT, - dc_chan_id TEXT, - dc_chan_receiver TEXT, - - mxid TEXT NOT NULL UNIQUE, - - PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver), - CONSTRAINT attachment_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE -); - -UPDATE portal SET receiver='' WHERE type<>1; diff --git a/database/upgrades/03-spaces.sql b/database/upgrades/03-spaces.sql deleted file mode 100644 index 79bc3c5..0000000 --- a/database/upgrades/03-spaces.sql +++ /dev/null @@ -1,73 +0,0 @@ --- v3: Store portal parent metadata for spaces -DROP TABLE guild; - -CREATE TABLE guild ( - dcid TEXT PRIMARY KEY, - mxid TEXT UNIQUE, - name TEXT NOT NULL, - name_set BOOLEAN NOT NULL, - avatar TEXT NOT NULL, - avatar_url TEXT NOT NULL, - avatar_set BOOLEAN NOT NULL, - - auto_bridge_channels BOOLEAN NOT NULL -); - -CREATE TABLE user_portal ( - discord_id TEXT, - user_mxid TEXT, - type TEXT NOT NULL, - in_space BOOLEAN NOT NULL, - timestamp BIGINT NOT NULL, - - PRIMARY KEY (discord_id, user_mxid), - CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE -); - -ALTER TABLE portal ADD COLUMN dc_guild_id TEXT; -ALTER TABLE portal ADD COLUMN dc_parent_id TEXT; -ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE; -ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE; -DELETE FROM portal WHERE type IS NULL; --- only: postgres -ALTER TABLE portal ALTER COLUMN type SET NOT NULL; - -ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false; --- only: postgres for next 5 lines -ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT; - -ALTER TABLE puppet RENAME COLUMN display_name TO name; -ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false; --- only: postgres for next 2 lines -ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT; -ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT; - -ALTER TABLE "user" ADD COLUMN space_room TEXT; -ALTER TABLE "user" ADD COLUMN dm_space_room TEXT; -ALTER TABLE "user" RENAME COLUMN token TO discord_token; - -UPDATE message SET timestamp=timestamp*1000; - -CREATE TABLE thread ( - dcid TEXT PRIMARY KEY, - parent_chan_id TEXT NOT NULL, - root_msg_dcid TEXT NOT NULL, - root_msg_mxid TEXT NOT NULL, - -- This is also not accessed by the bridge. - receiver TEXT NOT NULL DEFAULT '', - - CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE -); - -ALTER TABLE message ADD COLUMN dc_thread_id TEXT; -ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT; -ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT; diff --git a/database/upgrades/04-attachment-fix.postgres.sql b/database/upgrades/04-attachment-fix.postgres.sql deleted file mode 100644 index c476afd..0000000 --- a/database/upgrades/04-attachment-fix.postgres.sql +++ /dev/null @@ -1,20 +0,0 @@ --- v4: Fix storing attachments -ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; -ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey; -ALTER TABLE message DROP CONSTRAINT message_pkey; -ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0; -ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT; -ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT; -ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver); -INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) - SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid - FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid; -DROP TABLE attachment; - -ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT; -ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0; -ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey - FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) - REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver); diff --git a/database/upgrades/04-attachment-fix.sqlite.sql b/database/upgrades/04-attachment-fix.sqlite.sql deleted file mode 100644 index 88c4386..0000000 --- a/database/upgrades/04-attachment-fix.sqlite.sql +++ /dev/null @@ -1,45 +0,0 @@ --- v4: Fix storing attachments -CREATE TABLE new_message ( - dcid TEXT, - dc_attachment_id TEXT, - dc_edit_index INTEGER, - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_sender TEXT NOT NULL, - timestamp BIGINT NOT NULL, - dc_thread_id TEXT, - - mxid TEXT NOT NULL UNIQUE, - - PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE -); -INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) - SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message; -INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) - SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid - FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid; -DROP TABLE attachment; -DROP TABLE message; -ALTER TABLE new_message RENAME TO message; - -CREATE TABLE new_reaction ( - dc_chan_id TEXT, - dc_chan_receiver TEXT, - dc_msg_id TEXT, - dc_sender TEXT, - dc_emoji_name TEXT, - dc_thread_id TEXT, - - dc_first_attachment_id TEXT NOT NULL, - _dc_first_edit_index INTEGER NOT NULL DEFAULT 0, - - mxid TEXT NOT NULL UNIQUE, - - PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), - CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE -); -INSERT INTO new_reaction (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid) -SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction; -DROP TABLE reaction; -ALTER TABLE new_reaction RENAME TO reaction; diff --git a/database/upgrades/05-reaction-fkey-fix.sql b/database/upgrades/05-reaction-fkey-fix.sql deleted file mode 100644 index 1a02a5e..0000000 --- a/database/upgrades/05-reaction-fkey-fix.sql +++ /dev/null @@ -1,8 +0,0 @@ --- v5: Fix foreign key broken in v4 --- only: postgres - -ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; -ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey - FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) - REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) - ON DELETE CASCADE; diff --git a/database/upgrades/06-user-read-state-version.sql b/database/upgrades/06-user-read-state-version.sql deleted file mode 100644 index 612a777..0000000 --- a/database/upgrades/06-user-read-state-version.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v6: Store user read state version -ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0; diff --git a/database/upgrades/07-store-role-info.sql b/database/upgrades/07-store-role-info.sql deleted file mode 100644 index 21f6a57..0000000 --- a/database/upgrades/07-store-role-info.sql +++ /dev/null @@ -1,19 +0,0 @@ --- v7: Store role info -CREATE TABLE role ( - dc_guild_id TEXT, - dcid TEXT, - - name TEXT NOT NULL, - icon TEXT, - - mentionable BOOLEAN NOT NULL, - managed BOOLEAN NOT NULL, - hoist BOOLEAN NOT NULL, - - color INTEGER NOT NULL, - position INTEGER NOT NULL, - permissions BIGINT NOT NULL, - - PRIMARY KEY (dc_guild_id, dcid), - CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE -); diff --git a/database/upgrades/08-channel-plain-name.sql b/database/upgrades/08-channel-plain-name.sql deleted file mode 100644 index 22237b6..0000000 --- a/database/upgrades/08-channel-plain-name.sql +++ /dev/null @@ -1,9 +0,0 @@ --- v8: Store plain name of channels and guilds -ALTER TABLE guild ADD COLUMN plain_name TEXT; -ALTER TABLE portal ADD COLUMN plain_name TEXT; -UPDATE guild SET plain_name=name; -UPDATE portal SET plain_name=name; -UPDATE portal SET plain_name='' WHERE type=1; --- only: postgres for next 2 lines -ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL; -ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL; diff --git a/database/upgrades/09-more-thread-data.sql b/database/upgrades/09-more-thread-data.sql deleted file mode 100644 index 461a1d4..0000000 --- a/database/upgrades/09-more-thread-data.sql +++ /dev/null @@ -1,9 +0,0 @@ --- v9: Store more info for proper thread support -ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT ''; -UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL; -UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL; - --- only: postgres for next 3 lines -ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT; -ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL; -ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL; diff --git a/database/upgrades/10-remove-broken-double-puppets.sql b/database/upgrades/10-remove-broken-double-puppets.sql deleted file mode 100644 index 862c917..0000000 --- a/database/upgrades/10-remove-broken-double-puppets.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v10: Remove double puppet ghosts added while there was a bug in the bridge -DELETE FROM puppet WHERE id=''; diff --git a/database/upgrades/11-cache-reuploaded-files.sql b/database/upgrades/11-cache-reuploaded-files.sql deleted file mode 100644 index c32c2bc..0000000 --- a/database/upgrades/11-cache-reuploaded-files.sql +++ /dev/null @@ -1,18 +0,0 @@ --- v11: Cache files copied from Discord to Matrix -CREATE TABLE discord_file ( - url TEXT, - encrypted BOOLEAN, - - id TEXT, - mxc TEXT NOT NULL, - - size BIGINT NOT NULL, - width INTEGER, - height INTEGER, - - decryption_info jsonb, - - timestamp BIGINT NOT NULL, - - PRIMARY KEY (url, encrypted) -); diff --git a/database/upgrades/12-file-cache-mime-type.sql b/database/upgrades/12-file-cache-mime-type.sql deleted file mode 100644 index 1bdb960..0000000 --- a/database/upgrades/12-file-cache-mime-type.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v12: Cache mime type for reuploaded files -ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT ''; --- only: postgres -ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT; diff --git a/database/upgrades/13-merge-emoji-and-file.postgres.sql b/database/upgrades/13-merge-emoji-and-file.postgres.sql deleted file mode 100644 index 18ef607..0000000 --- a/database/upgrades/13-merge-emoji-and-file.postgres.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v13: Merge tables used for cached custom emojis and attachments -ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc); -ALTER TABLE discord_file ADD COLUMN emoji_name TEXT; -DROP TABLE emoji; diff --git a/database/upgrades/13-merge-emoji-and-file.sqlite.sql b/database/upgrades/13-merge-emoji-and-file.sqlite.sql deleted file mode 100644 index ffe1b25..0000000 --- a/database/upgrades/13-merge-emoji-and-file.sqlite.sql +++ /dev/null @@ -1,24 +0,0 @@ --- v13: Merge tables used for cached custom emojis and attachments -CREATE TABLE new_discord_file ( - url TEXT, - encrypted BOOLEAN, - mxc TEXT NOT NULL UNIQUE, - - id TEXT, - emoji_name TEXT, - - size BIGINT NOT NULL, - width INTEGER, - height INTEGER, - mime_type TEXT NOT NULL, - decryption_info jsonb, - timestamp BIGINT NOT NULL, - - PRIMARY KEY (url, encrypted) -); - -INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp) -SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file; - -DROP TABLE discord_file; -ALTER TABLE new_discord_file RENAME TO discord_file; diff --git a/database/upgrades/14-guild-bridging-mode.sql b/database/upgrades/14-guild-bridging-mode.sql deleted file mode 100644 index 854d1c0..0000000 --- a/database/upgrades/14-guild-bridging-mode.sql +++ /dev/null @@ -1,7 +0,0 @@ --- v14: Add more modes of bridging guilds -ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0; -UPDATE guild SET bridging_mode=2 WHERE mxid<>''; -UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true; -ALTER TABLE guild DROP COLUMN auto_bridge_channels; --- only: postgres -ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT; diff --git a/database/upgrades/15-portal-relay-webhook.sql b/database/upgrades/15-portal-relay-webhook.sql deleted file mode 100644 index 0035d00..0000000 --- a/database/upgrades/15-portal-relay-webhook.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v15: Store relay webhook URL for portals -ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT; -ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT; diff --git a/database/upgrades/16-add-contact-info.sql b/database/upgrades/16-add-contact-info.sql deleted file mode 100644 index 8595ae3..0000000 --- a/database/upgrades/16-add-contact-info.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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; diff --git a/database/upgrades/17-dm-portal-friend-nick.sql b/database/upgrades/17-dm-portal-friend-nick.sql deleted file mode 100644 index 2c2b43c..0000000 --- a/database/upgrades/17-dm-portal-friend-nick.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v17: Store whether DM portal name is a friend nickname -ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false; diff --git a/database/upgrades/18-extra-ghost-metadata.sql b/database/upgrades/18-extra-ghost-metadata.sql deleted file mode 100644 index 92677dc..0000000 --- a/database/upgrades/18-extra-ghost-metadata.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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; diff --git a/database/upgrades/19-message-edit-ts.postgres.sql b/database/upgrades/19-message-edit-ts.postgres.sql deleted file mode 100644 index 231afa1..0000000 --- a/database/upgrades/19-message-edit-ts.postgres.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 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; diff --git a/database/upgrades/19-message-edit-ts.sqlite.sql b/database/upgrades/19-message-edit-ts.sqlite.sql deleted file mode 100644 index a25f317..0000000 --- a/database/upgrades/19-message-edit-ts.sqlite.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 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; diff --git a/database/upgrades/20-message-sender-mxid.sql b/database/upgrades/20-message-sender-mxid.sql deleted file mode 100644 index aa2bd65..0000000 --- a/database/upgrades/20-message-sender-mxid.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v20 (compatible with v19+): Store message sender Matrix user ID -ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/21-more-puppet-info.sql b/database/upgrades/21-more-puppet-info.sql deleted file mode 100644 index 3bc374a..0000000 --- a/database/upgrades/21-more-puppet-info.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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; diff --git a/database/upgrades/22-file-cache-duplicate-mxc.sql b/database/upgrades/22-file-cache-duplicate-mxc.sql deleted file mode 100644 index b0bac3b..0000000 --- a/database/upgrades/22-file-cache-duplicate-mxc.sql +++ /dev/null @@ -1,26 +0,0 @@ --- 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); diff --git a/database/upgrades/23-puppet-is-application.sql b/database/upgrades/23-puppet-is-application.sql deleted file mode 100644 index 6279c88..0000000 --- a/database/upgrades/23-puppet-is-application.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v23 (compatible with v19+): Store is application status for puppets -ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false; diff --git a/database/user.go b/database/user.go deleted file mode 100644 index 763625d..0000000 --- a/database/user.go +++ /dev/null @@ -1,101 +0,0 @@ -package database - -import ( - "database/sql" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type UserQuery struct { - db *Database - log log.Logger -} - -func (uq *UserQuery) New() *User { - return &User{ - db: uq.db, - log: uq.log, - } -} - -func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1` - return uq.New().Scan(uq.db.QueryRow(query, userID)) -} - -func (uq *UserQuery) GetByID(id string) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1` - return uq.New().Scan(uq.db.QueryRow(query, id)) -} - -func (uq *UserQuery) GetAllWithToken() []*User { - query := ` - SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version - FROM "user" WHERE discord_token IS NOT NULL - ` - rows, err := uq.db.Query(query) - if err != nil || rows == nil { - return nil - } - - var users []*User - for rows.Next() { - user := uq.New().Scan(rows) - if user != nil { - users = append(users, user) - } - } - return users -} - -type User struct { - db *Database - log log.Logger - - MXID id.UserID - DiscordID string - DiscordToken string - ManagementRoom id.RoomID - SpaceRoom id.RoomID - DMSpaceRoom id.RoomID - - ReadStateVersion int -} - -func (u *User) Scan(row dbutil.Scannable) *User { - var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString - err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion) - if err != nil { - if err != sql.ErrNoRows { - u.log.Errorln("Database scan failed:", err) - panic(err) - } - return nil - } - u.DiscordID = discordID.String - u.DiscordToken = discordToken.String - u.ManagementRoom = id.RoomID(managementRoom.String) - u.SpaceRoom = id.RoomID(spaceRoom.String) - u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String) - return u -} - -func (u *User) Insert() { - query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion) - if err != nil { - u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) - panic(err) - } -} - -func (u *User) Update() { - query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7` - _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID) - if err != nil { - u.log.Warnfln("Failed to update %q: %v", u.MXID, err) - panic(err) - } -} diff --git a/database/userportal.go b/database/userportal.go deleted file mode 100644 index 783b83d..0000000 --- a/database/userportal.go +++ /dev/null @@ -1,140 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - "time" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -const ( - UserPortalTypeDM = "dm" - UserPortalTypeGuild = "guild" - UserPortalTypeThread = "thread" -) - -type UserPortal struct { - DiscordID string - Type string - Timestamp time.Time - InSpace bool -} - -func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal { - var ts int64 - err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace) - if err != nil { - l.Errorln("Error scanning user portal:", err) - panic(err) - } - up.Timestamp = time.UnixMilli(ts).UTC() - return &up -} - -func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal { - var ups []UserPortal - for rows.Next() { - up := UserPortal{}.Scan(u.log, rows) - if up != nil { - ups = append(ups, *up) - } - } - return ups -} - -func (db *Database) GetUsersInPortal(channelID string) []id.UserID { - rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID) - if err != nil { - db.Portal.log.Errorln("Failed to get users in portal:", err) - } - var users []id.UserID - for rows.Next() { - var mxid id.UserID - err = rows.Scan(&mxid) - if err != nil { - db.Portal.log.Errorln("Failed to scan user in portal:", err) - } else { - users = append(users, mxid) - } - } - return users -} - -func (u *User) GetPortals() []UserPortal { - rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID) - if err != nil { - u.log.Errorln("Failed to get portals:", err) - panic(err) - } - return u.scanUserPortals(rows) -} - -func (u *User) IsInSpace(discordID string) (isIn bool) { - query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2` - err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err) - panic(err) - } - return -} - -func (u *User) IsInPortal(discordID string) (isIn bool) { - query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)` - err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err) - panic(err) - } - return -} - -func (u *User) MarkInPortal(portal UserPortal) { - query := ` - INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (discord_id, user_mxid) DO UPDATE - SET timestamp=excluded.timestamp, in_space=excluded.in_space - ` - _, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace) - if err != nil { - u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err) - panic(err) - } -} - -func (u *User) MarkNotInPortal(discordID string) { - query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2` - _, err := u.db.Exec(query, u.MXID, discordID) - if err != nil { - u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err) - panic(err) - } -} - -func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) { - query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2` - err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers) - if err != nil { - u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err) - panic(err) - } - return -} - -func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal { - query := ` - DELETE FROM user_portal - WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild') - RETURNING discord_id, type, timestamp, in_space - ` - rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli()) - if err != nil { - u.log.Errorln("Failed to prune user guild list:", err) - panic(err) - } - return u.scanUserPortals(rows) -} diff --git a/directmedia.go b/directmedia.go deleted file mode 100644 index c6f8a2b..0000000 --- a/directmedia.go +++ /dev/null @@ -1,658 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "io" - "mime" - "mime/multipart" - "net" - "net/http" - "net/textproto" - "net/url" - "os" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gorilla/mux" - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/federation" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/config" - "go.mau.fi/mautrix-discord/database" -) - -type DirectMediaAPI struct { - bridge *DiscordBridge - ks *federation.KeyServer - cfg config.DirectMedia - log zerolog.Logger - proxy http.Client - - signatureKey [32]byte - - attachmentCache map[AttachmentCacheKey]AttachmentCacheValue - attachmentCacheLock sync.Mutex -} - -type AttachmentCacheKey struct { - ChannelID uint64 - AttachmentID uint64 -} - -type AttachmentCacheValue struct { - URL string - Expiry time.Time -} - -func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI { - if !br.Config.Bridge.DirectMedia.Enabled { - return nil - } - dma := &DirectMediaAPI{ - bridge: br, - cfg: br.Config.Bridge.DirectMedia, - log: br.ZLog.With().Str("component", "direct media").Logger(), - proxy: http.Client{ - Transport: &http.Transport{ - DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, - TLSHandshakeTimeout: 10 * time.Second, - ForceAttemptHTTP2: false, - }, - Timeout: 60 * time.Second, - }, - attachmentCache: make(map[AttachmentCacheKey]AttachmentCacheValue), - } - r := br.AS.Router - - parsed, err := federation.ParseSynapseKey(dma.cfg.ServerKey) - if err != nil { - dma.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to parse server key") - os.Exit(11) - return nil - } - dma.signatureKey = sha256.Sum256(parsed.Priv.Seed()) - dma.ks = &federation.KeyServer{ - KeyProvider: &federation.StaticServerKey{ - ServerName: dma.cfg.ServerName, - Key: parsed, - }, - WellKnownTarget: dma.cfg.WellKnownResponse, - Version: federation.ServerVersion{ - Name: br.Name, - Version: br.Version, - }, - } - if dma.ks.WellKnownTarget == "" { - dma.ks.WellKnownTarget = fmt.Sprintf("%s:443", dma.cfg.ServerName) - } - federationRouter := r.PathPrefix("/_matrix/federation").Subrouter() - mediaRouter := r.PathPrefix("/_matrix/media").Subrouter() - clientMediaRouter := r.PathPrefix("/_matrix/client/v1/media").Subrouter() - var reqIDCounter atomic.Uint64 - middleware := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization") - log := dma.log.With(). - Str("remote_addr", r.RemoteAddr). - Str("request_path", r.URL.Path). - Uint64("req_id", reqIDCounter.Add(1)). - Logger() - next.ServeHTTP(w, r.WithContext(log.WithContext(r.Context()))) - }) - } - mediaRouter.Use(middleware) - federationRouter.Use(middleware) - clientMediaRouter.Use(middleware) - addRoutes := func(version string) { - mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) - mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet) - mediaRouter.HandleFunc("/"+version+"/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) - mediaRouter.HandleFunc("/"+version+"/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut) - mediaRouter.HandleFunc("/"+version+"/upload", dma.UploadNotSupported).Methods(http.MethodPost) - mediaRouter.HandleFunc("/"+version+"/create", dma.UploadNotSupported).Methods(http.MethodPost) - mediaRouter.HandleFunc("/"+version+"/config", dma.UploadNotSupported).Methods(http.MethodGet) - mediaRouter.HandleFunc("/"+version+"/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet) - } - clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) - clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet) - clientMediaRouter.HandleFunc("/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) - clientMediaRouter.HandleFunc("/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut) - clientMediaRouter.HandleFunc("/upload", dma.UploadNotSupported).Methods(http.MethodPost) - clientMediaRouter.HandleFunc("/create", dma.UploadNotSupported).Methods(http.MethodPost) - clientMediaRouter.HandleFunc("/config", dma.UploadNotSupported).Methods(http.MethodGet) - clientMediaRouter.HandleFunc("/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet) - addRoutes("v3") - addRoutes("r0") - addRoutes("v1") - federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) - federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet) - mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) - mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) - federationRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) - federationRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) - dma.ks.Register(r) - - return dma -} - -func (dma *DirectMediaAPI) makeMXC(data MediaIDData) id.ContentURI { - return id.ContentURI{ - Homeserver: dma.cfg.ServerName, - FileID: data.Wrap().SignedString(dma.signatureKey), - } -} - -func parseExpiryTS(addr string) time.Time { - parsedURL, err := url.Parse(addr) - if err != nil { - return time.Time{} - } - tsBytes, err := hex.DecodeString(parsedURL.Query().Get("ex")) - if err != nil || len(tsBytes) != 4 { - return time.Time{} - } - parsedTS := int64(binary.BigEndian.Uint32(tsBytes)) - if parsedTS > time.Now().Unix() && parsedTS < time.Now().Add(365*24*time.Hour).Unix() { - return time.Unix(parsedTS, 0) - } - return time.Time{} -} - -func (dma *DirectMediaAPI) addAttachmentToCache(channelID uint64, att *discordgo.MessageAttachment) time.Time { - attachmentID, err := strconv.ParseUint(att.ID, 10, 64) - if err != nil { - return time.Time{} - } - expiry := parseExpiryTS(att.URL) - if expiry.IsZero() { - expiry = time.Now().Add(24 * time.Hour) - } - dma.attachmentCache[AttachmentCacheKey{ - ChannelID: channelID, - AttachmentID: attachmentID, - }] = AttachmentCacheValue{ - URL: att.URL, - Expiry: expiry, - } - return expiry -} - -func (dma *DirectMediaAPI) AttachmentMXC(channelID, messageID string, att *discordgo.MessageAttachment) (mxc id.ContentURI) { - if dma == nil { - return - } - channelIDInt, err := strconv.ParseUint(channelID, 10, 64) - if err != nil { - dma.log.Warn().Str("channel_id", channelID).Msg("Got non-integer channel ID") - return - } - messageIDInt, err := strconv.ParseUint(messageID, 10, 64) - if err != nil { - dma.log.Warn().Str("message_id", messageID).Msg("Got non-integer message ID") - return - } - attachmentIDInt, err := strconv.ParseUint(att.ID, 10, 64) - if err != nil { - dma.log.Warn().Str("attachment_id", att.ID).Msg("Got non-integer attachment ID") - return - } - dma.attachmentCacheLock.Lock() - dma.addAttachmentToCache(channelIDInt, att) - dma.attachmentCacheLock.Unlock() - return dma.makeMXC(&AttachmentMediaData{ - ChannelID: channelIDInt, - MessageID: messageIDInt, - AttachmentID: attachmentIDInt, - }) -} - -func (dma *DirectMediaAPI) EmojiMXC(emojiID, name string, animated bool) (mxc id.ContentURI) { - if dma == nil { - return - } - emojiIDInt, err := strconv.ParseUint(emojiID, 10, 64) - if err != nil { - dma.log.Warn().Str("emoji_id", emojiID).Msg("Got non-integer emoji ID") - return - } - return dma.makeMXC(&EmojiMediaData{ - EmojiMediaDataInner: EmojiMediaDataInner{ - EmojiID: emojiIDInt, - Animated: animated, - }, - Name: name, - }) -} - -func (dma *DirectMediaAPI) StickerMXC(stickerID string, format discordgo.StickerFormat) (mxc id.ContentURI) { - if dma == nil { - return - } - stickerIDInt, err := strconv.ParseUint(stickerID, 10, 64) - if err != nil { - dma.log.Warn().Str("sticker_id", stickerID).Msg("Got non-integer sticker ID") - return - } else if format > 255 || format < 0 { - dma.log.Warn().Int("format", int(format)).Msg("Got invalid sticker format") - return - } - return dma.makeMXC(&StickerMediaData{ - StickerID: stickerIDInt, - Format: byte(format), - }) -} - -func (dma *DirectMediaAPI) AvatarMXC(guildID, userID, avatarID string) (mxc id.ContentURI) { - if dma == nil { - return - } - animated := strings.HasPrefix(avatarID, "a_") - avatarIDBytes, err := hex.DecodeString(strings.TrimPrefix(avatarID, "a_")) - if err != nil { - dma.log.Warn().Str("avatar_id", avatarID).Msg("Got non-hex avatar ID") - return - } else if len(avatarIDBytes) != 16 { - dma.log.Warn().Str("avatar_id", avatarID).Msg("Got invalid avatar ID length") - return - } - avatarIDArray := [16]byte(avatarIDBytes) - userIDInt, err := strconv.ParseUint(userID, 10, 64) - if err != nil { - dma.log.Warn().Str("user_id", userID).Msg("Got non-integer user ID") - return - } - if guildID != "" { - guildIDInt, err := strconv.ParseUint(guildID, 10, 64) - if err != nil { - dma.log.Warn().Str("guild_id", guildID).Msg("Got non-integer guild ID") - return - } - return dma.makeMXC(&GuildMemberAvatarMediaData{ - GuildID: guildIDInt, - UserID: userIDInt, - AvatarID: avatarIDArray, - Animated: animated, - }) - } else { - return dma.makeMXC(&UserAvatarMediaData{ - UserID: userIDInt, - AvatarID: avatarIDArray, - Animated: animated, - }) - } -} - -type RespError struct { - Code string - Message string - Status int -} - -func (re *RespError) Error() string { - return re.Message -} - -var ErrNoUsersWithAccessFound = errors.New("no users found to fetch message") -var ErrAttachmentNotFound = errors.New("attachment not found") - -func (dma *DirectMediaAPI) fetchNewAttachmentURL(ctx context.Context, meta *AttachmentMediaData) (string, time.Time, error) { - var client *discordgo.Session - channelIDStr := strconv.FormatUint(meta.ChannelID, 10) - portal := dma.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: channelIDStr}) - var users []id.UserID - if portal != nil && portal.GuildID != "" { - users = dma.bridge.DB.GetUsersInPortal(portal.GuildID) - } else { - users = dma.bridge.DB.GetUsersInPortal(channelIDStr) - } - for _, userID := range users { - user := dma.bridge.GetCachedUserByMXID(userID) - if user == nil || user.Session == nil { - continue - } - perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channelIDStr) - if err == nil && perms&discordgo.PermissionViewChannel == 0 { - continue - } - if client == nil || err == nil { - client = user.Session - if !client.IsUser { - break - } - } - } - if client == nil { - return "", time.Time{}, ErrNoUsersWithAccessFound - } - var msgs []*discordgo.Message - var err error - messageIDStr := strconv.FormatUint(meta.MessageID, 10) - if client.IsUser { - msgs, err = client.ChannelMessages(channelIDStr, 5, "", "", messageIDStr) - } else { - var msg *discordgo.Message - msg, err = client.ChannelMessage(channelIDStr, messageIDStr) - msgs = []*discordgo.Message{msg} - } - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to fetch message: %w", err) - } - attachmentIDStr := strconv.FormatUint(meta.AttachmentID, 10) - var url string - var expiry time.Time - for _, item := range msgs { - for _, att := range item.Attachments { - thisExpiry := dma.addAttachmentToCache(meta.ChannelID, att) - if att.ID == attachmentIDStr { - url = att.URL - expiry = thisExpiry - } - } - } - if url == "" { - return "", time.Time{}, ErrAttachmentNotFound - } - return url, expiry, nil -} - -func (dma *DirectMediaAPI) GetEmojiInfo(contentURI id.ContentURI) *EmojiMediaData { - if dma == nil || contentURI.IsEmpty() || contentURI.Homeserver != dma.cfg.ServerName { - return nil - } - mediaID, err := ParseMediaID(contentURI.FileID, dma.signatureKey) - if err != nil { - return nil - } - emojiData, ok := mediaID.Data.(*EmojiMediaData) - if !ok { - return nil - } - return emojiData - -} - -func (dma *DirectMediaAPI) getMediaURL(ctx context.Context, encodedMediaID string) (url string, expiry time.Time, err error) { - var mediaID *MediaID - mediaID, err = ParseMediaID(encodedMediaID, dma.signatureKey) - if err != nil { - err = &RespError{ - Code: mautrix.MNotFound.ErrCode, - Message: err.Error(), - Status: http.StatusNotFound, - } - return - } - switch mediaData := mediaID.Data.(type) { - case *AttachmentMediaData: - dma.attachmentCacheLock.Lock() - defer dma.attachmentCacheLock.Unlock() - cached, ok := dma.attachmentCache[mediaData.CacheKey()] - if ok && time.Until(cached.Expiry) > 5*time.Minute { - return cached.URL, cached.Expiry, nil - } - zerolog.Ctx(ctx).Debug(). - Uint64("channel_id", mediaData.ChannelID). - Uint64("message_id", mediaData.MessageID). - Uint64("attachment_id", mediaData.AttachmentID). - Msg("Refreshing attachment URL") - url, expiry, err = dma.fetchNewAttachmentURL(ctx, mediaData) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to refresh attachment URL") - msg := "Failed to refresh attachment URL" - if errors.Is(err, ErrNoUsersWithAccessFound) { - msg = "No users found with access to the channel" - } else if errors.Is(err, ErrAttachmentNotFound) { - msg = "Attachment not found in message. Perhaps it was deleted?" - } - err = &RespError{ - Code: mautrix.MNotFound.ErrCode, - Message: msg, - Status: http.StatusNotFound, - } - } else { - zerolog.Ctx(ctx).Debug().Time("expiry", expiry).Msg("Successfully refreshed attachment URL") - } - case *EmojiMediaData: - if mediaData.Animated { - url = discordgo.EndpointEmojiAnimated(strconv.FormatUint(mediaData.EmojiID, 10)) - } else { - url = discordgo.EndpointEmoji(strconv.FormatUint(mediaData.EmojiID, 10)) - } - case *StickerMediaData: - url = discordgo.EndpointStickerImage( - strconv.FormatUint(mediaData.StickerID, 10), - discordgo.StickerFormat(mediaData.Format), - ) - case *UserAvatarMediaData: - if mediaData.Animated { - url = discordgo.EndpointUserAvatarAnimated( - strconv.FormatUint(mediaData.UserID, 10), - fmt.Sprintf("a_%x", mediaData.AvatarID), - ) - } else { - url = discordgo.EndpointUserAvatar( - strconv.FormatUint(mediaData.UserID, 10), - fmt.Sprintf("%x", mediaData.AvatarID), - ) - } - case *GuildMemberAvatarMediaData: - if mediaData.Animated { - url = discordgo.EndpointGuildMemberAvatarAnimated( - strconv.FormatUint(mediaData.GuildID, 10), - strconv.FormatUint(mediaData.UserID, 10), - fmt.Sprintf("a_%x", mediaData.AvatarID), - ) - } else { - url = discordgo.EndpointGuildMemberAvatar( - strconv.FormatUint(mediaData.GuildID, 10), - strconv.FormatUint(mediaData.UserID, 10), - fmt.Sprintf("%x", mediaData.AvatarID), - ) - } - default: - zerolog.Ctx(ctx).Error().Type("media_data_type", mediaData).Msg("Unrecognized media data struct") - err = &RespError{ - Code: "M_UNKNOWN", - Message: "Unrecognized media data struct", - Status: http.StatusInternalServerError, - } - } - return -} - -func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWriter, url, fileName string) { - log := zerolog.Ctx(ctx) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - log.Err(err).Str("url", url).Msg("Failed to create proxy request") - jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ - ErrCode: "M_UNKNOWN", - Err: "Failed to create proxy request", - }) - return - } - for key, val := range discordgo.DroidDownloadHeaders { - req.Header.Set(key, val) - } - resp, err := dma.proxy.Do(req) - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - if err != nil { - log.Err(err).Str("url", url).Msg("Failed to proxy download") - jsonResponse(w, http.StatusServiceUnavailable, &mautrix.RespError{ - ErrCode: "M_UNKNOWN", - Err: "Failed to proxy download", - }) - return - } else if resp.StatusCode != http.StatusOK { - log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("Unexpected status code proxying download") - jsonResponse(w, resp.StatusCode, &mautrix.RespError{ - ErrCode: "M_UNKNOWN", - Err: "Unexpected status code proxying download", - }) - return - } - w.Header()["Content-Type"] = resp.Header["Content-Type"] - w.Header()["Content-Length"] = resp.Header["Content-Length"] - w.Header()["Last-Modified"] = resp.Header["Last-Modified"] - w.Header()["Cache-Control"] = resp.Header["Cache-Control"] - contentDisposition := "attachment" - switch resp.Header.Get("Content-Type") { - case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif", - "image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime", - "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav", - "audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf": - contentDisposition = "inline" - } - if fileName != "" { - contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{ - "filename": fileName, - }) - } - w.Header().Set("Content-Disposition", contentDisposition) - w.WriteHeader(http.StatusOK) - _, err = io.Copy(w, resp.Body) - if err != nil { - log.Debug().Err(err).Msg("Failed to write proxy response") - } -} - -func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - log := zerolog.Ctx(ctx) - isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/") - vars := mux.Vars(r) - if !isNewFederation && vars["serverName"] != dma.cfg.ServerName { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MNotFound.ErrCode, - Err: fmt.Sprintf("This is a Discord media proxy for %q, other media downloads are not available here", dma.cfg.ServerName), - }) - return - } - // TODO check destination header in X-Matrix auth when isNewFederation - - url, expiresAt, err := dma.getMediaURL(ctx, vars["mediaID"]) - if err != nil { - var respError *RespError - if errors.As(err, &respError) { - jsonResponse(w, respError.Status, &mautrix.RespError{ - ErrCode: respError.Code, - Err: respError.Message, - }) - } else { - log.Err(err).Str("media_id", vars["mediaID"]).Msg("Failed to get media URL") - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MNotFound.ErrCode, - Err: "Media not found", - }) - } - return - } - if isNewFederation { - mp := multipart.NewWriter(w) - w.Header().Set("Content-Type", strings.Replace(mp.FormDataContentType(), "form-data", "mixed", 1)) - var metaPart io.Writer - metaPart, err = mp.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"application/json"}, - }) - if err != nil { - log.Err(err).Msg("Failed to create multipart metadata field") - return - } - _, err = metaPart.Write([]byte(`{}`)) - if err != nil { - log.Err(err).Msg("Failed to write multipart metadata field") - return - } - _, err = mp.CreatePart(textproto.MIMEHeader{ - "Location": {url}, - }) - if err != nil { - log.Err(err).Msg("Failed to create multipart redirect field") - return - } - err = mp.Close() - if err != nil { - log.Err(err).Msg("Failed to close multipart writer") - return - } - return - } - // Proxy if the config allows proxying and the request doesn't allow redirects. - // In any other case, redirect to the Discord CDN. - if dma.cfg.AllowProxy && r.URL.Query().Get("allow_redirect") != "true" { - dma.proxyDownload(ctx, w, url, vars["fileName"]) - return - } - w.Header().Set("Location", url) - expirySeconds := (time.Until(expiresAt) - 5*time.Minute).Seconds() - if expiresAt.IsZero() { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - } else if expirySeconds > 0 { - cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds)) - w.Header().Set("Cache-Control", cacheControl) - } else { - w.Header().Set("Cache-Control", "no-store") - } - w.WriteHeader(http.StatusTemporaryRedirect) -} - -func (dma *DirectMediaAPI) UploadNotSupported(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "This bridge only supports proxying Discord media downloads and does not support media uploads.", - }) -} - -func (dma *DirectMediaAPI) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "This bridge only supports proxying Discord media downloads and does not support URL previews.", - }) -} - -func (dma *DirectMediaAPI) UnknownEndpoint(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "Unrecognized endpoint", - }) -} - -func (dma *DirectMediaAPI) UnsupportedMethod(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{ - ErrCode: mautrix.MUnrecognized.ErrCode, - Err: "Invalid method for endpoint", - }) -} diff --git a/directmedia_id.go b/directmedia_id.go deleted file mode 100644 index 92b935a..0000000 --- a/directmedia_id.go +++ /dev/null @@ -1,287 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "errors" - "fmt" - "io" -) - -const MediaIDPrefix = "\U0001F408DISCORD" -const MediaIDVersion = 1 - -type MediaIDClass uint8 - -const ( - MediaIDClassAttachment MediaIDClass = 1 - MediaIDClassEmoji MediaIDClass = 2 - MediaIDClassSticker MediaIDClass = 3 - MediaIDClassUserAvatar MediaIDClass = 4 - MediaIDClassGuildMemberAvatar MediaIDClass = 5 -) - -type MediaIDData interface { - Write(to io.Writer) - Read(from io.Reader) error - Size() int - Wrap() *MediaID -} - -type MediaID struct { - Version uint8 - TypeClass MediaIDClass - Data MediaIDData -} - -func ParseMediaID(id string, key [32]byte) (*MediaID, error) { - data, err := base64.RawURLEncoding.DecodeString(id) - if err != nil { - return nil, fmt.Errorf("failed to decode base64: %w", err) - } - hasher := hmac.New(sha256.New, key[:]) - checksum := data[len(data)-TruncatedHashLength:] - data = data[:len(data)-TruncatedHashLength] - hasher.Write(data) - if !hmac.Equal(checksum, hasher.Sum(nil)[:TruncatedHashLength]) { - return nil, ErrMediaIDChecksumMismatch - } - mid := &MediaID{} - err = mid.Read(bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("failed to parse media ID: %w", err) - } - return mid, nil -} - -const TruncatedHashLength = 16 - -func (mid *MediaID) SignedString(key [32]byte) string { - buf := bytes.NewBuffer(make([]byte, 0, mid.Size())) - mid.Write(buf) - hasher := hmac.New(sha256.New, key[:]) - hasher.Write(buf.Bytes()) - buf.Write(hasher.Sum(nil)[:TruncatedHashLength]) - return base64.RawURLEncoding.EncodeToString(buf.Bytes()) -} - -func (mid *MediaID) Write(to io.Writer) { - _, _ = to.Write([]byte(MediaIDPrefix)) - _ = binary.Write(to, binary.BigEndian, mid.Version) - _ = binary.Write(to, binary.BigEndian, mid.TypeClass) - mid.Data.Write(to) -} - -func (mid *MediaID) Size() int { - return len(MediaIDPrefix) + 2 + mid.Data.Size() + TruncatedHashLength -} - -var ( - ErrInvalidMediaID = errors.New("invalid media ID") - ErrMediaIDChecksumMismatch = errors.New("invalid checksum in media ID") - ErrUnsupportedMediaID = errors.New("unsupported media ID") -) - -func (mid *MediaID) Read(from io.Reader) error { - prefix := make([]byte, len(MediaIDPrefix)) - _, err := io.ReadFull(from, prefix) - if err != nil || !bytes.Equal(prefix, []byte(MediaIDPrefix)) { - return fmt.Errorf("%w: prefix not found", ErrInvalidMediaID) - } - versionAndClass := make([]byte, 2) - _, err = io.ReadFull(from, versionAndClass) - if err != nil { - return fmt.Errorf("%w: version and class not found", ErrInvalidMediaID) - } else if versionAndClass[0] != MediaIDVersion { - return fmt.Errorf("%w: unknown version %d", ErrUnsupportedMediaID, versionAndClass[0]) - } - switch MediaIDClass(versionAndClass[1]) { - case MediaIDClassAttachment: - mid.Data = &AttachmentMediaData{} - case MediaIDClassEmoji: - mid.Data = &EmojiMediaData{} - case MediaIDClassSticker: - mid.Data = &StickerMediaData{} - case MediaIDClassUserAvatar: - mid.Data = &UserAvatarMediaData{} - case MediaIDClassGuildMemberAvatar: - mid.Data = &GuildMemberAvatarMediaData{} - default: - return fmt.Errorf("%w: unrecognized type class %d", ErrUnsupportedMediaID, versionAndClass[1]) - } - err = mid.Data.Read(from) - if err != nil { - return fmt.Errorf("failed to parse media ID data: %w", err) - } - return nil -} - -type AttachmentMediaData struct { - ChannelID uint64 - MessageID uint64 - AttachmentID uint64 -} - -func (amd *AttachmentMediaData) Write(to io.Writer) { - _ = binary.Write(to, binary.BigEndian, amd) -} - -func (amd *AttachmentMediaData) Read(from io.Reader) (err error) { - return binary.Read(from, binary.BigEndian, amd) -} - -func (amd *AttachmentMediaData) Size() int { - return binary.Size(amd) -} - -func (amd *AttachmentMediaData) Wrap() *MediaID { - return &MediaID{ - Version: MediaIDVersion, - TypeClass: MediaIDClassAttachment, - Data: amd, - } -} - -func (amd *AttachmentMediaData) CacheKey() AttachmentCacheKey { - return AttachmentCacheKey{ - ChannelID: amd.ChannelID, - AttachmentID: amd.AttachmentID, - } -} - -type StickerMediaData struct { - StickerID uint64 - Format uint8 -} - -func (smd *StickerMediaData) Write(to io.Writer) { - _ = binary.Write(to, binary.BigEndian, smd) -} - -func (smd *StickerMediaData) Read(from io.Reader) error { - return binary.Read(from, binary.BigEndian, smd) -} - -func (smd *StickerMediaData) Size() int { - return binary.Size(smd) -} - -func (smd *StickerMediaData) Wrap() *MediaID { - return &MediaID{ - Version: MediaIDVersion, - TypeClass: MediaIDClassSticker, - Data: smd, - } -} - -type EmojiMediaDataInner struct { - EmojiID uint64 - Animated bool -} - -type EmojiMediaData struct { - EmojiMediaDataInner - Name string -} - -func (emd *EmojiMediaData) Write(to io.Writer) { - _ = binary.Write(to, binary.BigEndian, &emd.EmojiMediaDataInner) - _, _ = to.Write([]byte(emd.Name)) -} - -func (emd *EmojiMediaData) Read(from io.Reader) (err error) { - err = binary.Read(from, binary.BigEndian, &emd.EmojiMediaDataInner) - if err != nil { - return - } - name, err := io.ReadAll(from) - if err != nil { - return - } - emd.Name = string(name) - return -} - -func (emd *EmojiMediaData) Size() int { - return binary.Size(&emd.EmojiMediaDataInner) + len(emd.Name) -} - -func (emd *EmojiMediaData) Wrap() *MediaID { - return &MediaID{ - Version: MediaIDVersion, - TypeClass: MediaIDClassEmoji, - Data: emd, - } -} - -type UserAvatarMediaData struct { - UserID uint64 - Animated bool - AvatarID [16]byte -} - -func (uamd *UserAvatarMediaData) Write(to io.Writer) { - _ = binary.Write(to, binary.BigEndian, uamd) -} - -func (uamd *UserAvatarMediaData) Read(from io.Reader) error { - return binary.Read(from, binary.BigEndian, uamd) -} - -func (uamd *UserAvatarMediaData) Size() int { - return binary.Size(uamd) -} - -func (uamd *UserAvatarMediaData) Wrap() *MediaID { - return &MediaID{ - Version: MediaIDVersion, - TypeClass: MediaIDClassUserAvatar, - Data: uamd, - } -} - -type GuildMemberAvatarMediaData struct { - GuildID uint64 - UserID uint64 - Animated bool - AvatarID [16]byte -} - -func (guamd *GuildMemberAvatarMediaData) Write(to io.Writer) { - _ = binary.Write(to, binary.BigEndian, guamd) -} - -func (guamd *GuildMemberAvatarMediaData) Read(from io.Reader) error { - return binary.Read(from, binary.BigEndian, guamd) -} - -func (guamd *GuildMemberAvatarMediaData) Size() int { - return binary.Size(guamd) -} - -func (guamd *GuildMemberAvatarMediaData) Wrap() *MediaID { - return &MediaID{ - Version: MediaIDVersion, - TypeClass: MediaIDClassGuildMemberAvatar, - Data: guamd, - } -} diff --git a/discord.go b/discord.go deleted file mode 100644 index 37cddbc..0000000 --- a/discord.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "errors" - - "github.com/bwmarrin/discordgo" -) - -func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool { - switch channel.Type { - case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews: - // allowed - case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM: - // DMs are always bridgeable, no need for permission checks - return true - default: - // everything else is not allowed - return false - } - - log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger() - - member, err := user.Session.State.Member(channel.GuildID, user.DiscordID) - if errors.Is(err, discordgo.ErrStateNotFound) { - log.Debug().Msg("Fetching own membership in guild to check roles") - member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get own membership in guild from server") - } else { - err = user.Session.State.MemberAdd(member) - if err != nil { - log.Warn().Err(err).Msg("Failed to add own membership in guild to cache") - } - } - } else if err != nil { - log.Warn().Err(err).Msg("Failed to get own membership in guild from cache") - } - err = user.Session.State.ChannelAdd(channel) - if err != nil { - log.Warn().Err(err).Msg("Failed to add channel to cache") - } - perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable") - return true - } - log.Debug(). - Int64("permissions", perms). - Bool("view_channel", perms&discordgo.PermissionViewChannel > 0). - Msg("Computed permissions in channel") - return perms&discordgo.PermissionViewChannel > 0 -} diff --git a/docker-run.sh b/docker-run.sh index 054a636..f4a5630 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -15,7 +15,7 @@ function fixperms { } if [[ ! -f /data/config.yaml ]]; then - cp /opt/mautrix-discord/example-config.yaml /data/config.yaml + /usr/bin/mautrix-discord -c /data/config.yaml -e echo "Didn't find a config file." echo "Copied default config file to /data/config.yaml" echo "Modify that config file to your liking." diff --git a/example-config.yaml b/example-config.yaml deleted file mode 100644 index 7fca846..0000000 --- a/example-config.yaml +++ /dev/null @@ -1,370 +0,0 @@ -# Homeserver details. -homeserver: - # The address that this appservice can use to connect to the homeserver. - address: https://matrix.example.com - # The domain of the homeserver (also known as server_name, used for MXIDs, etc). - domain: example.com - - # What software is the homeserver running? - # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. - software: standard - # The URL to push real-time bridge status to. - # If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes. - # The bridge will use the appservice as_token to authorize requests. - status_endpoint: null - # Endpoint for reporting per-message status. - message_send_checkpoint_endpoint: null - # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? - async_media: false - - # 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: - # The address that the homeserver can use to connect to this appservice. - address: http://localhost:29334 - - # The hostname and port where this appservice should listen. - hostname: 0.0.0.0 - port: 29334 - - # Database config. - database: - # The database type. "sqlite3-fk-wal" and "postgres" are supported. - type: postgres - # The database URI. - # SQLite: A raw file path is supported, but `file:?_txlock=immediate` is recommended. - # https://github.com/mattn/go-sqlite3#connection-string - # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable - # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql - uri: postgres://user:password@host/database?sslmode=disable - # Maximum number of connections. Mostly relevant for Postgres. - max_open_conns: 20 - max_idle_conns: 2 - # Maximum connection idle time and lifetime before they're closed. Disabled if null. - # Parsed with https://pkg.go.dev/time#ParseDuration - max_conn_idle_time: null - max_conn_lifetime: null - - # The unique ID of this appservice. - id: discord - # Appservice bot details. - bot: - # Username of the appservice bot. - username: discordbot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - displayname: Discord bridge bot - avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC - - # Whether or not to receive ephemeral events via appservice transactions. - # Requires MSC2409 support (i.e. Synapse 1.22+). - ephemeral_events: true - - # Should incoming events be handled asynchronously? - # This may be necessary for large public instances with lots of messages going through. - # However, messages will not be guaranteed to be bridged in the same order they were sent in. - async_transactions: false - - # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. - as_token: "This value is generated when generating the registration" - hs_token: "This value is generated when generating the registration" - -# Bridge config -bridge: - # Localpart template of MXIDs for Discord users. - # {{.}} is replaced with the internal ID of the Discord user. - username_template: discord_{{.}} - # Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled. - # Available variables: - # .ID - Internal user ID - # .Username - 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 - # .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. - # .ParentName - Parent channel name (used for categories). - # .GuildName - Guild name. - # .NSFW - Whether the channel is marked as NSFW. - # .Type - Channel type (see values at https://github.com/bwmarrin/discordgo/blob/v0.25.0/structs.go#L251-L267) - channel_name_template: '{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}' - # Displayname template for Discord guilds (bridged as spaces). - # Available variables: - # .Name - Guild name - guild_name_template: '{{.Name}}' - # Whether to explicitly set the avatar and room name for private chat portal rooms. - # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. - # If set to `always`, all DM rooms will have explicit names and avatars set. - # If set to `never`, DM rooms will never have names and avatars set. - private_chat_portal_meta: default - - # Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode. - # If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address. - # This should not have a trailing slash, the endpoint above will be appended to the provided address. - public_address: null - # A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature. - avatar_proxy_key: generate - - portal_message_buffer: 128 - - # Number of private channel portals to create on bridge startup. - # Other portals will be created when receiving messages. - startup_private_channel_create_limit: 5 - # Should the bridge send a read receipt from the bridge bot when a message has been sent to Discord? - delivery_receipts: false - # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. - message_status_events: false - # Whether the bridge should send error notices via m.notice events when a message fails to bridge. - message_error_notices: true - # Should the bridge use space-restricted join rules instead of invite-only for guild rooms? - # This can avoid unnecessary invite events in guild rooms when members are synced in. - restricted_rooms: true - # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? - # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). - autojoin_thread_on_open: true - # Should inline fields in Discord embeds be bridged as HTML tables to Matrix? - # Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI. - embed_fields_as_tables: true - # Should guild channels be muted when the portal is created? This only meant for single-user instances, - # it won't mute it for all users if there are multiple Matrix users in the same Discord guild. - mute_channels_on_create: false - # Should the bridge update the m.direct account data event when double puppeting is enabled. - # Note that updating the m.direct event is not atomic (except with mautrix-asmux) - # and is therefore prone to race conditions. - sync_direct_chat_list: false - # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. - # This field will automatically be changed back to false after it, except if the config file is not writable. - resend_bridge_info: false - # Should incoming custom emoji reactions be bridged as mxc:// URIs? - # If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available. - custom_emoji_reactions: true - # Should the bridge attempt to completely delete portal rooms when a channel is deleted on Discord? - # If true, the bridge will try to kick Matrix users from the room. Otherwise, the bridge only makes ghosts leave. - delete_portal_on_channel_delete: false - # Should the bridge delete all portal rooms when you leave a guild on Discord? - # This only applies if the guild has no other Matrix users on this bridge instance. - delete_guild_on_leave: true - # Whether or not created rooms should have federation enabled. - # If false, created portal rooms will never be federated. - federate_rooms: true - # 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 - # Settings for converting Discord media to custom mxc:// URIs instead of reuploading. - # More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html - direct_media: - # Should custom mxc:// URIs be used instead of reuploading media? - enabled: false - # The server name to use for the custom mxc:// URIs. - # This server name will effectively be a real Matrix server, it just won't implement anything other than media. - # You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge. - server_name: discord-media.example.com - # Optionally a custom .well-known response. This defaults to `server_name:443` - well_known_response: - # The bridge supports MSC3860 media download redirects and will use them if the requester supports it. - # Optionally, you can force redirects and not allow proxying at all by setting this to false. - allow_proxy: true - # Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file. - # This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them. - server_key: generate - # Settings for converting animated stickers. - animated_sticker: - # Format to which animated stickers should be converted. - # disable - No conversion, send as-is (lottie JSON) - # png - converts to non-animated png (fastest) - # gif - converts to animated gif - # webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support - # webp - converts to animated webp, requires ffmpeg executable with webp codec/container support - target: webp - # Arguments for converter. All converters take width and height. - args: - width: 320 - height: 320 - fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended) - # Servers to always allow double puppeting from - double_puppet_server_map: - example.com: https://example.com - # Allow using double puppeting from any server with a valid client .well-known file. - double_puppet_allow_discovery: false - # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth - # - # If set, double puppeting will be enabled automatically for local users - # instead of users having to find an access token and run `login-matrix` - # manually. - login_shared_secret_map: - example.com: foobar - - # The prefix for commands. Only required in non-management rooms. - command_prefix: '!discord' - # Messages sent upon joining a management room. - # Markdown is supported. The defaults are listed below. - management_room_text: - # Sent when joining a room. - welcome: "Hello, I'm a Discord bridge bot." - # Sent when joining a management room and the user is already logged in. - welcome_connected: "Use `help` for help." - # Sent when joining a management room and the user is not logged in. - welcome_unconnected: "Use `help` for help or `login` to log in." - # Optional extra text sent when joining a management room. - additional_help: "" - - # Settings for backfilling messages. - backfill: - # Limits for forward backfilling. - forward_limits: - # Initial backfill (when creating portal). 0 means backfill is disabled. - # A special unlimited value is not supported, you must set a limit. Initial backfill will - # fetch all messages first before backfilling anything, so high limits can take a lot of time. - initial: - dm: 0 - channel: 0 - thread: 0 - # Missed message backfill (on startup). - # 0 means backfill is disabled, -1 means fetch all messages since last bridged message. - # When using unlimited backfill (-1), messages are backfilled as they are fetched. - # With limits, all messages up to the limit are fetched first and backfilled afterwards. - missed: - dm: 0 - channel: 0 - thread: 0 - # Maximum members in a guild to enable backfilling. Set to -1 to disable limit. - # This can be used as a rough heuristic to disable backfilling in channels that are too active. - # Currently only applies to missed message backfill. - max_guild_members: -1 - - # End-to-bridge encryption support options. - # - # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. - encryption: - # Allow encryption, work in group chat rooms with e2ee enabled - allow: false - # Default to encryption, force-enable encryption in all portals the bridge creates - # This will cause the bridge bot to be in private chats for the encryption to work properly. - default: false - # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. - appservice: false - # Require encryption, drop any unencrypted messages. - require: false - # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. - # You must use a client that supports requesting keys from other users to use this feature. - allow_key_sharing: false - # Should users mentions be in the event wire content to enable the server to send push notifications? - plaintext_mentions: false - # Options for deleting megolm sessions from the bridge. - delete_keys: - # Beeper-specific: delete outbound sessions when hungryserv confirms - # that the user has uploaded the key to key backup. - delete_outbound_on_ack: false - # Don't store outbound sessions in the inbound table. - dont_store_outbound: false - # Ratchet megolm sessions forward after decrypting messages. - ratchet_on_decrypt: false - # Delete fully used keys (index >= max_messages) after decrypting messages. - delete_fully_used_on_decrypt: false - # Delete previous megolm sessions from same device when receiving a new one. - delete_prev_on_new_session: false - # Delete megolm sessions received from a device when the device is deleted. - delete_on_device_delete: false - # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. - periodically_delete_expired: false - # Delete inbound megolm sessions that don't have the received_at field used for - # automatic ratcheting and expired session deletion. This is meant as a migration - # to delete old keys prior to the bridge update. - delete_outdated_inbound: false - # What level of device verification should be required from users? - # - # Valid levels: - # unverified - Send keys to all device in the room. - # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. - # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). - # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. - # Note that creating user signatures from the bridge bot is not currently possible. - # verified - Require manual per-device verification - # (currently only possible by modifying the `trust` column in the `crypto_device` database table). - verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix. - receive: unverified - # Minimum level that the bridge should accept for incoming Matrix messages. - send: unverified - # Minimum level that the bridge should require for accepting key requests. - share: cross-signed-tofu - # Options for Megolm room key rotation. These options allow you to - # configure the m.room.encryption event content. See: - # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for - # more information about that event. - rotation: - # Enable custom Megolm room key rotation settings. Note that these - # settings will only apply to rooms created after this option is - # set. - enable_custom: false - # The maximum number of milliseconds a session should be used - # before changing it. The Matrix spec recommends 604800000 (a week) - # as the default. - milliseconds: 604800000 - # The maximum number of messages that should be sent with a given a - # session before changing it. The Matrix spec recommends 100 as the - # default. - messages: 100 - - # 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. - prefix: /_matrix/provision - # Shared secret for authentication. If set to "generate", a random secret will be generated, - # or if set to "disable", the provisioning API will be disabled. - shared_secret: generate - # Enable debug API at /debug with provisioning authentication. - debug_endpoints: false - - # Permissions for using the bridge. - # Permitted values: - # relay - Talk through the relaybot (if enabled), no access otherwise - # user - Access to use the bridge to chat with a Discord account. - # admin - User level and some additional administration tools - # Permitted keys: - # * - All Matrix users - # domain - All users on that homeserver - # mxid - Specific user - permissions: - "*": relay - "example.com": user - "@admin:example.com": admin - -# Logging config. See https://github.com/tulir/zeroconfig for details. -logging: - min_level: debug - writers: - - type: stdout - format: pretty-colored - - type: file - format: json - filename: ./logs/mautrix-discord.log - max_size: 100 - max_backups: 10 - compress: true diff --git a/formatter.go b/formatter.go deleted file mode 100644 index 8e99aa9..0000000 --- a/formatter.go +++ /dev/null @@ -1,246 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "regexp" - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/util" - "go.mau.fi/util/variationselector" - "golang.org/x/exp/slices" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/format/mdext" - "maunium.net/go/mautrix/id" -) - -// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement -// -// Discord allows escaping with just one backslash, e.g. \__a__, -// but standard markdown requires both to be escaped (\_\_a__) -var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) - -func escapeReplacement(s string) string { - return s[:2] + `\` + s[2:] -} - -// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. -// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). -type indentableParagraphParser struct { - parser.BlockParser -} - -var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} - -func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { - return true -} - -var removeFeaturesExceptLinks = []any{ - parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), - parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(), - parser.NewCodeBlockParser(), -} -var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser()) -var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) -var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag) - -var discordRenderer = goldmark.New( - goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)), - fixIndentedParagraphs, format.HTMLOptions, discordExtensions, -) -var discordRendererWithInlineLinks = goldmark.New( - goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)), - fixIndentedParagraphs, format.HTMLOptions, discordExtensions, -) - -func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string { - text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement) - - var buf strings.Builder - ctx := parser.NewContext() - ctx.Set(parserContextPortal, portal) - renderer := discordRenderer - if allowInlineLinks { - renderer = discordRendererWithInlineLinks - } - err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx)) - if err != nil { - panic(fmt.Errorf("markdown parser errored: %w", err)) - } - return format.UnwrapSingleParagraph(buf.String()) -} - -const 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 { - if item == newItem { - return arr - } - } - return append(arr, newItem) -} - -func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string { - if len(mxid) == 0 { - return displayname - } - if mxid[0] == '#' { - alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid)) - if err != nil { - return displayname - } - mxid = alias.RoomID.String() - } - if mxid[0] == '!' { - portal := br.GetPortalByMXID(id.RoomID(mxid)) - if portal != nil { - if eventID == "" { - //currentPortal := ctx[formatterContextPortalKey].(*Portal) - return fmt.Sprintf("<#%s>", portal.Key.ChannelID) - //if currentPortal.GuildID == portal.GuildID { - //} else if portal.GuildID != "" { - // return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name) - //} else { - // // TODO is mentioning private channels possible at all? - //} - } else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil { - guildID := portal.GuildID - if guildID == "" { - guildID = "@me" - } - return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID) - } - } - } else if mxid[0] == '@' { - allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID) - if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) { - return displayname - } - mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions) - parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid)) - if ok { - mentions.Users = appendIfNotContains(mentions.Users, parsedID) - return fmt.Sprintf("<@%s>", parsedID) - } - mentionedUser := br.GetUserByMXID(id.UserID(mxid)) - if mentionedUser != nil && mentionedUser.DiscordID != "" { - mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID) - return fmt.Sprintf("<@%s>", mentionedUser.DiscordID) - } - } - return displayname -} - -const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]` - -// Discord links start with http:// or https://, contain at least two characters afterwards, -// don't contain < or whitespace anywhere, and don't end with "'),.:;] -// -// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason -var discordLinkRegex = regexp.MustCompile(discordLinkPattern) -var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") - -var discordMarkdownEscaper = strings.NewReplacer( - `\`, `\\`, - `_`, `\_`, - `*`, `\*`, - `~`, `\~`, - "`", "\\`", - `|`, `\|`, - `<`, `\<`, - `#`, `\#`, -) - -func escapeDiscordMarkdown(s string) string { - submatches := discordLinkRegex.FindAllStringIndex(s, -1) - if submatches == nil { - return discordMarkdownEscaper.Replace(s) - } - var builder strings.Builder - offset := 0 - for _, match := range submatches { - start := match[0] - end := match[1] - builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start])) - builder.WriteString(s[start:end]) - offset = end - } - builder.WriteString(discordMarkdownEscaper.Replace(s[offset:])) - return builder.String() -} - -var matrixHTMLParser = &format.HTMLParser{ - TabsToSpaces: 4, - Newline: "\n", - HorizontalLine: "\n---\n", - ItalicConverter: func(s string, ctx format.Context) string { - return fmt.Sprintf("*%s*", s) - }, - UnderlineConverter: func(s string, ctx format.Context) string { - return fmt.Sprintf("__%s__", s) - }, - TextConverter: func(s string, ctx format.Context) string { - if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") { - // If we're in a code block, don't escape markdown - return s - } - return escapeDiscordMarkdown(s) - }, - SpoilerConverter: func(text, reason string, ctx format.Context) string { - if reason != "" { - return fmt.Sprintf("(%s) ||%s||", reason, text) - } - return fmt.Sprintf("||%s||", text) - }, - LinkConverter: func(text, href string, ctx format.Context) string { - if text == href { - return text - } else if !discordLinkRegexFull.MatchString(href) { - return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href)) - } - return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href) - }, -} - -func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) { - allowedMentions := &discordgo.MessageAllowedMentions{ - Parse: []discordgo.AllowedMentionType{}, - Users: []string{}, - RepliedUser: true, - } - if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { - ctx := format.NewContext() - ctx.ReturnData[formatterContextPortalKey] = portal - ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions - 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 - } -} diff --git a/formatter_everyone.go b/formatter_everyone.go deleted file mode 100644 index b1aed5a..0000000 --- a/formatter_everyone.go +++ /dev/null @@ -1,110 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "regexp" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -type astDiscordEveryone struct { - ast.BaseInline - onlyHere bool -} - -var _ ast.Node = (*astDiscordEveryone)(nil) -var astKindDiscordEveryone = ast.NewNodeKind("DiscordEveryone") - -func (n *astDiscordEveryone) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -func (n *astDiscordEveryone) Kind() ast.NodeKind { - return astKindDiscordEveryone -} - -func (n *astDiscordEveryone) String() string { - if n.onlyHere { - return "@here" - } - return "@everyone" -} - -type discordEveryoneParser struct{} - -var discordEveryoneRegex = regexp.MustCompile(`@(everyone|here)`) -var defaultDiscordEveryoneParser = &discordEveryoneParser{} - -func (s *discordEveryoneParser) Trigger() []byte { - return []byte{'@'} -} - -func (s *discordEveryoneParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - line, _ := block.PeekLine() - match := discordEveryoneRegex.FindSubmatch(line) - if match == nil { - return nil - } - block.Advance(len(match[0])) - return &astDiscordEveryone{ - onlyHere: string(match[1]) == "here", - } -} - -func (s *discordEveryoneParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -type discordEveryoneHTMLRenderer struct{} - -func (r *discordEveryoneHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(astKindDiscordEveryone, r.renderDiscordEveryone) -} - -func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { - status = ast.WalkContinue - if !entering { - return - } - mention, _ := n.(*astDiscordEveryone) - class := "everyone" - if mention != nil && mention.onlyHere { - class = "here" - } - _, _ = fmt.Fprintf(w, `@room`, class) - return -} - -type discordEveryone struct{} - -var ExtDiscordEveryone = &discordEveryone{} - -func (e *discordEveryone) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(defaultDiscordEveryoneParser, 600), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(&discordEveryoneHTMLRenderer{}, 600), - )) -} diff --git a/formatter_tag.go b/formatter_tag.go deleted file mode 100644 index fb7f741..0000000 --- a/formatter_tag.go +++ /dev/null @@ -1,343 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "math" - "regexp" - "strconv" - "strings" - "time" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/database" -) - -type astDiscordTag struct { - ast.BaseInline - portal *Portal - id int64 -} - -var _ ast.Node = (*astDiscordTag)(nil) -var astKindDiscordTag = ast.NewNodeKind("DiscordTag") - -func (n *astDiscordTag) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -func (n *astDiscordTag) Kind() ast.NodeKind { - return astKindDiscordTag -} - -type astDiscordUserMention struct { - astDiscordTag - hasNick bool -} - -func (n *astDiscordUserMention) String() string { - if n.hasNick { - return fmt.Sprintf("<@!%d>", n.id) - } - return fmt.Sprintf("<@%d>", n.id) -} - -type astDiscordRoleMention struct { - astDiscordTag -} - -func (n *astDiscordRoleMention) String() string { - return fmt.Sprintf("<@&%d>", n.id) -} - -type astDiscordChannelMention struct { - astDiscordTag - - guildID int64 - name string -} - -func (n *astDiscordChannelMention) String() string { - if n.guildID != 0 { - return fmt.Sprintf("<#%d:%d:%s>", n.id, n.guildID, n.name) - } - return fmt.Sprintf("<#%d>", n.id) -} - -type discordTimestampStyle rune - -func (dts discordTimestampStyle) Format() string { - switch dts { - case 't': - return "15:04 MST" - case 'T': - return "15:04:05 MST" - case 'd': - return "2006-01-02 MST" - case 'D': - return "2 January 2006 MST" - case 'F': - return "Monday, 2 January 2006 15:04 MST" - case 'f': - fallthrough - default: - return "2 January 2006 15:04 MST" - } -} - -type astDiscordTimestamp struct { - astDiscordTag - - timestamp int64 - style discordTimestampStyle -} - -func (n *astDiscordTimestamp) String() string { - if n.style == 'f' { - return fmt.Sprintf("", n.timestamp) - } - return fmt.Sprintf("", n.timestamp, n.style) -} - -type astDiscordCustomEmoji struct { - astDiscordTag - name string - animated bool -} - -func (n *astDiscordCustomEmoji) String() string { - if n.animated { - return fmt.Sprintf("", n.name, n.id) - } - return fmt.Sprintf("<%s%d>", n.name, n.id) -} - -type discordTagParser struct{} - -// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting -var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`) -var defaultDiscordTagParser = &discordTagParser{} - -func (s *discordTagParser) Trigger() []byte { - return []byte{'<'} -} - -var parserContextPortal = parser.NewContextKey() - -func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { - portal := pc.Get(parserContextPortal).(*Portal) - //before := block.PrecendingCharacter() - line, _ := block.PeekLine() - match := discordTagRegex.FindSubmatch(line) - if match == nil { - return nil - } - //seg := segment.WithStop(segment.Start + len(match[0])) - block.Advance(len(match[0])) - - id, err := strconv.ParseInt(string(match[2]), 10, 64) - if err != nil { - return nil - } - tag := astDiscordTag{id: id, portal: portal} - tagName := string(match[1]) - switch { - case tagName == "@": - return &astDiscordUserMention{astDiscordTag: tag} - case tagName == "@!": - return &astDiscordUserMention{astDiscordTag: tag, hasNick: true} - case tagName == "@&": - return &astDiscordRoleMention{astDiscordTag: tag} - case tagName == "#": - var guildID int64 - var channelName string - if len(match[4]) > 0 && len(match[5]) > 0 { - guildID, _ = strconv.ParseInt(string(match[4]), 10, 64) - channelName = string(match[5]) - } - return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName} - case tagName == "t:": - var style discordTimestampStyle - if len(match[3]) == 0 { - style = 'f' - } else { - style = discordTimestampStyle(match[3][0]) - } - return &astDiscordTimestamp{ - astDiscordTag: tag, - timestamp: id, - style: style, - } - case strings.HasPrefix(tagName, ":"): - return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag} - case strings.HasPrefix(tagName, "a:"): - return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true} - default: - return nil - } -} - -func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) { - // nothing to do -} - -type discordTagHTMLRenderer struct{} - -var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{} - -func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(astKindDiscordTag, r.renderDiscordMention) -} - -func relativeTimeFormat(ts time.Time) string { - now := time.Now() - if ts.Year() >= 2262 { - return "date out of range for relative format" - } - duration := ts.Sub(now) - word := "in %s" - if duration < 0 { - duration = -duration - word = "%s ago" - } - var count int - var unit string - switch { - case duration < time.Second: - count = int(duration.Milliseconds()) - unit = "millisecond" - case duration < time.Minute: - count = int(math.Round(duration.Seconds())) - unit = "second" - case duration < time.Hour: - count = int(math.Round(duration.Minutes())) - unit = "minute" - case duration < 24*time.Hour: - count = int(math.Round(duration.Hours())) - unit = "hour" - case duration < 30*24*time.Hour: - count = int(math.Round(duration.Hours() / 24)) - unit = "day" - case duration < 365*24*time.Hour: - count = int(math.Round(duration.Hours() / 24 / 30)) - unit = "month" - default: - count = int(math.Round(duration.Hours() / 24 / 365)) - unit = "year" - } - var diff string - if count == 1 { - diff = fmt.Sprintf("a %s", unit) - } else { - diff = fmt.Sprintf("%d %ss", count, unit) - } - return fmt.Sprintf(word, diff) -} - -func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { - status = ast.WalkContinue - if !entering { - return - } - switch node := n.(type) { - case *astDiscordUserMention: - var mxid id.UserID - var name string - if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil { - mxid = puppet.MXID - name = puppet.Name - } - if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil { - mxid = user.MXID - if name == "" { - name = user.MXID.Localpart() - } - } - _, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), name) - return - case *astDiscordRoleMention: - role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10)) - if role != nil { - _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name) - return - } - case *astDiscordChannelMention: - portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{ - ChannelID: strconv.FormatInt(node.id, 10), - Receiver: "", - }) - if portal != nil { - if portal.MXID != "" { - _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name) - } else { - _, _ = w.WriteString(portal.Name) - } - return - } - case *astDiscordCustomEmoji: - reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated) - if !reactionMXC.IsEmpty() { - attrs := "data-mx-emoticon" - if node.animated { - attrs += " data-mau-animated-emoji" - } - _, _ = fmt.Fprintf(w, `%[2]s`, reactionMXC.String(), node.name, attrs) - return - } - case *astDiscordTimestamp: - ts := time.Unix(node.timestamp, 0).UTC() - var formatted string - if node.style == 'R' { - formatted = relativeTimeFormat(ts) - } else { - formatted = ts.Format(node.style.Format()) - } - // https://github.com/matrix-org/matrix-spec-proposals/pull/3160 - const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700" - fullRFC := ts.Format(fullDatetimeFormat) - fullHumanReadable := ts.Format(discordTimestampStyle('F').Format()) - _, _ = fmt.Fprintf(w, ``, fullHumanReadable, fullRFC, node.style, formatted) - } - stringifiable, ok := n.(fmt.Stringer) - if ok { - _, _ = w.WriteString(stringifiable.String()) - } else { - _, _ = w.Write(source) - } - return -} - -type discordTag struct{} - -var ExtDiscordTag = &discordTag{} - -func (e *discordTag) Extend(m goldmark.Markdown) { - m.Parser().AddOptions(parser.WithInlineParsers( - util.Prioritized(defaultDiscordTagParser, 600), - )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(defaultDiscordTagHTMLRenderer, 600), - )) -} diff --git a/formatter_test.go b/formatter_test.go deleted file mode 100644 index c05f95b..0000000 --- a/formatter_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEscapeDiscordMarkdown(t *testing.T) { - type escapeTest struct { - name string - input string - expected string - } - - tests := []escapeTest{ - {"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."}, - {"Backslash", `foo\bar`, `foo\\bar`}, - {"Underscore", `foo_bar`, `foo\_bar`}, - {"Asterisk", `foo*bar`, `foo\*bar`}, - {"Tilde", `foo~bar`, `foo\~bar`}, - {"Backtick", "foo`bar", "foo\\`bar"}, - {"Forward tick", `foo´bar`, `foo´bar`}, - {"Pipe", `foo|bar`, `foo\|bar`}, - {"Less than", `foobar`, `foo>bar`}, - {"Multiple things", `\_*~|`, `\\\_\*\~\|`}, - {"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`}, - {"Multiple URLs", `hello_world https://example.com/foo_bar *testing* https://a_b_c/*def*`, `hello\_world https://example.com/foo_bar \*testing\* https://a_b_c/*def*`}, - {"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"}, - {"URL ends with less than", `https://example.com. - -package main - -import ( - "errors" - "fmt" - "sync" - - log "maunium.net/go/maulogger/v2" - "maunium.net/go/maulogger/v2/maulogadapt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "github.com/bwmarrin/discordgo" - - "go.mau.fi/mautrix-discord/config" - "go.mau.fi/mautrix-discord/database" -) - -type Guild struct { - *database.Guild - - bridge *DiscordBridge - log log.Logger - - roomCreateLock sync.Mutex -} - -func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild { - if dbGuild == nil { - if id == "" || !createIfNotExist { - return nil - } - - dbGuild = br.DB.Guild.New() - dbGuild.ID = id - dbGuild.Insert() - } - - guild := br.NewGuild(dbGuild) - - br.guildsByID[guild.ID] = guild - if guild.MXID != "" { - br.guildsByMXID[guild.MXID] = guild - } - - return guild -} - -func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild { - br.guildsLock.Lock() - defer br.guildsLock.Unlock() - - portal, ok := br.guildsByMXID[mxid] - if !ok { - return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false) - } - - return portal -} - -func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild { - br.guildsLock.Lock() - defer br.guildsLock.Unlock() - - guild, ok := br.guildsByID[id] - if !ok { - return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist) - } - - return guild -} - -func (br *DiscordBridge) GetAllGuilds() []*Guild { - return br.dbGuildsToGuilds(br.DB.Guild.GetAll()) -} - -func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild { - br.guildsLock.Lock() - defer br.guildsLock.Unlock() - - output := make([]*Guild, len(dbGuilds)) - for index, dbGuild := range dbGuilds { - if dbGuild == nil { - continue - } - - guild, ok := br.guildsByID[dbGuild.ID] - if !ok { - guild = br.loadGuild(dbGuild, "", false) - } - - output[index] = guild - } - - return output -} - -func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild { - guild := &Guild{ - Guild: dbGuild, - bridge: br, - log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)), - } - - return guild -} - -func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: guild.bridge.Bot.UserID, - Creator: guild.bridge.Bot.UserID, - Protocol: event.BridgeInfoSection{ - ID: "discordgo", - DisplayName: "Discord", - AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - ExternalURL: "https://discord.com/", - }, - Channel: event.BridgeInfoSection{ - ID: guild.ID, - DisplayName: guild.Name, - AvatarURL: guild.AvatarURL.CUString(), - }, - } - bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID) - return bridgeInfoStateKey, bridgeInfo -} - -func (guild *Guild) UpdateBridgeInfo() { - if len(guild.MXID) == 0 { - guild.log.Debugln("Not updating bridge info: no Matrix room created") - return - } - guild.log.Debugln("Updating bridge info...") - stateKey, content := guild.getBridgeInfo() - _, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content) - if err != nil { - guild.log.Warnln("Failed to update m.bridge:", err) - } - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content) - if err != nil { - guild.log.Warnln("Failed to update uk.half-shot.bridge:", err) - } -} - -func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error { - guild.roomCreateLock.Lock() - defer guild.roomCreateLock.Unlock() - if guild.MXID != "" { - return nil - } - guild.log.Infoln("Creating Matrix room for guild") - guild.UpdateInfo(user, meta) - - bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo() - - initialState := []*event.Event{{ - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - - if !guild.AvatarURL.IsEmpty() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{Parsed: &event.RoomAvatarEventContent{ - URL: guild.AvatarURL, - }}, - }) - } - - creationContent := map[string]interface{}{ - "type": event.RoomTypeSpace, - } - if !guild.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - - resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ - Visibility: "private", - Name: guild.Name, - Preset: "private_chat", - InitialState: initialState, - CreationContent: creationContent, - }) - if err != nil { - guild.log.Warnln("Failed to create room:", err) - return err - } - - guild.MXID = resp.RoomID - guild.NameSet = true - guild.AvatarSet = !guild.AvatarURL.IsEmpty() - guild.Update() - guild.bridge.guildsLock.Lock() - guild.bridge.guildsByMXID[guild.MXID] = guild - guild.bridge.guildsLock.Unlock() - guild.log.Infoln("Matrix room created:", guild.MXID) - - user.ensureInvited(nil, guild.MXID, false, true) - - return nil -} - -func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild { - if meta.Unavailable { - guild.log.Debugfln("Ignoring unavailable guild update") - return meta - } - changed := false - changed = guild.UpdateName(meta) || changed - changed = guild.UpdateAvatar(meta.Icon) || changed - if changed { - guild.UpdateBridgeInfo() - guild.Update() - } - source.ensureInvited(nil, guild.MXID, false, false) - return meta -} - -func (guild *Guild) UpdateName(meta *discordgo.Guild) bool { - name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{ - Name: meta.Name, - }) - if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") { - return false - } - guild.log.Debugfln("Updating name %q -> %q", guild.Name, name) - guild.Name = name - guild.PlainName = meta.Name - guild.NameSet = false - if guild.MXID != "" { - _, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name) - if err != nil { - guild.log.Warnln("Failed to update room name: %s", err) - } else { - guild.NameSet = true - } - } - return true -} - -func (guild *Guild) UpdateAvatar(iconID string) bool { - if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") { - return false - } - guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID) - guild.AvatarSet = false - guild.Avatar = iconID - guild.AvatarURL = id.ContentURI{} - if guild.Avatar != "" { - // 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", iconID, err) - return true - } - guild.AvatarURL = copied.MXC - } - if guild.MXID != "" { - _, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL) - if err != nil { - guild.log.Warnln("Failed to update room avatar:", err) - } else { - guild.AvatarSet = true - } - } - return true -} - -func (guild *Guild) cleanup() { - if guild.MXID == "" { - return - } - intent := guild.bridge.Bot - if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := intent.BeeperDeleteRoom(guild.MXID) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err) - } - return - } - guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log)) -} - -func (guild *Guild) RemoveMXID() { - guild.bridge.guildsLock.Lock() - defer guild.bridge.guildsLock.Unlock() - if guild.MXID == "" { - return - } - delete(guild.bridge.guildsByMXID, guild.MXID) - guild.MXID = "" - guild.AvatarSet = false - guild.NameSet = false - guild.BridgingMode = database.GuildBridgeNothing - guild.Update() -} - -func (guild *Guild) Delete() { - guild.Guild.Delete() - guild.bridge.guildsLock.Lock() - delete(guild.bridge.guildsByID, guild.ID) - if guild.MXID != "" { - delete(guild.bridge.guildsByMXID, guild.MXID) - } - guild.bridge.guildsLock.Unlock() - -} diff --git a/main.go b/main.go deleted file mode 100644 index 1700d8f..0000000 --- a/main.go +++ /dev/null @@ -1,206 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - _ "embed" - "net/http" - "sync" - - "go.mau.fi/util/configupgrade" - "go.mau.fi/util/exsync" - "golang.org/x/sync/semaphore" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/config" - "go.mau.fi/mautrix-discord/database" -) - -// Information to find out exactly which commit the bridge was built from. -// These are filled at build time with the -X linker flag. -var ( - Tag = "unknown" - Commit = "unknown" - BuildTime = "unknown" -) - -//go:embed example-config.yaml -var ExampleConfig string - -type DiscordBridge struct { - bridge.Bridge - - Config *config.Config - DB *database.Database - - DMA *DirectMediaAPI - provisioning *ProvisioningAPI - - usersByMXID map[id.UserID]*User - usersByID map[string]*User - usersLock sync.Mutex - - managementRooms map[id.RoomID]*User - managementRoomsLock sync.Mutex - - portalsByMXID map[id.RoomID]*Portal - portalsByID map[database.PortalKey]*Portal - portalsLock sync.Mutex - - threadsByID map[string]*Thread - threadsByRootMXID map[id.EventID]*Thread - threadsByCreationNoticeMXID map[id.EventID]*Thread - threadsLock sync.Mutex - - guildsByMXID map[id.RoomID]*Guild - guildsByID map[string]*Guild - guildsLock sync.Mutex - - puppets map[string]*Puppet - puppetsByCustomMXID map[id.UserID]*Puppet - puppetsLock sync.Mutex - - attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]] - parallelAttachmentSemaphore *semaphore.Weighted -} - -func (br *DiscordBridge) GetExampleConfig() string { - return ExampleConfig -} - -func (br *DiscordBridge) GetConfigPtr() interface{} { - br.Config = &config.Config{ - BaseConfig: &br.Bridge.Config, - } - br.Config.BaseConfig.Bridge = &br.Config.Bridge - return br.Config -} - -func (br *DiscordBridge) Init() { - br.CommandProcessor = commands.NewProcessor(&br.Bridge) - br.RegisterCommands() - - matrixHTMLParser.PillConverter = br.pillConverter - - br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) - discordLog = br.ZLog.With().Str("component", "discordgo").Logger() -} - -func (br *DiscordBridge) Start() { - if br.Config.Bridge.Provisioning.SharedSecret != "disable" { - br.provisioning = newProvisioningAPI(br) - } - if br.Config.Bridge.PublicAddress != "" { - br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet) - } - br.DMA = newDirectMediaAPI(br) - br.WaitWebsocketConnected() - go br.startUsers() -} - -func (br *DiscordBridge) Stop() { - for _, user := range br.usersByMXID { - if user.Session == nil { - continue - } - - br.Log.Debugln("Disconnecting", user.MXID) - user.Session.Close() - } -} - -func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal { - p := br.GetPortalByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User { - p := br.GetUserByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *DiscordBridge) IsGhost(mxid id.UserID) bool { - _, isGhost := br.ParsePuppetMXID(mxid) - return isGhost -} - -func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost { - p := br.GetPuppetByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) { - //TODO implement -} - -func main() { - br := &DiscordBridge{ - usersByMXID: make(map[id.UserID]*User), - usersByID: make(map[string]*User), - - managementRooms: make(map[id.RoomID]*User), - - portalsByMXID: make(map[id.RoomID]*Portal), - portalsByID: make(map[database.PortalKey]*Portal), - - threadsByID: make(map[string]*Thread), - threadsByRootMXID: make(map[id.EventID]*Thread), - threadsByCreationNoticeMXID: make(map[id.EventID]*Thread), - - guildsByID: make(map[string]*Guild), - guildsByMXID: make(map[id.RoomID]*Guild), - - puppets: make(map[string]*Puppet), - puppetsByCustomMXID: make(map[id.UserID]*Puppet), - - attachmentTransfers: 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.7.0", - ProtocolName: "Discord", - BeeperServiceName: "discordgo", - BeeperNetworkName: "discord", - - CryptoPickleKey: "maunium.net/go/mautrix-whatsapp", - - ConfigUpgrader: &configupgrade.StructUpgrader{ - SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), - Blocks: config.SpacedBlocks, - Base: ExampleConfig, - }, - - Child: br, - } - br.InitVersion(Tag, Commit, BuildTime) - - br.Main() -} diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go new file mode 100644 index 0000000..ef628dd --- /dev/null +++ b/pkg/connector/chatinfo.go @@ -0,0 +1,28 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" +) + +func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + //TODO implement me + panic("implement me") +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go new file mode 100644 index 0000000..0a9f5eb --- /dev/null +++ b/pkg/connector/client.go @@ -0,0 +1,58 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" +) + +type DiscordClient struct { +} + +func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { + //TODO implement me + panic("implement me") +} + +var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) + +func (d *DiscordClient) Connect(ctx context.Context) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) Disconnect() { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) IsLoggedIn() bool { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) LogoutRemote(ctx context.Context) { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities { + //TODO implement me + panic("implement me") +} diff --git a/pkg/connector/config.go b/pkg/connector/config.go new file mode 100644 index 0000000..9b0fa42 --- /dev/null +++ b/pkg/connector/config.go @@ -0,0 +1,26 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "go.mau.fi/util/configupgrade" +) + +func (d *DiscordConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { + //TODO implement me + panic("implement me") +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go new file mode 100644 index 0000000..2cbb6b3 --- /dev/null +++ b/pkg/connector/connector.go @@ -0,0 +1,48 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" +) + +type DiscordConnector struct { +} + +var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) + +func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { + //TODO implement me + panic("implement me") +} + +func (d *DiscordConnector) Start(ctx context.Context) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordConnector) GetName() bridgev2.BridgeName { + //TODO implement me + panic("implement me") +} + +func (d *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { + //TODO implement me + panic("implement me") +} diff --git a/database/upgrades/upgrades.go b/pkg/connector/dbmeta.go similarity index 78% rename from database/upgrades/upgrades.go rename to pkg/connector/dbmeta.go index d6954d5..f397ec4 100644 --- a/database/upgrades/upgrades.go +++ b/pkg/connector/dbmeta.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -14,19 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package upgrades +package connector import ( - "embed" - - "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix/bridgev2/database" ) -var Table dbutil.UpgradeTable - -//go:embed *.sql -var rawUpgrades embed.FS - -func init() { - Table.RegisterFS(rawUpgrades) +func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { + //TODO implement me + panic("implement me") } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go new file mode 100644 index 0000000..d8084f9 --- /dev/null +++ b/pkg/connector/handlematrix.go @@ -0,0 +1,72 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" +) + +var ( + _ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil) + _ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil) + _ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil) + _ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil) + _ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil) +) + +func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + //TODO implement me + panic("implement me") +} diff --git a/config/config.go b/pkg/connector/login.go similarity index 63% rename from config/config.go rename to pkg/connector/login.go index d704651..71ee401 100644 --- a/config/config.go +++ b/pkg/connector/login.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -14,22 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package config +package connector import ( - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/id" + "context" + + "maunium.net/go/mautrix/bridgev2" ) -type Config struct { - *bridgeconfig.BaseConfig `yaml:",inline"` - - Bridge BridgeConfig `yaml:"bridge"` +func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { + //TODO implement me + panic("implement me") } -func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool { - _, homeserver, _ := userID.Parse() - _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver] - - return hasSecret +func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + //TODO implement me + panic("implement me") } diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go new file mode 100644 index 0000000..cdf0ee4 --- /dev/null +++ b/pkg/connector/userinfo.go @@ -0,0 +1,34 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { + //TODO implement me + panic("implement me") +} + +func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { + //TODO implement me + panic("implement me") +} diff --git a/remoteauth/README.md b/pkg/remoteauth/README.md similarity index 100% rename from remoteauth/README.md rename to pkg/remoteauth/README.md diff --git a/remoteauth/client.go b/pkg/remoteauth/client.go similarity index 100% rename from remoteauth/client.go rename to pkg/remoteauth/client.go diff --git a/remoteauth/clientpackets.go b/pkg/remoteauth/clientpackets.go similarity index 100% rename from remoteauth/clientpackets.go rename to pkg/remoteauth/clientpackets.go diff --git a/remoteauth/serverpackets.go b/pkg/remoteauth/serverpackets.go similarity index 98% rename from remoteauth/serverpackets.go rename to pkg/remoteauth/serverpackets.go index b7376d3..5e44037 100644 --- a/remoteauth/serverpackets.go +++ b/pkg/remoteauth/serverpackets.go @@ -103,6 +103,7 @@ func (h *serverHello) process(client *Client) error { ticker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond) go func() { defer ticker.Stop() + //lint:ignore S1000 - for { select { // case <-client.ctx.Done(): @@ -126,7 +127,7 @@ func (h *serverHello) process(client *Client) error { <-time.After(duration) client.Lock() - client.err = fmt.Errorf("Timed out after %s", duration) + client.err = fmt.Errorf("timed out after %s", duration) client.close() client.Unlock() }() diff --git a/remoteauth/user.go b/pkg/remoteauth/user.go similarity index 100% rename from remoteauth/user.go rename to pkg/remoteauth/user.go diff --git a/portal.go b/portal.go deleted file mode 100644 index a86fa64..0000000 --- a/portal.go +++ /dev/null @@ -1,2494 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "reflect" - "regexp" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gabriel-vasile/mimetype" - "github.com/gorilla/mux" - "github.com/rs/zerolog" - "go.mau.fi/util/exsync" - "go.mau.fi/util/variationselector" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/config" - "go.mau.fi/mautrix-discord/database" -) - -type portalDiscordMessage struct { - msg interface{} - user *User - - thread *Thread -} - -type portalMatrixMessage struct { - evt *event.Event - user *User -} - -var relayClient, _ = discordgo.New("") - -type Portal struct { - *database.Portal - - Parent *Portal - Guild *Guild - - bridge *DiscordBridge - log zerolog.Logger - - roomCreateLock sync.Mutex - encryptLock sync.Mutex - - discordMessages chan portalDiscordMessage - matrixMessages chan portalMatrixMessage - - recentMessages *exsync.RingBuffer[string, *discordgo.Message] - - commands map[string]*discordgo.ApplicationCommand - commandsLock sync.RWMutex - - forwardBackfillLock sync.Mutex - - currentlyTyping []id.UserID - currentlyTypingLock sync.Mutex -} - -const recentMessageBufferSize = 32 - -var _ bridge.Portal = (*Portal)(nil) -var _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) -var _ bridge.MembershipHandlingPortal = (*Portal)(nil) -var _ bridge.TypingPortal = (*Portal)(nil) - -//var _ bridge.MetaHandlingPortal = (*Portal)(nil) -//var _ bridge.DisappearingPortal = (*Portal)(nil) - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - portal.Update() -} - -func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.RelayWebhookID != "" { - portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt} - } -} - -var ( - portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType} -) - -func (br *DiscordBridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey, chanType discordgo.ChannelType) *Portal { - if dbPortal == nil { - if key == nil || chanType < 0 { - return nil - } - - dbPortal = br.DB.Portal.New() - dbPortal.Key = *key - dbPortal.Type = chanType - dbPortal.Insert() - } - - portal := br.NewPortal(dbPortal) - - br.portalsByID[portal.Key] = portal - if portal.MXID != "" { - br.portalsByMXID[portal.MXID] = portal - } - - if portal.GuildID != "" { - portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true) - } - if portal.ParentID != "" { - parentKey := database.NewPortalKey(portal.ParentID, "") - var ok bool - portal.Parent, ok = br.portalsByID[parentKey] - if !ok { - portal.Parent = br.loadPortal(br.DB.Portal.GetByID(parentKey), nil, -1) - } - } - - return portal -} - -func (br *DiscordBridge) GetPortalByMXID(mxid id.RoomID) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - portal, ok := br.portalsByMXID[mxid] - if !ok { - return br.loadPortal(br.DB.Portal.GetByMXID(mxid), nil, -1) - } - - return portal -} - -func (user *User) GetPortalByMeta(meta *discordgo.Channel) *Portal { - return user.GetPortalByID(meta.ID, meta.Type) -} - -func (user *User) GetExistingPortalByID(id string) *Portal { - return user.bridge.GetExistingPortalByID(database.NewPortalKey(id, user.DiscordID)) -} - -func (user *User) GetPortalByID(id string, chanType discordgo.ChannelType) *Portal { - return user.bridge.GetPortalByID(database.NewPortalKey(id, user.DiscordID), chanType) -} - -func (user *User) FindPrivateChatWith(userID string) *Portal { - user.bridge.portalsLock.Lock() - defer user.bridge.portalsLock.Unlock() - dbPortal := user.bridge.DB.Portal.FindPrivateChatBetween(userID, user.DiscordID) - if dbPortal == nil { - return nil - } - existing, ok := user.bridge.portalsByID[dbPortal.Key] - if ok { - return existing - } - return user.bridge.loadPortal(dbPortal, nil, discordgo.ChannelTypeDM) -} - -func (br *DiscordBridge) GetExistingPortalByID(key database.PortalKey) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal, ok := br.portalsByID[key] - if !ok { - if key.Receiver != "" { - portal, ok = br.portalsByID[database.NewPortalKey(key.ChannelID, "")] - } - if !ok { - return br.loadPortal(br.DB.Portal.GetByID(key), nil, -1) - } - } - - return portal -} - -func (br *DiscordBridge) GetPortalByID(key database.PortalKey, chanType discordgo.ChannelType) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - if chanType != discordgo.ChannelTypeDM { - key.Receiver = "" - } - - portal, ok := br.portalsByID[key] - if !ok { - return br.loadPortal(br.DB.Portal.GetByID(key), &key, chanType) - } - - return portal -} - -func (br *DiscordBridge) GetAllPortals() []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAll()) -} - -func (br *DiscordBridge) GetAllPortalsInGuild(guildID string) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllInGuild(guildID)) -} - -func (br *DiscordBridge) GetAllIPortals() (iportals []bridge.Portal) { - portals := br.GetAllPortals() - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *DiscordBridge) GetDMPortalsWith(otherUserID string) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(otherUserID)) -} - -func (br *DiscordBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - - portal, ok := br.portalsByID[dbPortal.Key] - if !ok { - portal = br.loadPortal(dbPortal, nil, -1) - } - - output[index] = portal - } - - return output -} - -func (br *DiscordBridge) NewPortal(dbPortal *database.Portal) *Portal { - portal := &Portal{ - Portal: dbPortal, - bridge: br, - log: br.ZLog.With(). - Str("channel_id", dbPortal.Key.ChannelID). - Str("channel_receiver", dbPortal.Key.Receiver). - Str("room_id", dbPortal.MXID.String()). - Logger(), - - discordMessages: make(chan portalDiscordMessage, br.Config.Bridge.PortalMessageBuffer), - matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), - - recentMessages: exsync.NewRingBuffer[string, *discordgo.Message](recentMessageBufferSize), - - commands: make(map[string]*discordgo.ApplicationCommand), - } - - go portal.messageLoop() - - return portal -} - -func (portal *Portal) messageLoop() { - for { - select { - case msg := <-portal.matrixMessages: - portal.handleMatrixMessages(msg) - case msg := <-portal.discordMessages: - portal.handleDiscordMessages(msg) - } - } -} - -func (portal *Portal) IsPrivateChat() bool { - return portal.Type == discordgo.ChannelTypeDM -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - if portal.IsPrivateChat() && portal.OtherUserID != "" { - return portal.bridge.GetPuppetByID(portal.OtherUserID).DefaultIntent() - } - - return portal.bridge.Bot -} - -type CustomBridgeInfoContent struct { - event.BridgeEventContent - RoomType string `json:"com.beeper.room_type,omitempty"` -} - -func init() { - event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) - event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) -} - -func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: "discordgo", - DisplayName: "Discord", - AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - ExternalURL: "https://discord.com/", - }, - Channel: event.BridgeInfoSection{ - ID: portal.Key.ChannelID, - DisplayName: portal.Name, - }, - } - var bridgeInfoStateKey string - if portal.GuildID == "" { - bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/dm/%s", portal.Key.ChannelID) - bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/@me/%s", portal.Key.ChannelID) - } else { - bridgeInfo.Network = &event.BridgeInfoSection{ - ID: portal.GuildID, - } - if portal.Guild != nil { - bridgeInfo.Network.DisplayName = portal.Guild.Name - bridgeInfo.Network.AvatarURL = portal.Guild.AvatarURL.CUString() - // TODO is it possible to find the URL? - } - bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/%s/%s", portal.GuildID, portal.Key.ChannelID) - bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/%s/%s", portal.GuildID, portal.Key.ChannelID) - } - var roomType string - if portal.Type == discordgo.ChannelTypeDM || portal.Type == discordgo.ChannelTypeGroupDM { - roomType = "dm" - } - return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType} -} - -func (portal *Portal) UpdateBridgeInfo() { - if len(portal.MXID) == 0 { - portal.log.Debug().Msg("Not updating bridge info: no Matrix room created") - return - } - portal.log.Debug().Msg("Updating bridge info...") - stateKey, content := portal.getBridgeInfo() - _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to update m.bridge") - } - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge") - } -} - -func (portal *Portal) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") -} - -func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { - evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} - if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { - evt.RotationPeriodMillis = rot.Milliseconds - evt.RotationPeriodMessages = rot.Messages - } - return -} - -func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - portal.ensureUserInvited(user, false) - return nil - } - portal.log.Info().Msg("Creating Matrix room for channel") - - channel = portal.UpdateInfo(user, channel) - if channel == nil { - return fmt.Errorf("didn't find channel metadata") - } - - intent := portal.MainIntent() - if err := intent.EnsureRegistered(); err != nil { - return err - } - - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - initialState := []*event.Event{{ - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - - var invite []id.UserID - - if portal.bridge.Config.Bridge.Encryption.Default { - initialState = append(initialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: portal.GetEncryptionEventContent(), - }, - }) - portal.Encrypted = true - - if portal.IsPrivateChat() { - invite = append(invite, portal.bridge.Bot.UserID) - } - } - - if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{Parsed: &event.RoomAvatarEventContent{ - URL: portal.AvatarURL, - }}, - }) - portal.AvatarSet = true - } else { - portal.AvatarSet = false - } - - creationContent := make(map[string]interface{}) - if portal.Type == discordgo.ChannelTypeGuildCategory { - creationContent["type"] = event.RoomTypeSpace - } - if !portal.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - spaceID := portal.ExpectedSpaceID() - if spaceID != "" { - spaceIDStr := spaceID.String() - initialState = append(initialState, &event.Event{ - Type: event.StateSpaceParent, - StateKey: &spaceIDStr, - Content: event.Content{Parsed: &event.SpaceParentEventContent{ - Via: []string{portal.bridge.AS.HomeserverDomain}, - Canonical: true, - }}, - }) - } - if portal.bridge.Config.Bridge.RestrictedRooms && portal.Guild != nil && portal.Guild.MXID != "" { - // TODO don't do this for private channels in guilds - initialState = append(initialState, &event.Event{ - Type: event.StateJoinRules, - Content: event.Content{Parsed: &event.JoinRulesEventContent{ - JoinRule: event.JoinRuleRestricted, - Allow: []event.JoinRuleAllow{{ - RoomID: portal.Guild.MXID, - Type: event.JoinRuleAllowRoomMembership, - }}, - }}, - }) - } - - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: portal.Name, - Topic: portal.Topic, - Invite: invite, - Preset: "private_chat", - IsDirect: portal.IsPrivateChat(), - InitialState: initialState, - CreationContent: creationContent, - } - if !portal.shouldSetDMRoomMetadata() && !portal.FriendNick { - req.Name = "" - } - - var backfillStarted bool - portal.forwardBackfillLock.Lock() - defer func() { - if !backfillStarted { - portal.log.Debug().Msg("Backfill wasn't started, unlocking forward backfill lock") - portal.forwardBackfillLock.Unlock() - } - }() - - resp, err := intent.CreateRoom(req) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to create room") - return err - } - - portal.NameSet = len(req.Name) > 0 - portal.TopicSet = len(req.Topic) > 0 - portal.MXID = resp.RoomID - portal.log = portal.bridge.ZLog.With(). - Str("channel_id", portal.Key.ChannelID). - Str("channel_receiver", portal.Key.Receiver). - Str("room_id", portal.MXID.String()). - Logger() - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - portal.Update() - portal.log.Info().Msg("Matrix room created") - - if portal.Encrypted && portal.IsPrivateChat() { - err = portal.bridge.Bot.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) - if err != nil { - portal.log.Err(err).Msg("Failed to ensure bridge bot is joined to encrypted private chat portal") - } - } - - if portal.GuildID == "" { - user.addPrivateChannelToSpace(portal) - } else { - portal.updateSpace(user) - } - portal.ensureUserInvited(user, true) - user.syncChatDoublePuppetDetails(portal, true) - - portal.syncParticipants(user, channel.Recipients) - - if portal.IsPrivateChat() { - puppet := user.bridge.GetPuppetByID(portal.Key.Receiver) - - chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}} - user.updateDirectChats(chats) - } - - firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, portalCreationDummyEvent, struct{}{}) - if err != nil { - portal.log.Err(err).Msg("Failed to send dummy event to mark portal creation") - } else { - portal.FirstEventID = firstEventResp.EventID - portal.Update() - } - - go portal.forwardBackfillInitial(user, nil) - backfillStarted = true - - return nil -} - -func (portal *Portal) handleDiscordMessages(msg portalDiscordMessage) { - if portal.MXID == "" { - msgCreate, ok := msg.msg.(*discordgo.MessageCreate) - if !ok { - portal.log.Warn().Msg("Can't create Matrix room from non new message event") - return - } - - portal.log.Debug(). - Str("message_id", msgCreate.ID). - Msg("Creating Matrix room from incoming message") - if err := portal.CreateMatrixRoom(msg.user, nil); err != nil { - portal.log.Err(err).Msg("Failed to create portal room") - return - } - } - portal.forwardBackfillLock.Lock() - defer portal.forwardBackfillLock.Unlock() - - switch convertedMsg := msg.msg.(type) { - case *discordgo.MessageCreate: - portal.handleDiscordMessageCreate(msg.user, convertedMsg.Message, msg.thread) - case *discordgo.MessageUpdate: - portal.handleDiscordMessageUpdate(msg.user, convertedMsg.Message) - case *discordgo.MessageDelete: - portal.handleDiscordMessageDelete(msg.user, convertedMsg.Message) - case *discordgo.MessageDeleteBulk: - portal.handleDiscordMessageDeleteBulk(msg.user, convertedMsg.Messages) - case *discordgo.MessageReactionAdd: - portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, true, msg.thread, convertedMsg.Member) - case *discordgo.MessageReactionRemove: - portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, false, msg.thread, nil) - default: - portal.log.Warn().Type("message_type", msg.msg).Msg("Unknown message type in handleDiscordMessages") - } -} - -func (portal *Portal) ensureUserInvited(user *User, ignoreCache bool) bool { - return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat(), ignoreCache) -} - -func (portal *Portal) markMessageHandled(discordID string, authorID string, timestamp time.Time, threadID string, senderMXID id.UserID, parts []database.MessagePart) *database.Message { - msg := portal.bridge.DB.Message.New() - msg.Channel = portal.Key - msg.DiscordID = discordID - msg.SenderID = authorID - msg.Timestamp = timestamp - msg.ThreadID = threadID - msg.SenderMXID = senderMXID - msg.MassInsertParts(parts) - msg.MXID = parts[0].MXID - msg.AttachmentID = parts[0].AttachmentID - return msg -} - -func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message, thread *Thread) { - switch msg.Type { - case discordgo.MessageTypeChannelNameChange, discordgo.MessageTypeChannelIconChange, discordgo.MessageTypeChannelPinnedMessage: - // These are handled via channel updates - return - } - - log := portal.log.With(). - Str("message_id", msg.ID). - Int("message_type", int(msg.Type)). - Str("author_id", msg.Author.ID). - Str("action", "discord message create"). - Logger() - ctx := log.WithContext(context.Background()) - - portal.recentMessages.Push(msg.ID, msg) - - existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID) - if existing != nil { - log.Debug().Msg("Dropping duplicate message") - return - } - - handlingStartTime := time.Now() - puppet := portal.bridge.GetPuppetByID(msg.Author.ID) - puppet.UpdateInfo(user, msg.Author, msg) - intent := puppet.IntentFor(portal) - - 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 - } - } - replyTo := portal.getReplyTarget(user, discordThreadID, msg.MessageReference, msg.Embeds, false) - mentions := portal.convertDiscordMentions(msg, true) - - ts, _ := discordgo.SnowflakeTimestamp(msg.ID) - parts := portal.convertDiscordMessage(ctx, puppet, intent, msg) - dbParts := make([]database.MessagePart, 0, len(parts)) - eventIDs := zerolog.Dict() - for i, part := range parts { - if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil { - part.Content.RelatesTo = &event.RelatesTo{} - } - if threadRootEvent != "" { - part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent) - } - if replyTo != nil { - part.Content.RelatesTo.SetReplyTo(replyTo.EventID) - if replyTo.UnstableRoomID != "" { - part.Content.RelatesTo.InReplyTo.UnstableRoomID = replyTo.UnstableRoomID - } - // 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{} - - resp, err := portal.sendMatrixMessage(intent, part.Type, part.Content, part.Extra, ts.UnixMilli()) - if err != nil { - log.Err(err). - Int("part_index", i). - Str("attachment_id", part.AttachmentID). - Msg("Failed to send part of message to Matrix") - continue - } - lastThreadEvent = resp.EventID - dbParts = append(dbParts, database.MessagePart{AttachmentID: part.AttachmentID, MXID: resp.EventID}) - eventIDs.Str(part.AttachmentID, resp.EventID.String()) - } - - log = log.With().Dur("handling_time", time.Since(handlingStartTime)).Logger() - if len(parts) == 0 { - log.Warn().Msg("Unhandled message") - } else if len(dbParts) == 0 { - log.Warn().Msg("All parts of message failed to send to Matrix") - } else { - log.Debug().Dict("event_ids", eventIDs).Msg("Finished handling Discord message") - firstDBMessage := portal.markMessageHandled(msg.ID, msg.Author.ID, ts, discordThreadID, intent.UserID, dbParts) - if msg.Flags == discordgo.MessageFlagsHasThread { - portal.bridge.threadFound(ctx, user, firstDBMessage, msg.ID, msg.Thread) - } - } -} - -var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`) - -func isReplyEmbed(embed *discordgo.MessageEmbed) bool { - return hackyReplyPattern.MatchString(embed.Description) -} - -func (portal *Portal) getReplyTarget(source *User, threadID string, ref *discordgo.MessageReference, embeds []*discordgo.MessageEmbed, allowNonExistent bool) *event.InReplyTo { - if ref == nil && len(embeds) > 0 { - match := hackyReplyPattern.FindStringSubmatch(embeds[0].Description) - if match != nil && match[1] == portal.GuildID && (match[2] == portal.Key.ChannelID || match[2] == threadID) { - ref = &discordgo.MessageReference{ - MessageID: match[3], - ChannelID: match[2], - GuildID: match[1], - } - } - } - if ref == nil { - return nil - } - // TODO add config option for cross-room replies - crossRoomReplies := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry - - targetPortal := portal - if ref.ChannelID != portal.Key.ChannelID && ref.ChannelID != threadID && crossRoomReplies { - targetPortal = portal.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: ref.ChannelID, Receiver: source.DiscordID}) - if targetPortal == nil { - return nil - } - } - replyToMsg := portal.bridge.DB.Message.GetByDiscordID(targetPortal.Key, ref.MessageID) - if len(replyToMsg) > 0 { - if !crossRoomReplies { - return &event.InReplyTo{EventID: replyToMsg[0].MXID} - } - return &event.InReplyTo{ - EventID: replyToMsg[0].MXID, - UnstableRoomID: targetPortal.MXID, - } - } else if allowNonExistent { - return &event.InReplyTo{ - EventID: targetPortal.deterministicEventID(ref.MessageID, ""), - UnstableRoomID: targetPortal.MXID, - } - } - return nil -} - -const JoinThreadReaction = "join thread" - -func (portal *Portal) sendThreadCreationNotice(ctx context.Context, thread *Thread) { - thread.creationNoticeLock.Lock() - defer thread.creationNoticeLock.Unlock() - if thread.CreationNoticeMXID != "" { - return - } - creationNotice := "Thread created. React to this message with \"join thread\" to join the thread on Discord." - if portal.bridge.Config.Bridge.AutojoinThreadOnOpen { - creationNotice = "Thread created. Opening this thread will auto-join you to it on Discord." - } - log := zerolog.Ctx(ctx) - resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{ - Body: creationNotice, - MsgType: event.MsgNotice, - RelatesTo: (&event.RelatesTo{}).SetThread(thread.RootMXID, thread.RootMXID), - }, nil, time.Now().UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send thread creation notice") - return - } - portal.bridge.threadsLock.Lock() - thread.CreationNoticeMXID = resp.EventID - portal.bridge.threadsByCreationNoticeMXID[resp.EventID] = thread - portal.bridge.threadsLock.Unlock() - thread.Update() - log.Debug(). - Str("creation_notice_mxid", thread.CreationNoticeMXID.String()). - Msg("Sent thread creation notice") - - resp, err = portal.MainIntent().SendMessageEvent(portal.MXID, event.EventReaction, &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: thread.CreationNoticeMXID, - Key: JoinThreadReaction, - }, - }) - if err != nil { - log.Err(err).Msg("Failed to send prefilled reaction to thread creation notice") - } else { - log.Debug(). - Str("reaction_event_id", resp.EventID.String()). - Str("creation_notice_mxid", thread.CreationNoticeMXID.String()). - Msg("Sent prefilled reaction to thread creation notice") - } -} - -func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Message) { - log := portal.log.With(). - Str("message_id", msg.ID). - Str("action", "discord message update"). - Logger() - ctx := log.WithContext(context.Background()) - if portal.MXID == "" { - log.Warn().Msg("handle message called without a valid portal") - return - } - - existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID) - if existing == nil { - log.Warn().Msg("Dropping update of unknown message") - return - } - if msg.EditedTimestamp != nil && !msg.EditedTimestamp.After(existing[0].EditTimestamp) { - log.Debug(). - Time("received_edit_ts", *msg.EditedTimestamp). - Time("db_edit_ts", existing[0].EditTimestamp). - Msg("Dropping update of message with older or equal edit timestamp") - return - } - - if msg.Flags == discordgo.MessageFlagsHasThread { - portal.bridge.threadFound(ctx, user, existing[0], msg.ID, msg.Thread) - } - - if msg.Author == nil { - creationMessage, ok := portal.recentMessages.Get(msg.ID) - if !ok { - log.Debug().Msg("Dropping edit with no author of non-recent message") - return - } else if creationMessage.Type == discordgo.MessageTypeCall { - log.Debug().Msg("Dropping edit with of call message") - return - } - log.Debug().Msg("Found original message in cache for edit without author") - if len(msg.Embeds) > 0 { - creationMessage.Embeds = msg.Embeds - } - if len(msg.Attachments) > 0 { - creationMessage.Attachments = msg.Attachments - } - if len(msg.Components) > 0 { - creationMessage.Components = msg.Components - } - // TODO are there other fields that need copying? - msg = creationMessage - } else { - portal.recentMessages.Replace(msg.ID, msg) - } - if msg.Author.ID == portal.RelayWebhookID { - log.Debug(). - Str("message_id", msg.ID). - Str("author_id", msg.Author.ID). - Msg("Dropping edit from relay webhook") - return - } - - puppet := portal.bridge.GetPuppetByID(msg.Author.ID) - intent := puppet.IntentFor(portal) - - redactions := zerolog.Dict() - attachmentMap := map[string]*database.Message{} - for _, existingPart := range existing { - if existingPart.AttachmentID != "" { - attachmentMap[existingPart.AttachmentID] = existingPart - } - } - for _, remainingAttachment := range msg.Attachments { - if _, found := attachmentMap[remainingAttachment.ID]; found { - delete(attachmentMap, remainingAttachment.ID) - } - } - for _, remainingSticker := range msg.StickerItems { - if _, found := attachmentMap[remainingSticker.ID]; found { - delete(attachmentMap, remainingSticker.ID) - } - } - for _, remainingEmbed := range msg.Embeds { - // Other types of embeds are sent inline with the text message part - if getEmbedType(nil, remainingEmbed) != EmbedVideo { - continue - } - embedID := "video_" + remainingEmbed.URL - if _, found := attachmentMap[embedID]; found { - delete(attachmentMap, embedID) - } - } - for _, deletedAttachment := range attachmentMap { - resp, err := intent.RedactEvent(portal.MXID, deletedAttachment.MXID) - if err != nil { - log.Err(err). - Str("event_id", deletedAttachment.MXID.String()). - Msg("Failed to redact attachment") - } else { - redactions.Str(deletedAttachment.AttachmentID, resp.EventID.String()) - } - deletedAttachment.Delete() - } - - var converted *ConvertedMessage - // Slightly hacky special case: messages with gif links will get an embed with the gif. - // The link isn't rendered on Discord, so just edit the link message into a gif message on Matrix too. - if isPlainGifMessage(msg) { - converted = portal.convertDiscordVideoEmbed(ctx, intent, msg.Embeds[0]) - } else { - converted = portal.convertDiscordTextMessage(ctx, intent, msg) - } - if converted == nil { - log.Debug(). - Bool("has_message_on_matrix", existing[0].AttachmentID == ""). - Bool("has_text_on_discord", len(msg.Content) > 0). - Msg("Dropping non-text edit") - return - } - puppet.addWebhookMeta(converted, msg) - puppet.addMemberMeta(converted, msg) - converted.Content.Mentions = portal.convertDiscordMentions(msg, false) - converted.Content.SetEdit(existing[0].MXID) - // Never actually mention new users of edits, only include mentions inside m.new_content - converted.Content.Mentions = &event.Mentions{} - if converted.Extra != nil { - converted.Extra = map[string]any{ - "m.new_content": converted.Extra, - } - } - - var editTS int64 - if msg.EditedTimestamp != nil { - editTS = msg.EditedTimestamp.UnixMilli() - } - // TODO figure out some way to deduplicate outgoing edits - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, converted.Content, converted.Extra, editTS) - if err != nil { - log.Err(err).Msg("Failed to send edit to Matrix") - return - } - - portal.sendDeliveryReceipt(resp.EventID) - - if msg.EditedTimestamp != nil { - existing[0].UpdateEditTimestamp(*msg.EditedTimestamp) - } - log.Debug(). - Str("event_id", resp.EventID.String()). - Dict("redacted_attachments", redactions). - Msg("Finished handling Discord edit") -} - -func (portal *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message) { - lastResp := portal.redactAllParts(portal.MainIntent(), msg.ID) - if lastResp != "" { - portal.sendDeliveryReceipt(lastResp) - } -} - -func (portal *Portal) handleDiscordMessageDeleteBulk(user *User, messages []string) { - intent := portal.MainIntent() - var lastResp id.EventID - for _, msgID := range messages { - newLastResp := portal.redactAllParts(intent, msgID) - if newLastResp != "" { - lastResp = newLastResp - } - } - if lastResp != "" { - portal.sendDeliveryReceipt(lastResp) - } -} - -func (portal *Portal) redactAllParts(intent *appservice.IntentAPI, msgID string) (lastResp id.EventID) { - existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msgID) - for _, dbMsg := range existing { - resp, err := intent.RedactEvent(portal.MXID, dbMsg.MXID) - if err != nil { - portal.log.Err(err). - Str("message_id", msgID). - Str("event_id", dbMsg.MXID.String()). - Msg("Failed to redact Matrix message") - } else if resp != nil && resp.EventID != "" { - lastResp = resp.EventID - } - dbMsg.Delete() - } - return -} - -func (portal *Portal) handleDiscordTyping(evt *discordgo.TypingStart) { - puppet := portal.bridge.GetPuppetByID(evt.UserID) - if puppet.Name == "" { - // Puppet hasn't been synced yet - return - } - log := portal.log.With(). - Str("ghost_mxid", puppet.MXID.String()). - Str("action", "discord typing"). - Logger() - intent := puppet.IntentFor(portal) - err := intent.EnsureJoined(portal.MXID) - if err != nil { - log.Warn().Err(err).Msg("Failed to ensure ghost is joined for typing notification") - return - } - _, err = intent.UserTyping(portal.MXID, true, 12*time.Second) - if err != nil { - log.Warn().Err(err).Msg("Failed to send typing notification to Matrix") - } -} - -func (portal *Portal) syncParticipant(source *User, participant *discordgo.User, remove bool) { - puppet := portal.bridge.GetPuppetByID(participant.ID) - puppet.UpdateInfo(source, participant, nil) - log := portal.log.With(). - Str("participant_id", participant.ID). - Str("ghost_mxid", puppet.MXID.String()). - Logger() - - user := portal.bridge.GetUserByID(participant.ID) - if user != nil { - log.Debug().Msg("Ensuring Matrix user is invited or joined to room") - portal.ensureUserInvited(user, false) - } - - if remove { - _, err := puppet.DefaultIntent().LeaveRoom(portal.MXID) - if err != nil { - log.Warn().Err(err).Msg("Failed to make ghost leave room after member remove event") - } - } else if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { - if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil { - log.Warn().Err(err).Msg("Failed to add ghost to room") - } - } -} - -func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) { - for _, participant := range participants { - puppet := portal.bridge.GetPuppetByID(participant.ID) - puppet.UpdateInfo(source, participant, nil) - - var user *User - if participant.ID != portal.OtherUserID { - user = portal.bridge.GetUserByID(participant.ID) - if user != nil { - portal.ensureUserInvited(user, false) - } - } - - if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { - if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil { - portal.log.Warn().Err(err). - Str("participant_id", participant.ID). - Msg("Failed to add ghost to room") - } - } - } -} - -func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { - if !portal.Encrypted || portal.bridge.Crypto == nil { - return eventType, nil - } - intent.AddDoublePuppetValue(content) - // TODO maybe the locking should be inside mautrix-go? - portal.encryptLock.Lock() - err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) - portal.encryptLock.Unlock() - if err != nil { - return eventType, fmt.Errorf("failed to encrypt event: %w", err) - } - return event.EventEncrypted, nil -} - -func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := event.Content{Parsed: content, Raw: extraContent} - var err error - eventType, err = portal.encrypt(intent, &wrappedContent, eventType) - if err != nil { - return nil, err - } - - _, _ = intent.UserTyping(portal.MXID, false, 0) - if timestamp == 0 { - return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) - } else { - return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) - } -} - -func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { - portal.forwardBackfillLock.Lock() - defer portal.forwardBackfillLock.Unlock() - switch msg.evt.Type { - case event.EventMessage, event.EventSticker: - portal.handleMatrixMessage(msg.user, msg.evt) - case event.EventRedaction: - portal.handleMatrixRedaction(msg.user, msg.evt) - case event.EventReaction: - portal.handleMatrixReaction(msg.user, msg.evt) - default: - portal.log.Warn().Str("event_type", msg.evt.Type.Type).Msg("Unknown event type in handleMatrixMessages") - } -} - -const discordEpoch = 1420070400000 - -func generateNonce() string { - snowflake := (time.Now().UnixMilli() - discordEpoch) << 22 - // Nonce snowflakes don't have internal IDs or increments - return strconv.FormatInt(snowflake, 10) -} - -func (portal *Portal) getEvent(mxid id.EventID) (*event.Event, error) { - evt, err := portal.MainIntent().GetEvent(portal.MXID, mxid) - if err != nil { - return nil, err - } - _ = evt.Content.ParseRaw(evt.Type) - if evt.Type == event.EventEncrypted { - decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt) - if err != nil { - return nil, fmt.Errorf("failed to decrypt event: %w", err) - } else { - evt = decryptedEvt - } - } - return evt, nil -} - -func genThreadName(evt *event.Event) string { - body := evt.Content.AsMessage().Body - if len(body) == 0 { - return "thread" - } - fields := strings.Fields(body) - var title string - for _, field := range fields { - if len(title)+len(field) < 40 { - title += field - title += " " - continue - } - if len(title) == 0 { - title = field[:40] - } - break - } - return title -} - -func (portal *Portal) startThreadFromMatrix(sender *User, threadRoot id.EventID) (string, error) { - rootEvt, err := portal.getEvent(threadRoot) - if err != nil { - return "", fmt.Errorf("failed to get root event: %w", err) - } - threadName := genThreadName(rootEvt) - - existingMsg := portal.bridge.DB.Message.GetByMXID(portal.Key, threadRoot) - if existingMsg == nil { - return "", fmt.Errorf("unknown root event") - } else if existingMsg.ThreadID != "" { - return "", fmt.Errorf("root event is already in a thread") - } else { - var ch *discordgo.Channel - ch, err = sender.Session.MessageThreadStartComplex(portal.Key.ChannelID, existingMsg.DiscordID, &discordgo.ThreadStart{ - Name: threadName, - AutoArchiveDuration: 24 * 60, - Type: discordgo.ChannelTypeGuildPublicThread, - Location: "Message", - }) - if err != nil { - return "", fmt.Errorf("error starting thread: %v", err) - } - portal.log.Debug(). - Str("thread_root_mxid", threadRoot.String()). - Str("thread_id", ch.ID). - Msg("Created Discord thread") - portal.bridge.GetThreadByID(existingMsg.DiscordID, existingMsg) - return ch.ID, nil - } -} - -func (portal *Portal) sendErrorMessage(evt *event.Event, msgType, message string, confirmed bool) id.EventID { - if !portal.bridge.Config.Bridge.MessageErrorNotices { - return "" - } - certainty := "may not have been" - if confirmed { - certainty = "was not" - } - if portal.RelayWebhookSecret != "" { - message = strings.ReplaceAll(message, portal.RelayWebhookSecret, "") - } - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, message), - } - relatable, ok := evt.Content.Parsed.(event.Relatable) - if ok && relatable.OptionalGetRelatesTo().GetThreadParent() != "" { - content.GetRelatesTo().SetThread(relatable.OptionalGetRelatesTo().GetThreadParent(), evt.ID) - } - resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to send bridging error message") - return "" - } - return resp.EventID -} - -var ( - errUnknownMsgType = errors.New("unknown msgtype") - errUnexpectedParsedContentType = errors.New("unexpected parsed content type") - errUserNotReceiver = errors.New("user is not portal receiver") - errUserNotLoggedIn = errors.New("user is not logged in and portal doesn't have webhook") - errUnknownEditTarget = errors.New("unknown edit target") - errUnknownRelationType = errors.New("unknown relation type") - errTargetNotFound = errors.New("target event not found") - errUnknownEmoji = errors.New("unknown emoji") - errCantStartThread = errors.New("can't create thread without being logged into Discord") -) - -func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string, checkpointError error) { - var restErr *discordgo.RESTError - switch { - case errors.Is(err, errUnknownMsgType), - errors.Is(err, errUnknownRelationType), - errors.Is(err, errUnexpectedParsedContentType), - errors.Is(err, errUnknownEmoji), - errors.Is(err, id.InvalidContentURI), - errors.Is(err, attachment.UnsupportedVersion), - errors.Is(err, attachment.UnsupportedAlgorithm), - errors.Is(err, errCantStartThread): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "", nil - case errors.Is(err, attachment.HashMismatch), - errors.Is(err, attachment.InvalidKey), - errors.Is(err, attachment.InvalidInitVector): - return event.MessageStatusUndecryptable, event.MessageStatusFail, true, true, "", nil - case errors.Is(err, errUserNotReceiver), errors.Is(err, errUserNotLoggedIn): - return event.MessageStatusNoPermission, event.MessageStatusFail, true, false, "", nil - case errors.Is(err, errUnknownEditTarget): - return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil - case errors.Is(err, errTargetNotFound): - return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil - case errors.As(err, &restErr): - if restErr.Message != nil && (restErr.Message.Code != 0 || len(restErr.Message.Message) > 0) { - reason, humanMessage = restErrorToStatusReason(restErr.Message) - status = event.MessageStatusFail - isCertain = true - sendNotice = true - checkpointError = fmt.Errorf("HTTP %d: %d: %s", restErr.Response.StatusCode, restErr.Message.Code, restErr.Message.Message) - if len(restErr.Message.Errors) > 0 { - jsonExtraErrors, _ := json.Marshal(restErr.Message.Errors) - checkpointError = fmt.Errorf("%w (%s)", checkpointError, jsonExtraErrors) - } - return - } else if restErr.Response.StatusCode == http.StatusBadRequest && bytes.HasPrefix(restErr.ResponseBody, []byte(`{"captcha_key"`)) { - return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Captcha error", errors.New("captcha required") - } else if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusServiceUnavailable || restErr.Response.StatusCode == http.StatusBadGateway || restErr.Response.StatusCode == http.StatusGatewayTimeout) { - return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, fmt.Sprintf("HTTP %s", restErr.Response.Status), fmt.Errorf("HTTP %d", restErr.Response.StatusCode) - } - fallthrough - case errors.Is(err, context.DeadlineExceeded): - return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", context.DeadlineExceeded - case strings.HasSuffix(err.Error(), "(Client.Timeout exceeded while awaiting headers)"): - return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", errors.New("HTTP request timed out") - case errors.Is(err, syscall.ECONNRESET): - return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", errors.New("connection reset") - default: - return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", nil - } -} - -func restErrorToStatusReason(msg *discordgo.APIErrorMessage) (reason event.MessageStatusReason, humanMessage string) { - switch msg.Code { - case discordgo.ErrCodeRequestEntityTooLarge: - return event.MessageStatusUnsupported, "Attachment is too large" - case discordgo.ErrCodeUnknownEmoji: - return event.MessageStatusUnsupported, "Unsupported emoji" - case discordgo.ErrCodeMissingPermissions, discordgo.ErrCodeMissingAccess: - return event.MessageStatusUnsupported, "You don't have the permissions to do that" - case discordgo.ErrCodeCannotSendMessagesToThisUser: - return event.MessageStatusUnsupported, "You can't send messages to this user" - case discordgo.ErrCodeCannotSendMessagesInVoiceChannel: - return event.MessageStatusUnsupported, "You can't send messages in a non-text channel" - case discordgo.ErrCodeInvalidFormBody: - contentErrs := msg.Errors["content"].Errors - if len(contentErrs) == 1 && contentErrs[0].Code == "BASE_TYPE_MAX_LENGTH" { - return event.MessageStatusUnsupported, "Message is too long: " + contentErrs[0].Message - } - } - return event.MessageStatusGenericError, fmt.Sprintf("%d: %s", msg.Code, msg.Message) -} - -func (portal *Portal) sendStatusEvent(evtID id.EventID, err error) { - if !portal.bridge.Config.Bridge.MessageStatusEvents { - return - } - intent := portal.bridge.Bot - if !portal.Encrypted { - // Bridge bot isn't present in unencrypted DMs - intent = portal.MainIntent() - } - stateKey, _ := portal.getBridgeInfo() - content := event.BeeperMessageStatusEventContent{ - Network: stateKey, - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: evtID, - }, - Status: event.MessageStatusSuccess, - } - if err == nil { - content.Status = event.MessageStatusSuccess - } else { - var checkpointErr error - content.Reason, content.Status, _, _, content.Message, checkpointErr = errorToStatusReason(err) - if checkpointErr != nil { - content.Error = checkpointErr.Error() - } else { - content.Error = err.Error() - } - } - _, err = intent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content) - if err != nil { - portal.log.Err(err).Str("event_id", evtID.String()).Msg("Failed to send message status event") - } -} - -func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string) { - var msgType string - switch evt.Type { - case event.EventMessage, event.EventSticker: - msgType = "message" - case event.EventReaction: - msgType = "reaction" - case event.EventRedaction: - msgType = "redaction" - default: - msgType = "unknown event" - } - level := zerolog.DebugLevel - if err != nil && part != "Ignoring" { - level = zerolog.ErrorLevel - } - logEvt := portal.log.WithLevel(level). - Str("action", "send matrix message metrics"). - Str("event_type", evt.Type.Type). - Str("event_id", evt.ID.String()). - Str("sender", evt.Sender.String()) - if evt.Type == event.EventRedaction { - logEvt.Str("redacts", evt.Redacts.String()) - } - if err != nil { - logEvt.Err(err). - Str("result", fmt.Sprintf("%s event", part)). - Msg("Matrix event not handled") - reason, statusCode, isCertain, sendNotice, humanMessage, checkpointErr := errorToStatusReason(err) - if checkpointErr == nil { - checkpointErr = err - } - checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode) - portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, checkpointErr, checkpointStatus, 0) - if sendNotice { - if humanMessage == "" { - humanMessage = err.Error() - } - portal.sendErrorMessage(evt, msgType, humanMessage, isCertain) - } - portal.sendStatusEvent(evt.ID, err) - } else { - logEvt.Err(err).Msg("Matrix event handled successfully") - portal.sendDeliveryReceipt(evt.ID) - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - portal.sendStatusEvent(evt.ID, nil) - } -} - -func (br *DiscordBridge) serveMediaProxy(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - mxc := id.ContentURI{ - Homeserver: vars["server"], - FileID: vars["mediaID"], - } - checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"]) - if err != nil || len(checksum) != 32 { - w.WriteHeader(http.StatusNotFound) - return - } - _, expectedChecksum := br.hashMediaProxyURL(mxc) - if !hmac.Equal(checksum, expectedChecksum) { - w.WriteHeader(http.StatusNotFound) - return - } - reader, err := br.Bot.Download(mxc) - if err != nil { - br.ZLog.Warn().Err(err).Msg("Failed to download media to proxy") - w.WriteHeader(http.StatusInternalServerError) - return - } - buf := make([]byte, 32*1024) - n, err := io.ReadFull(reader, buf) - if err != nil && (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { - br.ZLog.Warn().Err(err).Msg("Failed to read first part of media to proxy") - w.WriteHeader(http.StatusBadGateway) - return - } - w.Header().Add("Content-Type", http.DetectContentType(buf[:n])) - if n < len(buf) { - w.Header().Add("Content-Length", strconv.Itoa(n)) - } - w.WriteHeader(http.StatusOK) - _, err = w.Write(buf[:n]) - if err != nil { - return - } - if n >= len(buf) { - _, _ = io.CopyBuffer(w, reader, buf) - } -} - -func (br *DiscordBridge) hashMediaProxyURL(mxc id.ContentURI) (string, []byte) { - path := fmt.Sprintf("/mautrix-discord/avatar/%s/%s/", mxc.Homeserver, mxc.FileID) - checksum := hmac.New(sha256.New, []byte(br.Config.Bridge.AvatarProxyKey)) - checksum.Write([]byte(path)) - return path, checksum.Sum(nil) -} - -func (br *DiscordBridge) makeMediaProxyURL(mxc id.ContentURI) string { - if br.Config.Bridge.PublicAddress == "" { - return "" - } - path, checksum := br.hashMediaProxyURL(mxc) - return br.Config.Bridge.PublicAddress + path + base64.RawURLEncoding.EncodeToString(checksum) -} - -func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) { - member := portal.bridge.StateStore.GetMember(portal.MXID, sender.MXID) - name = member.Displayname - if name == "" { - name = sender.MXID.String() - } - mxc := member.AvatarURL.ParseOrIgnore() - if !mxc.IsEmpty() && portal.bridge.Config.Bridge.PublicAddress != "" { - avatarURL = portal.bridge.makeMediaProxyURL(mxc) - } - return -} - -const replyEmbedMaxLines = 1 -const replyEmbedMaxChars = 72 - -func cutBody(body string) string { - lines := strings.Split(strings.TrimSpace(body), "\n") - var output string - for i, line := range lines { - if i >= replyEmbedMaxLines { - output += " […]" - break - } - if i > 0 { - output += "\n" - } - output += line - if len(output) > replyEmbedMaxChars { - output = output[:replyEmbedMaxChars] + "…" - break - } - } - return output -} - -func (portal *Portal) convertReplyMessageToEmbed(eventID id.EventID, url string) (*discordgo.MessageEmbed, error) { - evt, err := portal.getEvent(eventID) - if err != nil { - return nil, fmt.Errorf("failed to get reply target event: %w", err) - } - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - return nil, fmt.Errorf("unsupported event type %s / %T", evt.Type.String(), evt.Content.Parsed) - } - content.RemoveReplyFallback() - var targetUser string - - puppet := portal.bridge.GetPuppetByMXID(evt.Sender) - if puppet != nil { - targetUser = fmt.Sprintf("<@%s>", puppet.ID) - } else if user := portal.bridge.GetUserByMXID(evt.Sender); user != nil && user.DiscordID != "" { - targetUser = fmt.Sprintf("<@%s>", user.DiscordID) - } else if member := portal.bridge.StateStore.GetMember(portal.MXID, evt.Sender); member != nil && member.Displayname != "" { - targetUser = member.Displayname - } else { - targetUser = evt.Sender.String() - } - body := escapeDiscordMarkdown(cutBody(content.Body)) - body = fmt.Sprintf("**[Replying to](%s) %s**\n%s", url, targetUser, body) - embed := &discordgo.MessageEmbed{Description: body} - return embed, nil -} - -func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return - } - - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") - return - } - - channelID := portal.Key.ChannelID - sess := sender.Session - if sess == nil && portal.RelayWebhookID == "" { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") - return - } - isWebhookSend := sess == nil - var threadID string - - if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil { - edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID) - if edits != nil { - discordContent, allowedMentions := portal.parseMatrixHTML(content.NewContent) - var err error - var msg *discordgo.Message - if !isWebhookSend { - // TODO save edit in message table - msg, err = sess.ChannelMessageEdit(edits.DiscordProtoChannelID(), edits.DiscordID, discordContent) - } else { - msg, err = relayClient.WebhookMessageEdit(portal.RelayWebhookID, portal.RelayWebhookSecret, edits.DiscordID, &discordgo.WebhookEdit{ - Content: &discordContent, - AllowedMentions: allowedMentions, - }) - } - go portal.sendMessageMetrics(evt, err, "Failed to edit") - if msg.EditedTimestamp != nil { - edits.UpdateEditTimestamp(*msg.EditedTimestamp) - } - } else { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEditTarget, editMXID), "Ignoring") - } - return - } else if threadRoot := content.GetRelatesTo().GetThreadParent(); threadRoot != "" { - existingThread := portal.bridge.GetThreadByRootMXID(threadRoot) - if existingThread != nil { - threadID = existingThread.ID - existingThread.initialBackfillAttempted = true - } else { - if isWebhookSend { - // TODO start thread with bot? - go portal.sendMessageMetrics(evt, errCantStartThread, "Dropping") - return - } - var err error - threadID, err = portal.startThreadFromMatrix(sender, threadRoot) - if err != nil { - portal.log.Warn().Err(err). - Str("thread_root_mxid", threadRoot.String()). - Msg("Failed to start thread from Matrix") - } - } - } - if threadID != "" { - channelID = threadID - } - - var sendReq discordgo.MessageSend - - var description string - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - if mimeData := mimetype.Lookup(content.Info.MimeType); mimeData != nil { - description = content.Body - content.Body = "sticker" + mimeData.Extension() - } - } - - if replyToMXID := content.RelatesTo.GetNonFallbackReplyTo(); replyToMXID != "" { - replyTo := portal.bridge.DB.Message.GetByMXID(portal.Key, replyToMXID) - if replyTo != nil && replyTo.ThreadID == threadID { - if isWebhookSend { - messageURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", portal.GuildID, channelID, replyTo.DiscordID) - embed, err := portal.convertReplyMessageToEmbed(replyTo.MXID, messageURL) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to convert reply message to embed for webhook send") - } else if embed != nil { - sendReq.Embeds = []*discordgo.MessageEmbed{embed} - } - } else { - sendReq.Reference = &discordgo.MessageReference{ - ChannelID: channelID, - MessageID: replyTo.DiscordID, - } - } - } - } - switch content.MsgType { - case event.MsgText, event.MsgEmote, event.MsgNotice: - sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content) - if content.MsgType == event.MsgEmote { - sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content) - } - case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: - data, err := downloadMatrixAttachment(portal.MainIntent(), content) - if err != nil { - go portal.sendMessageMetrics(evt, err, "Error downloading media in") - return - } - filename := content.Body - if content.FileName != "" && content.FileName != content.Body { - filename = content.FileName - sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content) - } - - if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser { - att := &discordgo.MessageAttachment{ - ID: "0", - Filename: filename, - Description: description, - } - sendReq.Attachments = []*discordgo.MessageAttachment{att} - prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ - Files: []*discordgo.FilePrepare{{ - Size: len(data), - Name: att.Filename, - ID: sender.NextDiscordUploadID(), - }}, - }) - if err != nil { - go portal.sendMessageMetrics(evt, err, "Error preparing to reupload media in") - return - } - prepared := prep.Attachments[0] - att.UploadedFilename = prepared.UploadFilename - err = uploadDiscordAttachment(prepared.UploadURL, data) - if err != nil { - go portal.sendMessageMetrics(evt, err, "Error reuploading media in") - return - } - } else { - sendReq.Files = []*discordgo.File{{ - Name: filename, - ContentType: content.Info.MimeType, - Reader: bytes.NewReader(data), - }} - } - default: - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType), "Ignoring") - return - } - if !isWebhookSend { - // AllowedMentions must not be set for real users, and it's also not that useful for personal bots. - // It's only important for relaying, where the webhook may have higher permissions than the user on Matrix. - sendReq.AllowedMentions = nil - } else if strings.Contains(sendReq.Content, "@everyone") || strings.Contains(sendReq.Content, "@here") { - powerLevels, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - portal.log.Warn().Err(err). - Str("user_id", sender.MXID.String()). - Msg("Failed to get power levels to check if user can use @everyone") - } else if powerLevels.GetUserLevel(sender.MXID) >= powerLevels.Notifications.Room() { - sendReq.AllowedMentions.Parse = append(sendReq.AllowedMentions.Parse, discordgo.AllowedMentionTypeEveryone) - } - } - sendReq.Nonce = generateNonce() - var msg *discordgo.Message - var err error - if !isWebhookSend { - msg, err = sess.ChannelMessageSendComplex(channelID, &sendReq) - } else { - username, avatarURL := portal.getRelayUserMeta(sender) - msg, err = relayClient.WebhookThreadExecute(portal.RelayWebhookID, portal.RelayWebhookSecret, true, threadID, &discordgo.WebhookParams{ - Content: sendReq.Content, - Username: username, - AvatarURL: avatarURL, - TTS: sendReq.TTS, - Files: sendReq.Files, - Components: sendReq.Components, - Embeds: sendReq.Embeds, - AllowedMentions: sendReq.AllowedMentions, - }) - } - go portal.sendMessageMetrics(evt, err, "Error sending") - if msg != nil { - dbMsg := portal.bridge.DB.Message.New() - dbMsg.Channel = portal.Key - dbMsg.DiscordID = msg.ID - if len(msg.Attachments) > 0 { - dbMsg.AttachmentID = msg.Attachments[0].ID - } - dbMsg.MXID = evt.ID - if sess != nil { - dbMsg.SenderID = sender.DiscordID - } else { - dbMsg.SenderID = portal.RelayWebhookID - } - dbMsg.SenderMXID = sender.MXID - dbMsg.Timestamp, _ = discordgo.SnowflakeTimestamp(msg.ID) - dbMsg.ThreadID = threadID - dbMsg.Insert() - } -} - -func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) { - if portal.bridge.Config.Bridge.DeliveryReceipts { - err := portal.bridge.Bot.MarkRead(portal.MXID, eventID) - if err != nil { - portal.log.Warn().Err(err). - Str("event_id", eventID.String()). - Msg("Failed to send delivery receipt") - } - } -} - -func (portal *Portal) HandleMatrixLeave(brSender bridge.User) { - sender := brSender.(*User) - if portal.IsPrivateChat() && sender.DiscordID == portal.Key.Receiver { - portal.log.Debug().Msg("User left private chat portal, cleaning up and deleting...") - portal.cleanup(false) - portal.RemoveMXID() - } else { - portal.cleanupIfEmpty() - } -} - -func (portal *Portal) HandleMatrixKick(brSender bridge.User, brTarget bridge.Ghost) {} -func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brTarget bridge.Ghost) {} - -func (portal *Portal) Delete() { - portal.Portal.Delete() - portal.bridge.portalsLock.Lock() - delete(portal.bridge.portalsByID, portal.Key) - if portal.MXID != "" { - delete(portal.bridge.portalsByMXID, portal.MXID) - } - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) cleanupIfEmpty() { - if portal.MXID == "" { - return - } - - users, err := portal.getMatrixUsers() - if err != nil { - portal.log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up") - return - } - - if len(users) == 0 { - portal.log.Info().Msg("Room seems to be empty, cleaning up...") - portal.cleanup(false) - portal.RemoveMXID() - } -} - -func (portal *Portal) RemoveMXID() { - portal.bridge.portalsLock.Lock() - defer portal.bridge.portalsLock.Unlock() - if portal.MXID == "" { - return - } - delete(portal.bridge.portalsByMXID, portal.MXID) - portal.MXID = "" - portal.log = portal.bridge.ZLog.With(). - Str("channel_id", portal.Key.ChannelID). - Str("channel_receiver", portal.Key.Receiver). - Str("room_id", portal.MXID.String()). - Logger() - portal.AvatarSet = false - portal.NameSet = false - portal.TopicSet = false - portal.Encrypted = false - portal.InSpace = "" - portal.FirstEventID = "" - portal.Update() - portal.bridge.DB.Message.DeleteAll(portal.Key) -} - -func (portal *Portal) cleanup(puppetsOnly bool) { - if portal.MXID == "" { - return - } - intent := portal.MainIntent() - if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := intent.BeeperDeleteRoom(portal.MXID) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - portal.log.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint") - } - return - } - - if portal.IsPrivateChat() { - _, err := portal.MainIntent().LeaveRoom(portal.MXID) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to leave private chat portal with main intent") - } - return - } - - portal.bridge.cleanupRoom(intent, portal.MXID, puppetsOnly, portal.log) -} - -func (br *DiscordBridge) cleanupRoom(intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool, log zerolog.Logger) { - members, err := intent.JoinedMembers(mxid) - if err != nil { - log.Err(err).Msg("Failed to get portal members for cleanup") - return - } - - for member := range members.Joined { - if member == intent.UserID { - continue - } - - puppet := br.GetPuppetByMXID(member) - if puppet != nil { - _, err = puppet.DefaultIntent().LeaveRoom(mxid) - if err != nil { - log.Err(err).Msg("Error leaving as puppet while cleaning up portal") - } - } else if !puppetsOnly { - _, err = intent.KickUser(mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - log.Err(err).Msg("Error kicking user while cleaning up portal") - } - } - } - - _, err = intent.LeaveRoom(mxid) - if err != nil { - log.Err(err).Msg("Error leaving with main intent while cleaning up portal") - } -} - -func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { - members, err := portal.MainIntent().JoinedMembers(portal.MXID) - if err != nil { - return nil, fmt.Errorf("failed to get member list: %w", err) - } - - var users []id.UserID - for userID := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(userID) - if !isPuppet && userID != portal.bridge.Bot.UserID { - users = append(users, userID) - } - } - - return users, nil -} - -func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return - } else if !sender.IsLoggedIn() { - //go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring") - return - } - - reaction := evt.Content.AsReaction() - if reaction.RelatesTo.Type != event.RelAnnotation { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownRelationType, reaction.RelatesTo.Type), "Ignoring") - return - } - - if reaction.RelatesTo.Key == JoinThreadReaction { - thread := portal.bridge.GetThreadByRootOrCreationNoticeMXID(reaction.RelatesTo.EventID) - if thread == nil { - go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring thread join") - return - } - thread.Join(sender) - return - } - - msg := portal.bridge.DB.Message.GetByMXID(portal.Key, reaction.RelatesTo.EventID) - if msg == nil { - go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring") - return - } - - firstMsg := msg - if msg.AttachmentID != "" { - firstMsg = portal.bridge.DB.Message.GetFirstByDiscordID(portal.Key, msg.DiscordID) - // TODO should the emoji be rerouted to the first message if it's different? - } - - // Figure out if this is a custom emoji or not. - emojiID := reaction.RelatesTo.Key - if strings.HasPrefix(emojiID, "mxc://") { - uri, _ := id.ParseContentURI(emojiID) - emojiInfo := portal.bridge.DMA.GetEmojiInfo(uri) - if emojiInfo != nil { - emojiID = fmt.Sprintf("%s:%d", emojiInfo.Name, emojiInfo.EmojiID) - } else if emojiFile := portal.bridge.DB.File.GetEmojiByMXC(uri); emojiFile != nil && emojiFile.ID != "" && emojiFile.EmojiName != "" { - emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID) - } else { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring") - return - } - } else { - emojiID = variationselector.FullyQualify(emojiID) - } - - existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, msg.DiscordID, sender.DiscordID, emojiID) - if existing != nil { - portal.log.Debug(). - Str("event_id", evt.ID.String()). - Str("existing_reaction_mxid", existing.MXID.String()). - Msg("Dropping duplicate Matrix reaction") - go portal.sendMessageMetrics(evt, nil, "") - return - } - - err := sender.Session.MessageReactionAdd(msg.DiscordProtoChannelID(), msg.DiscordID, emojiID) - go portal.sendMessageMetrics(evt, err, "Error sending") - if err == nil { - dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.Channel = portal.Key - dbReaction.MessageID = msg.DiscordID - dbReaction.FirstAttachmentID = firstMsg.AttachmentID - dbReaction.Sender = sender.DiscordID - dbReaction.EmojiName = emojiID - dbReaction.ThreadID = msg.ThreadID - dbReaction.MXID = evt.ID - dbReaction.Insert() - } -} - -func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool, thread *Thread, member *discordgo.Member) { - puppet := portal.bridge.GetPuppetByID(reaction.UserID) - if member != nil { - puppet.UpdateInfo(user, member.User, nil) - } - intent := puppet.IntentFor(portal) - - log := portal.log.With(). - Str("message_id", reaction.MessageID). - Str("author_id", reaction.UserID). - Bool("add", add). - Str("action", "discord reaction"). - Logger() - - var discordID string - var matrixReaction string - - if reaction.Emoji.ID != "" { - reactionMXC := portal.getEmojiMXCByDiscordID(reaction.Emoji.ID, reaction.Emoji.Name, reaction.Emoji.Animated) - if reactionMXC.IsEmpty() { - return - } - matrixReaction = reactionMXC.String() - discordID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID) - } else { - discordID = reaction.Emoji.Name - matrixReaction = variationselector.Add(reaction.Emoji.Name) - } - - // Find the message that we're working with. - message := portal.bridge.DB.Message.GetByDiscordID(portal.Key, reaction.MessageID) - if message == nil { - log.Debug().Msg("Failed to add reaction to message: message not found") - return - } - - // Lookup an existing reaction - existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, message[0].DiscordID, reaction.UserID, discordID) - if !add { - if existing == nil { - log.Debug().Msg("Failed to remove reaction: reaction not found") - return - } - - resp, err := intent.RedactEvent(portal.MXID, existing.MXID) - if err != nil { - log.Err(err).Msg("Failed to remove reaction") - } else { - go portal.sendDeliveryReceipt(resp.EventID) - } - - existing.Delete() - return - } else if existing != nil { - log.Debug().Msg("Ignoring duplicate reaction") - return - } - - content := event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - EventID: message[0].MXID, - Type: event.RelAnnotation, - Key: matrixReaction, - }, - } - extraContent := map[string]any{} - if reaction.Emoji.ID != "" { - extraContent["fi.mau.discord.reaction"] = map[string]any{ - "id": reaction.Emoji.ID, - "name": reaction.Emoji.Name, - "mxc": matrixReaction, - } - wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name) - extraContent["com.beeper.reaction.shortcode"] = wrappedShortcode - if !portal.bridge.Config.Bridge.CustomEmojiReactions { - content.RelatesTo.Key = wrappedShortcode - } - } - - resp, err := intent.SendMessageEvent(portal.MXID, event.EventReaction, &event.Content{ - Parsed: &content, - Raw: extraContent, - }) - if err != nil { - log.Err(err).Msg("Failed to send reaction") - return - } - - if existing == nil { - dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.Channel = portal.Key - dbReaction.MessageID = message[0].DiscordID - dbReaction.FirstAttachmentID = message[0].AttachmentID - dbReaction.Sender = reaction.UserID - dbReaction.EmojiName = discordID - dbReaction.MXID = resp.EventID - if thread != nil { - dbReaction.ThreadID = thread.ID - } - dbReaction.Insert() - portal.sendDeliveryReceipt(dbReaction.MXID) - } -} - -func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return - } - - sess := sender.Session - if sess == nil && portal.RelayWebhookID == "" { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") - return - } - - message := portal.bridge.DB.Message.GetByMXID(portal.Key, evt.Redacts) - if message != nil { - var err error - // TODO add support for deleting individual attachments from messages - if sess != nil { - err = sess.ChannelMessageDelete(message.DiscordProtoChannelID(), message.DiscordID) - } else { - // TODO pre-validate that the message was sent by the webhook? - err = relayClient.WebhookMessageDelete(portal.RelayWebhookID, portal.RelayWebhookSecret, message.DiscordID) - } - go portal.sendMessageMetrics(evt, err, "Error sending") - if err == nil { - message.Delete() - } - return - } - - if sess != nil { - reaction := portal.bridge.DB.Reaction.GetByMXID(evt.Redacts) - if reaction != nil && reaction.Channel == portal.Key { - err := sess.MessageReactionRemove(reaction.DiscordProtoChannelID(), reaction.MessageID, reaction.EmojiName, reaction.Sender) - go portal.sendMessageMetrics(evt, err, "Error sending") - if err == nil { - reaction.Delete() - } - return - } - } - - go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring") -} - -func (portal *Portal) HandleMatrixReadReceipt(brUser bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - sender := brUser.(*User) - if sender.Session == nil { - return - } - var thread *Thread - discordThreadID := "" - if receipt.ThreadID != "" && receipt.ThreadID != event.ReadReceiptThreadMain { - thread = portal.bridge.GetThreadByRootMXID(receipt.ThreadID) - if thread != nil { - discordThreadID = thread.ID - } - } - log := portal.log.With(). - Str("sender", brUser.GetMXID().String()). - Str("event_id", eventID.String()). - Str("action", "matrix read receipt"). - Str("discord_thread_id", discordThreadID). - Logger() - if thread != nil { - if portal.bridge.Config.Bridge.AutojoinThreadOnOpen { - thread.Join(sender) - } - if eventID == thread.CreationNoticeMXID { - log.Debug().Msg("Dropping read receipt for thread creation notice") - return - } - } - if !sender.Session.IsUser { - // Drop read receipts from bot users (after checking for the thread auto-join stuff) - return - } - msg := portal.bridge.DB.Message.GetByMXID(portal.Key, eventID) - if msg == nil { - msg = portal.bridge.DB.Message.GetClosestBefore(portal.Key, discordThreadID, receipt.Timestamp) - if msg == nil { - log.Debug().Msg("Dropping read receipt: no messages found") - return - } else { - log = log.With(). - Str("closest_event_id", msg.MXID.String()). - Str("closest_message_id", msg.DiscordID). - Logger() - log.Debug().Msg("Read receipt target event not found, using closest message") - } - } else { - log = log.With(). - Str("message_id", msg.DiscordID). - Logger() - } - if receipt.ThreadID != "" && msg.ThreadID != discordThreadID { - log.Debug(). - Str("receipt_thread_event_id", receipt.ThreadID.String()). - Str("message_discord_thread_id", msg.ThreadID). - Msg("Dropping read receipt: thread ID mismatch") - return - } - resp, err := sender.Session.ChannelMessageAckNoToken(msg.DiscordProtoChannelID(), msg.DiscordID) - if err != nil { - log.Err(err).Msg("Failed to send read receipt to Discord") - } else if resp.Token != nil { - log.Debug(). - Str("unexpected_resp_token", *resp.Token). - Msg("Marked message as read on Discord (and got unexpected non-nil token)") - } else { - log.Debug().Msg("Marked message as read on Discord") - } -} - -func typingDiff(prev, new []id.UserID) (started []id.UserID) { -OuterNew: - for _, userID := range new { - for _, previousUserID := range prev { - if userID == previousUserID { - continue OuterNew - } - } - started = append(started, userID) - } - return -} - -func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) { - portal.currentlyTypingLock.Lock() - defer portal.currentlyTypingLock.Unlock() - startedTyping := typingDiff(portal.currentlyTyping, newTyping) - portal.currentlyTyping = newTyping - for _, userID := range startedTyping { - user := portal.bridge.GetUserByMXID(userID) - if user != nil && user.Session != nil { - user.ViewingChannel(portal) - err := user.Session.ChannelTyping(portal.Key.ChannelID) - if err != nil { - portal.log.Warn().Err(err). - Str("user_id", user.MXID.String()). - Msg("Failed to mark user as typing") - } else { - portal.log.Debug(). - Str("user_id", user.MXID.String()). - Msg("Marked user as typing") - } - } - } -} - -func (portal *Portal) UpdateName(meta *discordgo.Channel) bool { - var parentName, guildName string - if portal.Parent != nil { - parentName = portal.Parent.PlainName - } - if portal.Guild != nil { - guildName = portal.Guild.PlainName - } - plainNameChanged := portal.PlainName != meta.Name - portal.PlainName = meta.Name - return portal.UpdateNameDirect(portal.bridge.Config.Bridge.FormatChannelName(config.ChannelNameParams{ - Name: meta.Name, - ParentName: parentName, - GuildName: guildName, - NSFW: meta.NSFW, - Type: meta.Type, - }), false) || plainNameChanged -} - -func (portal *Portal) UpdateNameDirect(name string, isFriendNick bool) bool { - if portal.FriendNick && !isFriendNick { - return false - } else if portal.Name == name && (portal.NameSet || portal.MXID == "" || (!portal.shouldSetDMRoomMetadata() && !isFriendNick)) { - return false - } - portal.log.Debug(). - Str("old_name", portal.Name). - Str("new_name", name). - Msg("Updating portal name") - portal.Name = name - portal.NameSet = false - portal.updateRoomName() - return true -} - -func (portal *Portal) updateRoomName() { - if portal.MXID != "" && (portal.shouldSetDMRoomMetadata() || portal.FriendNick) { - _, err := portal.MainIntent().SetRoomName(portal.MXID, portal.Name) - if err != nil { - portal.log.Err(err).Msg("Failed to update room name") - } else { - portal.NameSet = true - } - } -} - -func (portal *Portal) UpdateAvatarFromPuppet(puppet *Puppet) bool { - if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (puppet.Avatar == "" || portal.AvatarSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { - return false - } - portal.log.Debug(). - Str("old_avatar_id", portal.Avatar). - Str("new_avatar_id", puppet.Avatar). - Msg("Updating avatar from puppet") - portal.Avatar = puppet.Avatar - portal.AvatarURL = puppet.AvatarURL - portal.AvatarSet = false - portal.updateRoomAvatar() - return true -} - -func (portal *Portal) UpdateGroupDMAvatar(iconID string) bool { - if portal.Avatar == iconID && (iconID == "") == portal.AvatarURL.IsEmpty() && (iconID == "" || portal.AvatarSet || portal.MXID == "") { - return false - } - portal.log.Debug(). - Str("old_avatar_id", portal.Avatar). - Str("new_avatar_id", portal.Avatar). - Msg("Updating group DM avatar") - portal.Avatar = iconID - portal.AvatarSet = false - portal.AvatarURL = id.ContentURI{} - if portal.Avatar != "" { - // TODO direct media support - copied, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), discordgo.EndpointGroupIcon(portal.Key.ChannelID, portal.Avatar), false, AttachmentMeta{ - AttachmentID: fmt.Sprintf("private_channel_avatar/%s/%s", portal.Key.ChannelID, iconID), - }) - if err != nil { - portal.log.Err(err).Str("avatar_id", iconID).Msg("Failed to reupload channel avatar") - return true - } - portal.AvatarURL = copied.MXC - } - portal.updateRoomAvatar() - return true -} - -func (portal *Portal) updateRoomAvatar() { - if portal.MXID == "" || portal.AvatarURL.IsEmpty() || !portal.shouldSetDMRoomMetadata() { - return - } - _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) - if err != nil { - portal.log.Err(err).Msg("Failed to update room avatar") - } else { - portal.AvatarSet = true - } -} - -func (portal *Portal) UpdateTopic(topic string) bool { - if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { - return false - } - portal.log.Debug(). - Str("old_topic", portal.Topic). - Str("new_topic", topic). - Msg("Updating portal topic") - portal.Topic = topic - portal.TopicSet = false - portal.updateRoomTopic() - return true -} - -func (portal *Portal) updateRoomTopic() { - if portal.MXID != "" { - _, err := portal.MainIntent().SetRoomTopic(portal.MXID, portal.Topic) - if err != nil { - portal.log.Err(err).Msg("Failed to update room topic") - } else { - portal.TopicSet = true - } - } -} - -func (portal *Portal) removeFromSpace() { - if portal.InSpace == "" { - return - } - - log := portal.log.With().Str("space_mxid", portal.InSpace.String()).Logger() - log.Debug().Msg("Removing room from space") - _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, portal.InSpace.String(), struct{}{}) - if err != nil { - log.Warn().Err(err).Msg("Failed to clear m.space.parent event in room") - } - _, err = portal.bridge.Bot.SendStateEvent(portal.InSpace, event.StateSpaceChild, portal.MXID.String(), struct{}{}) - if err != nil { - log.Warn().Err(err).Msg("Failed to clear m.space.child event in space") - } - portal.InSpace = "" -} - -func (portal *Portal) addToSpace(mxid id.RoomID) bool { - if portal.InSpace == mxid { - return false - } - portal.removeFromSpace() - if mxid == "" { - return true - } - - log := portal.log.With().Str("space_mxid", mxid.String()).Logger() - _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, mxid.String(), &event.SpaceParentEventContent{ - Via: []string{portal.bridge.AS.HomeserverDomain}, - Canonical: true, - }) - if err != nil { - log.Warn().Err(err).Msg("Failed to set m.space.parent event in room") - } - - _, err = portal.bridge.Bot.SendStateEvent(mxid, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{portal.bridge.AS.HomeserverDomain}, - // TODO order - }) - if err != nil { - log.Warn().Err(err).Msg("Failed to set m.space.child event in space") - } else { - portal.InSpace = mxid - } - return true -} - -func (portal *Portal) UpdateParent(parentID string) bool { - if portal.ParentID == parentID { - return false - } - portal.log.Debug(). - Str("old_parent_id", portal.ParentID). - Str("new_parent_id", parentID). - Msg("Updating parent ID") - portal.ParentID = parentID - if portal.ParentID != "" { - portal.Parent = portal.bridge.GetPortalByID(database.NewPortalKey(parentID, ""), discordgo.ChannelTypeGuildCategory) - } else { - portal.Parent = nil - } - return true -} - -func (portal *Portal) ExpectedSpaceID() id.RoomID { - if portal.Parent != nil { - return portal.Parent.MXID - } else if portal.Guild != nil { - return portal.Guild.MXID - } - return "" -} - -func (portal *Portal) updateSpace(source *User) bool { - if portal.MXID == "" { - return false - } - if portal.Parent != nil { - if portal.Parent.MXID != "" { - portal.log.Warn().Str("parent_id", portal.ParentID).Msg("Parent portal has no Matrix room, creating...") - err := portal.Parent.CreateMatrixRoom(source, nil) - if err != nil { - portal.log.Err(err).Str("parent_id", portal.ParentID).Msg("Failed to create Matrix room for parent") - return false - } - } - return portal.addToSpace(portal.Parent.MXID) - } else if portal.Guild != nil { - return portal.addToSpace(portal.Guild.MXID) - } - return false -} - -func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discordgo.Channel { - changed := false - - log := portal.log.With(). - Str("action", "update info"). - Str("through_user_mxid", source.MXID.String()). - Str("through_user_dcid", source.DiscordID). - Logger() - - if meta == nil { - log.Debug().Msg("UpdateInfo called without metadata, fetching from user's state cache") - meta, _ = source.Session.State.Channel(portal.Key.ChannelID) - if meta == nil { - log.Warn().Msg("No metadata found in state cache, fetching from server via user") - var err error - meta, err = source.Session.Channel(portal.Key.ChannelID) - if err != nil { - log.Err(err).Msg("Failed to fetch meta via user") - return nil - } - } - } - - if portal.Type != meta.Type { - log.Warn(). - Int("old_type", int(portal.Type)). - Int("new_type", int(meta.Type)). - Msg("Portal type changed") - portal.Type = meta.Type - changed = true - } - if portal.OtherUserID == "" && portal.IsPrivateChat() { - if len(meta.Recipients) == 0 { - var err error - meta, err = source.Session.Channel(meta.ID) - if err != nil { - log.Err(err).Msg("Failed to fetch DM channel info to find other user ID") - } - } - if len(meta.Recipients) > 0 { - portal.OtherUserID = meta.Recipients[0].ID - log.Info().Str("other_user_id", portal.OtherUserID).Msg("Found other user ID") - changed = true - } - } - if meta.GuildID != "" && portal.GuildID == "" { - portal.GuildID = meta.GuildID - portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true) - changed = true - } - - switch portal.Type { - case discordgo.ChannelTypeDM: - if portal.OtherUserID != "" { - puppet := portal.bridge.GetPuppetByID(portal.OtherUserID) - changed = portal.UpdateAvatarFromPuppet(puppet) || changed - if rel, ok := source.relationships[portal.OtherUserID]; ok && rel.Nickname != "" { - portal.FriendNick = true - changed = portal.UpdateNameDirect(rel.Nickname, true) || changed - } else { - portal.FriendNick = false - changed = portal.UpdateNameDirect(puppet.Name, false) || changed - } - } - if portal.MXID != "" { - portal.syncParticipants(source, meta.Recipients) - } - case discordgo.ChannelTypeGroupDM: - changed = portal.UpdateGroupDMAvatar(meta.Icon) || changed - if portal.MXID != "" { - portal.syncParticipants(source, meta.Recipients) - } - fallthrough - default: - changed = portal.UpdateName(meta) || changed - if portal.MXID != "" { - portal.ensureUserInvited(source, false) - } - } - changed = portal.UpdateTopic(meta.Topic) || changed - changed = portal.UpdateParent(meta.ParentID) || changed - // Private channels are added to the space in User.handlePrivateChannel - if portal.GuildID != "" && portal.MXID != "" && portal.ExpectedSpaceID() != portal.InSpace { - changed = portal.updateSpace(source) || changed - } - if changed { - portal.UpdateBridgeInfo() - portal.Update() - } - return meta -} diff --git a/portal_convert.go b/portal_convert.go deleted file mode 100644 index 90f0100..0000000 --- a/portal_convert.go +++ /dev/null @@ -1,732 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "fmt" - "html" - "strconv" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" - "golang.org/x/exp/slices" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" -) - -type ConvertedMessage struct { - AttachmentID string - - Type event.Type - Content *event.MessageEventContent - Extra map[string]any -} - -func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent { - return &event.MessageEventContent{ - Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr), - MsgType: event.MsgNotice, - } -} - -const DiscordStickerSize = 160 - -func (portal *Portal) convertDiscordFile(ctx context.Context, typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent { - meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType} - if typeName == "sticker" && content.Info.MimeType == "application/json" { - meta.Converter = portal.bridge.convertLottie - } - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") - return portal.createMediaFailedMessage(err) - } - if typeName == "sticker" && content.Info.MimeType == "application/json" { - content.Info.MimeType = dbFile.MimeType - } - content.Info.Size = dbFile.Size - if content.Info.Width == 0 && content.Info.Height == 0 { - content.Info.Width = dbFile.Width - content.Info.Height = dbFile.Height - } - if dbFile.DecryptionInfo != nil { - content.File = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - content.URL = dbFile.MXC.CUString() - } - return content -} - -func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) { - if content.Info == nil { - return - } - if content.Info.Width == 0 && content.Info.Height == 0 { - content.Info.Width = DiscordStickerSize - content.Info.Height = DiscordStickerSize - } else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize { - if content.Info.Width > content.Info.Height { - content.Info.Height /= content.Info.Width / DiscordStickerSize - content.Info.Width = DiscordStickerSize - } else if content.Info.Width < content.Info.Height { - content.Info.Width /= content.Info.Height / DiscordStickerSize - content.Info.Height = DiscordStickerSize - } else { - content.Info.Width = DiscordStickerSize - content.Info.Height = DiscordStickerSize - } - } -} - -func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage { - var mime string - switch sticker.FormatType { - case discordgo.StickerFormatTypePNG: - mime = "image/png" - case discordgo.StickerFormatTypeAPNG: - mime = "image/apng" - case discordgo.StickerFormatTypeLottie: - mime = "application/json" - case discordgo.StickerFormatTypeGIF: - mime = "image/gif" - default: - zerolog.Ctx(ctx).Warn(). - Int("sticker_format", int(sticker.FormatType)). - Str("sticker_id", sticker.ID). - Msg("Unknown sticker format") - } - content := &event.MessageEventContent{ - Body: sticker.Name, // TODO find description from somewhere? - Info: &event.FileInfo{ - MimeType: mime, - }, - } - - mxc := portal.bridge.DMA.StickerMXC(sticker.ID, sticker.FormatType) - // TODO add config option to use direct media even for lottie stickers - if mxc.IsEmpty() && mime != "application/json" { - content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content) - } else { - content.URL = mxc.CUString() - } - portal.cleanupConvertedStickerInfo(content) - return &ConvertedMessage{ - AttachmentID: sticker.ID, - Type: event.EventSticker, - Content: content, - } -} - -func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, messageID string, att *discordgo.MessageAttachment) *ConvertedMessage { - content := &event.MessageEventContent{ - Body: att.Filename, - Info: &event.FileInfo{ - Height: att.Height, - MimeType: att.ContentType, - Width: att.Width, - - // This gets overwritten later after the file is uploaded to the homeserver - Size: att.Size, - }, - } - if att.Description != "" { - content.Body = att.Description - content.FileName = att.Filename - } - - var extra map[string]any - - switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { - case "audio": - content.MsgType = event.MsgAudio - if att.Waveform != nil { - // TODO convert waveform - extra = map[string]any{ - "org.matrix.1767.audio": map[string]any{ - "duration": int(att.DurationSeconds * 1000), - }, - "org.matrix.msc3245.voice": map[string]any{}, - } - } - case "image": - content.MsgType = event.MsgImage - case "video": - content.MsgType = event.MsgVideo - default: - content.MsgType = event.MsgFile - } - mxc := portal.bridge.DMA.AttachmentMXC(portal.Key.ChannelID, messageID, att) - if mxc.IsEmpty() { - content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content) - } else { - content.URL = mxc.CUString() - } - return &ConvertedMessage{ - AttachmentID: att.ID, - Type: event.EventMessage, - Content: content, - Extra: extra, - } -} - -func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage { - attachmentID := fmt.Sprintf("video_%s", embed.URL) - var proxyURL string - if embed.Video != nil { - proxyURL = embed.Video.ProxyURL - } else if embed.Thumbnail != nil { - proxyURL = embed.Thumbnail.ProxyURL - } else { - zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed") - return &ConvertedMessage{ - AttachmentID: attachmentID, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed", - MsgType: event.MsgNotice, - }, - } - } - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") - return &ConvertedMessage{ - AttachmentID: attachmentID, - Type: event.EventMessage, - Content: portal.createMediaFailedMessage(err), - } - } - - content := &event.MessageEventContent{ - Body: embed.URL, - Info: &event.FileInfo{ - MimeType: dbFile.MimeType, - Size: dbFile.Size, - }, - } - if embed.Video != nil { - content.MsgType = event.MsgVideo - content.Info.Width = embed.Video.Width - content.Info.Height = embed.Video.Height - } else { - content.MsgType = event.MsgImage - content.Info.Width = embed.Thumbnail.Width - content.Info.Height = embed.Thumbnail.Height - } - if content.Info.Width == 0 && content.Info.Height == 0 { - content.Info.Width = dbFile.Width - content.Info.Height = dbFile.Height - } - if dbFile.DecryptionInfo != nil { - content.File = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - content.URL = dbFile.MXC.CUString() - } - extra := map[string]any{} - if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv { - extra["info"] = map[string]any{ - "fi.mau.discord.gifv": true, - "fi.mau.loop": true, - "fi.mau.autoplay": true, - "fi.mau.hide_controls": true, - "fi.mau.no_audio": true, - } - } - return &ConvertedMessage{ - AttachmentID: attachmentID, - Type: event.EventMessage, - Content: content, - Extra: extra, - } -} - -func (portal *Portal) convertDiscordMessage(ctx context.Context, puppet *Puppet, intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage { - predictedLength := len(msg.Attachments) + len(msg.StickerItems) - if msg.Content != "" { - predictedLength++ - } - parts := make([]*ConvertedMessage, 0, predictedLength) - if textPart := portal.convertDiscordTextMessage(ctx, intent, msg); textPart != nil { - parts = append(parts, textPart) - } - log := zerolog.Ctx(ctx) - handledIDs := make(map[string]struct{}) - for _, att := range msg.Attachments { - if _, handled := handledIDs[att.ID]; handled { - continue - } - handledIDs[att.ID] = struct{}{} - log := log.With().Str("attachment_id", att.ID).Logger() - if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil { - parts = append(parts, part) - } - } - for _, sticker := range msg.StickerItems { - if _, handled := handledIDs[sticker.ID]; handled { - continue - } - handledIDs[sticker.ID] = struct{}{} - log := log.With().Str("sticker_id", sticker.ID).Logger() - if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil { - parts = append(parts, part) - } - } - for i, embed := range msg.Embeds { - // Ignore non-video embeds, they're handled in convertDiscordTextMessage - if getEmbedType(msg, embed) != EmbedVideo { - continue - } - // Discord deduplicates embeds by URL. It makes things easier for us too. - if _, handled := handledIDs[embed.URL]; handled { - continue - } - handledIDs[embed.URL] = struct{}{} - log := log.With(). - Str("computed_embed_type", "video"). - Str("embed_type", string(embed.Type)). - Int("embed_index", i). - Logger() - part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed) - if part != nil { - parts = append(parts, part) - } - } - if len(parts) == 0 && msg.Thread != nil { - parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name), - }}) - } - for _, part := range parts { - puppet.addWebhookMeta(part, msg) - puppet.addMemberMeta(part, msg) - } - return parts -} - -func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) { - if msg.Member == nil { - return - } - if part.Extra == nil { - part.Extra = make(map[string]any) - } - var avatarURL id.ContentURI - var discordAvatarURL string - if msg.Member.Avatar != "" { - var err error - avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar) - if err != nil { - puppet.log.Warn().Err(err). - Str("avatar_id", msg.Author.Avatar). - Msg("Failed to reupload guild user avatar") - } - } - part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{ - "nick": msg.Member.Nick, - "avatar_id": msg.Member.Avatar, - "avatar_url": discordAvatarURL, - "avatar_mxc": avatarURL.String(), - } - if msg.Member.Nick != "" || !avatarURL.IsEmpty() { - perMessageProfile := map[string]any{ - "is_multiple_users": false, - - "displayname": msg.Member.Nick, - "avatar_url": avatarURL.String(), - } - if msg.Member.Nick == "" { - perMessageProfile["displayname"] = puppet.Name - } - if avatarURL.IsEmpty() { - perMessageProfile["avatar_url"] = puppet.AvatarURL.String() - } - part.Extra["com.beeper.per_message_profile"] = perMessageProfile - } -} - -func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) { - if msg.WebhookID == "" { - return - } - if part.Extra == nil { - part.Extra = make(map[string]any) - } - var avatarURL id.ContentURI - if msg.Author.Avatar != "" { - var err error - avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar) - if err != nil { - puppet.log.Warn().Err(err). - Str("avatar_id", msg.Author.Avatar). - Msg("Failed to reupload webhook avatar") - } - } - part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{ - "id": msg.WebhookID, - "name": msg.Author.Username, - "avatar_id": msg.Author.Avatar, - "avatar_url": msg.Author.AvatarURL(""), - "avatar_mxc": avatarURL.String(), - } - part.Extra["com.beeper.per_message_profile"] = map[string]any{ - "is_multiple_users": true, - - "avatar_url": avatarURL.String(), - "displayname": msg.Author.Username, - } -} - -const ( - embedHTMLWrapper = `
    %s
    ` - embedHTMLWrapperColor = `
    %s
    ` - embedHTMLAuthorWithImage = `

     %s

    ` - embedHTMLAuthorPlain = `

    %s

    ` - embedHTMLAuthorLink = `%s` - embedHTMLTitleWithLink = `

    %s

    ` - embedHTMLTitlePlain = `

    %s

    ` - embedHTMLDescription = `

    %s

    ` - embedHTMLFieldName = `%s` - embedHTMLFieldValue = `%s` - embedHTMLFields = `%s%s
    ` - embedHTMLLinearField = `

    %s
    %s

    ` - embedHTMLImage = `

    ` - embedHTMLFooterWithImage = `` - embedHTMLFooterPlain = `` - embedHTMLFooterOnlyDate = `` - embedHTMLDate = `` - embedFooterDateSeparator = ` • ` -) - -func (portal *Portal) convertDiscordRichEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string { - log := zerolog.Ctx(ctx) - var htmlParts []string - if embed.Author != nil { - var authorHTML string - authorNameHTML := html.EscapeString(embed.Author.Name) - if embed.Author.URL != "" { - authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML) - } - authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) - if embed.Author.ProxyIconURL != "" { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta) - if err != nil { - log.Warn().Err(err).Msg("Failed to reupload author icon in embed") - } else { - authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML) - } - } - htmlParts = append(htmlParts, authorHTML) - } - if embed.Title != "" { - var titleHTML string - baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false) - if embed.URL != "" { - titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) - } else { - titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML) - } - htmlParts = append(htmlParts, titleHTML) - } - if embed.Description != "" { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true))) - } - for i := 0; i < len(embed.Fields); i++ { - item := embed.Fields[i] - if portal.bridge.Config.Bridge.EmbedFieldsAsTables { - splitItems := []*discordgo.MessageEmbedField{item} - if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { - splitItems = append(splitItems, embed.Fields[i+1]) - i++ - if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { - splitItems = append(splitItems, embed.Fields[i+1]) - i++ - } - } - headerParts := make([]string, len(splitItems)) - contentParts := make([]string, len(splitItems)) - for j, splitItem := range splitItems { - headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) - contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true)) - } - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) - } else { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, - strconv.FormatBool(item.Inline), - portal.renderDiscordMarkdownOnlyHTML(item.Name, false), - portal.renderDiscordMarkdownOnlyHTML(item.Value, true), - )) - } - } - if embed.Image != nil { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta) - if err != nil { - log.Warn().Err(err).Msg("Failed to reupload image in embed") - } else { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC)) - } - } - var embedDateHTML string - if embed.Timestamp != "" { - formattedTime := embed.Timestamp - parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) - if err != nil { - log.Warn().Err(err).Msg("Failed to parse timestamp in embed") - } else { - formattedTime = parsedTS.Format(discordTimestampStyle('F').Format()) - } - embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime) - } - if embed.Footer != nil { - var footerHTML string - var datePart string - if embedDateHTML != "" { - datePart = embedFooterDateSeparator + embedDateHTML - } - footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) - if embed.Footer.ProxyIconURL != "" { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta) - if err != nil { - log.Warn().Err(err).Msg("Failed to reupload footer icon in embed") - } else { - footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart) - } - } - htmlParts = append(htmlParts, footerHTML) - } else if embed.Timestamp != "" { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML)) - } - - if len(htmlParts) == 0 { - return "" - } - - compiledHTML := strings.Join(htmlParts, "") - if embed.Color != 0 { - compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML) - } else { - compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML) - } - return compiledHTML -} - -type BeeperLinkPreview struct { - mautrix.RespPreviewURL - MatchedURL string `json:"matched_url"` - ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` -} - -func (portal *Portal) convertDiscordLinkEmbedImage(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview") - return - } - if width != 0 || height != 0 { - preview.ImageWidth = width - preview.ImageHeight = height - } else { - preview.ImageWidth = dbFile.Width - preview.ImageHeight = dbFile.Height - } - preview.ImageSize = dbFile.Size - preview.ImageType = dbFile.MimeType - if dbFile.Encrypted { - preview.ImageEncryption = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - preview.ImageURL = dbFile.MXC.CUString() - } -} - -func (portal *Portal) convertDiscordLinkEmbedToBeeper(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview { - var preview BeeperLinkPreview - preview.MatchedURL = embed.URL - preview.Title = embed.Title - preview.Description = embed.Description - if embed.Image != nil { - portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) - } else if embed.Thumbnail != nil { - portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) - } - return &preview -} - -const msgInteractionTemplateHTML = `
    -%s used /%s -
    ` - -const msgComponentTemplateHTML = `

    This message contains interactive elements. Use the Discord app to interact with the message.

    ` - -type BridgeEmbedType int - -const ( - EmbedUnknown BridgeEmbedType = iota - EmbedRich - EmbedLinkPreview - EmbedVideo -) - -func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { - // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview, - // so this is a hacky way to detect those. - return embed.Video != nil && embed.Video.ProxyURL == "" -} - -func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType { - switch embed.Type { - case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle: - return EmbedLinkPreview - case discordgo.EmbedTypeVideo: - if isActuallyLinkPreview(embed) { - return EmbedLinkPreview - } - return EmbedVideo - case discordgo.EmbedTypeGifv: - return EmbedVideo - case discordgo.EmbedTypeImage: - if msg != nil && isPlainGifMessage(msg) { - return EmbedVideo - } else if embed.Image == nil && embed.Thumbnail != nil { - return EmbedLinkPreview - } - return EmbedRich - case discordgo.EmbedTypeRich: - return EmbedRich - default: - return EmbedUnknown - } -} - -func isPlainGifMessage(msg *discordgo.Message) bool { - if len(msg.Embeds) != 1 { - return false - } - embed := msg.Embeds[0] - isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil - isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil - contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content) - return contentIsOnlyURL && (isGifVideo || isGifImage) -} - -func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions { - var matrixMentions event.Mentions - for _, mention := range msg.Mentions { - puppet := portal.bridge.GetPuppetByID(mention.ID) - if syncGhosts { - puppet.UpdateInfo(nil, mention, nil) - } - user := portal.bridge.GetUserByID(mention.ID) - if user != nil { - matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID) - } else { - matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID) - } - } - slices.Sort(matrixMentions.UserIDs) - matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs) - if msg.MentionEveryone { - matrixMentions.Room = true - } - return &matrixMentions -} - -func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage { - log := zerolog.Ctx(ctx) - if msg.Type == discordgo.MessageTypeCall { - return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "started a call", - }} - } else if msg.Type == discordgo.MessageTypeGuildMemberJoin { - return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "joined the server", - }} - } - var htmlParts []string - if msg.Interaction != nil { - puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID) - puppet.UpdateInfo(nil, msg.Interaction.User, nil) - htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name)) - } - if msg.Content != "" && !isPlainGifMessage(msg) { - htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, true)) - } - previews := make([]*BeeperLinkPreview, 0) - for i, embed := range msg.Embeds { - if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) { - continue - } - with := log.With(). - Str("embed_type", string(embed.Type)). - Int("embed_index", i) - switch getEmbedType(msg, embed) { - case EmbedRich: - log := with.Str("computed_embed_type", "rich").Logger() - htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i)) - case EmbedLinkPreview: - log := with.Str("computed_embed_type", "link preview").Logger() - previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed)) - case EmbedVideo: - // Ignore video embeds, they're handled as separate messages - default: - log := with.Logger() - log.Warn().Msg("Unknown embed type in message") - } - } - - if len(msg.Components) > 0 { - htmlParts = append(htmlParts, msgComponentTemplateHTML) - } - - if len(htmlParts) == 0 { - return nil - } - - fullHTML := strings.Join(htmlParts, "\n") - if !msg.MentionEveryone { - fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om") - } - - content := format.HTMLToContent(fullHTML) - extraContent := map[string]any{ - "com.beeper.linkpreviews": previews, - } - - if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages { - content.EnsureHasHTML() - content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body) - content.FormattedBody = fmt.Sprintf("%s: %s", html.EscapeString(msg.Author.Username), content.FormattedBody) - } - - return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent} -} diff --git a/provisioning.go b/provisioning.go deleted file mode 100644 index c9ff3ab..0000000 --- a/provisioning.go +++ /dev/null @@ -1,552 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "net" - "net/http" - _ "net/http/pprof" - "strings" - "time" - - "github.com/gorilla/mux" - "github.com/gorilla/websocket" - log "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-discord/database" - "go.mau.fi/mautrix-discord/remoteauth" -) - -const ( - SecWebSocketProtocol = "com.gitlab.beeper.discord" -) - -const ( - ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED" - ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN" - ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED" - ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED" - ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED" - ErrCodeGuildBridgeFailed = "M_UNKNOWN" - ErrCodeGuildUnbridgeFailed = "M_UNKNOWN" - ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED" - ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED" - ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED" - ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED" - ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED" -) - -type ProvisioningAPI struct { - bridge *DiscordBridge - log log.Logger -} - -func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI { - p := &ProvisioningAPI{ - bridge: br, - log: br.Log.Sub("Provisioning"), - } - - prefix := br.Config.Bridge.Provisioning.Prefix - - p.log.Debugln("Enabling provisioning API at", prefix) - - r := br.AS.Router.PathPrefix(prefix).Subrouter() - - r.Use(p.authMiddleware) - - r.HandleFunc("/v1/disconnect", p.disconnect).Methods(http.MethodPost) - r.HandleFunc("/v1/ping", p.ping).Methods(http.MethodGet) - r.HandleFunc("/v1/login/qr", p.qrLogin).Methods(http.MethodGet) - r.HandleFunc("/v1/login/token", p.tokenLogin).Methods(http.MethodPost) - r.HandleFunc("/v1/logout", p.logout).Methods(http.MethodPost) - r.HandleFunc("/v1/reconnect", p.reconnect).Methods(http.MethodPost) - - r.HandleFunc("/v1/guilds", p.guildsList).Methods(http.MethodGet) - r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost) - r.HandleFunc("/v1/guilds/{guildID}", p.guildsUnbridge).Methods(http.MethodDelete) - - if p.bridge.Config.Bridge.Provisioning.DebugEndpoints { - p.log.Debugln("Enabling debug API at /debug") - r := p.bridge.AS.Router.PathPrefix("/debug").Subrouter() - r.Use(p.authMiddleware) - r.PathPrefix("/pprof").Handler(http.DefaultServeMux) - } - - return p -} - -func jsonResponse(w http.ResponseWriter, status int, response interface{}) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(response) -} - -// Response structs -type Response struct { - Success bool `json:"success"` - Status string `json:"status"` -} - -type Error struct { - Success bool `json:"success"` - Error string `json:"error"` - ErrCode string `json:"errcode"` -} - -// Wrapped http.ResponseWriter to capture the status code -type responseWrap struct { - http.ResponseWriter - statusCode int -} - -var _ http.Hijacker = (*responseWrap)(nil) - -func (rw *responseWrap) WriteHeader(statusCode int) { - rw.ResponseWriter.WriteHeader(statusCode) - rw.statusCode = statusCode -} - -func (rw *responseWrap) Hijack() (net.Conn, *bufio.ReadWriter, error) { - hijacker, ok := rw.ResponseWriter.(http.Hijacker) - if !ok { - return nil, nil, errors.New("response does not implement http.Hijacker") - } - return hijacker.Hijack() -} - -// Middleware -func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") - - // Special case the login endpoint to use the discord qrcode auth - if auth == "" && strings.HasSuffix(r.URL.Path, "/login") { - authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") - for _, part := range authParts { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, SecWebSocketProtocol+"-") { - auth = part[len(SecWebSocketProtocol+"-"):] - - break - } - } - } else if strings.HasPrefix(auth, "Bearer ") { - auth = auth[len("Bearer "):] - } - - if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret { - jsonResponse(w, http.StatusUnauthorized, map[string]interface{}{ - "error": "Invalid auth token", - "errcode": mautrix.MUnknownToken.ErrCode, - }) - - return - } - - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) - - start := time.Now() - wWrap := &responseWrap{w, 200} - h.ServeHTTP(wWrap, r.WithContext(context.WithValue(r.Context(), "user", user))) - duration := time.Now().Sub(start).Seconds() - - p.log.Infofln("%s %s from %s took %.2f seconds and returned status %d", r.Method, r.URL.Path, user.MXID, duration, wWrap.statusCode) - }) -} - -// websocket upgrader -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - Subprotocols: []string{SecWebSocketProtocol}, -} - -// Handlers -func (p *ProvisioningAPI) disconnect(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - - if !user.Connected() { - jsonResponse(w, http.StatusConflict, Error{ - Error: "You're not connected to discord", - ErrCode: ErrCodeNotConnected, - }) - return - } - - if err := user.Disconnect(); err != nil { - p.log.Errorfln("Failed to disconnect %s: %v", user.MXID, err) - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: "Failed to disconnect from discord", - ErrCode: ErrCodeDisconnectFailed, - }) - } else { - jsonResponse(w, http.StatusOK, Response{ - Success: true, - Status: "Disconnected from Discord", - }) - } -} - -type respPing struct { - Discord struct { - ID string `json:"id,omitempty"` - LoggedIn bool `json:"logged_in"` - Connected bool `json:"connected"` - Conn struct { - LastHeartbeatAck int64 `json:"last_heartbeat_ack,omitempty"` - LastHeartbeatSent int64 `json:"last_heartbeat_sent,omitempty"` - } `json:"conn"` - } - MXID id.UserID `json:"mxid"` - ManagementRoom id.RoomID `json:"management_room"` -} - -func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - - resp := respPing{ - MXID: user.MXID, - ManagementRoom: user.ManagementRoom, - } - resp.Discord.LoggedIn = user.IsLoggedIn() - resp.Discord.Connected = user.Connected() - resp.Discord.ID = user.DiscordID - if user.Session != nil { - resp.Discord.Conn.LastHeartbeatAck = user.Session.LastHeartbeatAck.UnixMilli() - resp.Discord.Conn.LastHeartbeatSent = user.Session.LastHeartbeatSent.UnixMilli() - } - jsonResponse(w, http.StatusOK, resp) -} - -func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - var msg string - if user.DiscordID != "" { - msg = "Logged out successfully." - } else { - msg = "User wasn't logged in." - } - user.Logout(false) - jsonResponse(w, http.StatusOK, Response{true, msg}) -} - -func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) - - c, err := upgrader.Upgrade(w, r, nil) - if err != nil { - p.log.Errorln("Failed to upgrade connection to websocket:", err) - return - } - - log := p.log.Sub("QRLogin").Sub(user.MXID.String()) - - defer func() { - err := c.Close() - if err != nil { - log.Debugln("Error closing websocket:", err) - } - }() - - go func() { - // Read everything so SetCloseHandler() works - for { - _, _, err := c.ReadMessage() - if err != nil { - break - } - } - }() - - ctx, cancel := context.WithCancel(context.Background()) - c.SetCloseHandler(func(code int, text string) error { - log.Debugfln("Login websocket closed (%d), cancelling login", code) - cancel() - return nil - }) - - if user.IsLoggedIn() { - _ = c.WriteJSON(Error{ - Error: "You're already logged into Discord", - ErrCode: ErrCodeAlreadyLoggedIn, - }) - return - } - - client, err := remoteauth.New() - if err != nil { - log.Errorln("Failed to prepare login:", err) - _ = c.WriteJSON(Error{ - Error: "Failed to prepare login", - ErrCode: ErrCodeLoginPrepareFailed, - }) - return - } - - qrChan := make(chan string) - doneChan := make(chan struct{}) - - log.Debugln("Started login via provisioning API") - - err = client.Dial(ctx, qrChan, doneChan) - if err != nil { - log.Errorln("Failed to connect to Discord login websocket:", err) - close(qrChan) - close(doneChan) - _ = c.WriteJSON(Error{ - Error: "Failed to connect to Discord login websocket", - ErrCode: ErrCodeLoginConnectionFailed, - }) - return - } - - for { - select { - case qrCode, ok := <-qrChan: - if !ok { - continue - } - err = c.WriteJSON(map[string]interface{}{ - "code": qrCode, - "timeout": 120, // TODO: move this to the library or something - }) - if err != nil { - log.Errorln("Failed to write QR code to websocket:", err) - } - case <-doneChan: - var discordUser remoteauth.User - discordUser, err = client.Result() - if err != nil { - log.Errorln("Discord login websocket returned error:", err) - _ = c.WriteJSON(Error{ - Error: "Failed to log in", - ErrCode: ErrCodeLoginFailed, - }) - return - } - - log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID) - - if err = user.Login(discordUser.Token); err != nil { - log.Errorln("Failed to connect after logging in:", err) - _ = c.WriteJSON(Error{ - Error: "Failed to connect to Discord after logging in", - ErrCode: ErrCodePostLoginConnFailed, - }) - return - } - - err = c.WriteJSON(respLogin{ - Success: true, - ID: user.DiscordID, - Username: discordUser.Username, - Discriminator: discordUser.Discriminator, - }) - if err != nil { - log.Errorln("Failed to write login success to websocket:", err) - } - return - case <-ctx.Done(): - return - } - } -} - -type respLogin struct { - Success bool `json:"success"` - ID string `json:"id"` - Username string `json:"username"` - Discriminator string `json:"discriminator"` -} - -type reqTokenLogin struct { - Token string `json:"token"` -} - -func (p *ProvisioningAPI) tokenLogin(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) - log := p.log.Sub("TokenLogin").Sub(user.MXID.String()) - if user.IsLoggedIn() { - jsonResponse(w, http.StatusConflict, Error{ - Error: "You're already logged into Discord", - ErrCode: ErrCodeAlreadyLoggedIn, - }) - return - } - var body reqTokenLogin - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - log.Errorln("Failed to parse login request:", err) - jsonResponse(w, http.StatusBadRequest, Error{ - Error: "Failed to parse request body", - ErrCode: mautrix.MBadJSON.ErrCode, - }) - return - } - if err := user.Login(body.Token); err != nil { - log.Errorln("Failed to connect with provided token:", err) - jsonResponse(w, http.StatusUnauthorized, Error{ - Error: "Failed to connect to Discord", - ErrCode: ErrCodePostLoginConnFailed, - }) - return - } - log.Infoln("Successfully logged in") - jsonResponse(w, http.StatusOK, respLogin{ - Success: true, - ID: user.DiscordID, - Username: user.Session.State.User.Username, - Discriminator: user.Session.State.User.Discriminator, - }) -} - -func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - - if user.Connected() { - jsonResponse(w, http.StatusConflict, Error{ - Error: "You're already connected to discord", - ErrCode: ErrCodeAlreadyConnected, - }) - - return - } - - if err := user.Connect(); err != nil { - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: "Failed to connect to discord", - ErrCode: ErrCodeConnectFailed, - }) - } else { - jsonResponse(w, http.StatusOK, Response{ - Success: true, - Status: "Connected to Discord", - }) - } -} - -type guildEntry struct { - ID string `json:"id"` - Name string `json:"name"` - AvatarURL id.ContentURI `json:"avatar_url"` - MXID id.RoomID `json:"mxid"` - AutoBridge bool `json:"auto_bridge_channels"` - BridgingMode string `json:"bridging_mode"` -} - -type respGuildsList struct { - Guilds []guildEntry `json:"guilds"` -} - -func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - - var resp respGuildsList - resp.Guilds = []guildEntry{} - for _, userGuild := range user.GetPortals() { - guild := p.bridge.GetGuildByID(userGuild.DiscordID, false) - if guild == nil { - continue - } - resp.Guilds = append(resp.Guilds, guildEntry{ - ID: guild.ID, - Name: guild.PlainName, - AvatarURL: guild.AvatarURL, - MXID: guild.MXID, - AutoBridge: guild.BridgingMode == database.GuildBridgeEverything, - BridgingMode: guild.BridgingMode.String(), - }) - } - - jsonResponse(w, http.StatusOK, resp) -} - -type reqBridgeGuild struct { - AutoCreateChannels bool `json:"auto_create_channels"` -} - -type respBridgeGuild struct { - Success bool `json:"success"` - MXID id.RoomID `json:"mxid"` -} - -func (p *ProvisioningAPI) guildsBridge(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - guildID := mux.Vars(r)["guildID"] - - var body reqBridgeGuild - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - p.log.Errorln("Failed to parse bridge request:", err) - jsonResponse(w, http.StatusBadRequest, Error{ - Error: "Failed to parse request body", - ErrCode: mautrix.MBadJSON.ErrCode, - }) - return - } - - guild := user.bridge.GetGuildByID(guildID, false) - if guild == nil { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "Guild not found", - ErrCode: mautrix.MNotFound.ErrCode, - }) - return - } - alreadyExists := guild.MXID == "" - if err := user.bridgeGuild(guildID, body.AutoCreateChannels); err != nil { - p.log.Errorfln("Error bridging %s: %v", guildID, err) - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: "Internal error while trying to bridge guild", - ErrCode: ErrCodeGuildBridgeFailed, - }) - } else if alreadyExists { - jsonResponse(w, http.StatusOK, respBridgeGuild{ - Success: true, - MXID: guild.MXID, - }) - } else { - jsonResponse(w, http.StatusCreated, respBridgeGuild{ - Success: true, - MXID: guild.MXID, - }) - } -} - -func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request) { - guildID := mux.Vars(r)["guildID"] - user := r.Context().Value("user").(*User) - if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin { - jsonResponse(w, http.StatusForbidden, Error{ - Error: "Only bridge admins can unbridge guilds", - ErrCode: mautrix.MForbidden.ErrCode, - }) - } else if guild := user.bridge.GetGuildByID(guildID, false); guild == nil { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "Guild not found", - ErrCode: mautrix.MNotFound.ErrCode, - }) - } else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "That guild is not bridged", - ErrCode: ErrCodeGuildNotBridged, - }) - } else if err := user.unbridgeGuild(guildID); err != nil { - p.log.Errorfln("Error unbridging %s: %v", guildID, err) - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: "Internal error while trying to unbridge guild", - ErrCode: ErrCodeGuildUnbridgeFailed, - }) - } else { - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/puppet.go b/puppet.go deleted file mode 100644 index ca6489e..0000000 --- a/puppet.go +++ /dev/null @@ -1,386 +0,0 @@ -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" - - "go.mau.fi/mautrix-discord/database" -) - -type Puppet struct { - *database.Puppet - - bridge *DiscordBridge - log zerolog.Logger - - MXID id.UserID - - customIntent *appservice.IntentAPI - customUser *User - - syncLock sync.Mutex -} - -var _ bridge.Ghost = (*Puppet)(nil) -var _ bridge.GhostWithProfile = (*Puppet)(nil) - -func (puppet *Puppet) GetMXID() id.UserID { - return puppet.MXID -} - -var userIDRegex *regexp.Regexp - -func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { - return &Puppet{ - Puppet: dbPuppet, - bridge: br, - log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(), - - MXID: br.FormatPuppetMXID(dbPuppet.ID), - } -} - -func (br *DiscordBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) { - if userIDRegex == nil { - pattern := fmt.Sprintf( - "^@%s:%s$", - br.Config.Bridge.FormatUsername("([0-9]+)"), - br.Config.Homeserver.Domain, - ) - - userIDRegex = regexp.MustCompile(pattern) - } - - match := userIDRegex.FindStringSubmatch(string(mxid)) - if len(match) == 2 { - return match[1], true - } - - return "", false -} - -func (br *DiscordBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - discordID, ok := br.ParsePuppetMXID(mxid) - if !ok { - return nil - } - - return br.GetPuppetByID(discordID) -} - -func (br *DiscordBridge) GetPuppetByID(id string) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - puppet, ok := br.puppets[id] - if !ok { - dbPuppet := br.DB.Puppet.Get(id) - if dbPuppet == nil { - dbPuppet = br.DB.Puppet.New() - dbPuppet.ID = id - dbPuppet.Insert() - } - - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.ID] = puppet - } - - return puppet -} - -func (br *DiscordBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - puppet, ok := br.puppetsByCustomMXID[mxid] - if !ok { - dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid) - if dbPuppet == nil { - return nil - } - - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.ID] = puppet - br.puppetsByCustomMXID[puppet.CustomMXID] = puppet - } - - return puppet -} - -func (br *DiscordBridge) GetAllPuppetsWithCustomMXID() []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID()) -} - -func (br *DiscordBridge) GetAllPuppets() []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll()) -} - -func (br *DiscordBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - output := make([]*Puppet, len(dbPuppets)) - for index, dbPuppet := range dbPuppets { - if dbPuppet == nil { - continue - } - - puppet, ok := br.puppets[dbPuppet.ID] - if !ok { - puppet = br.NewPuppet(dbPuppet) - br.puppets[dbPuppet.ID] = puppet - - if dbPuppet.CustomMXID != "" { - br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet - } - } - - output[index] = puppet - } - - return output -} - -func (br *DiscordBridge) FormatPuppetMXID(did string) id.UserID { - return id.NewUserID( - br.Config.Bridge.FormatUsername(did), - br.Config.Homeserver.Domain, - ) -} - -func (puppet *Puppet) GetDisplayname() string { - return puppet.Name -} - -func (puppet *Puppet) GetAvatarURL() id.ContentURI { - return puppet.AvatarURL -} - -func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { - return puppet.bridge.AS.Intent(puppet.MXID) -} - -func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { - if puppet.customIntent == nil || (portal.Key.Receiver != "" && portal.Key.Receiver != puppet.ID) { - return puppet.DefaultIntent() - } - - return puppet.customIntent -} - -func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { - if puppet == nil { - return nil - } - return puppet.customIntent -} - -func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { - for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.ID) { - // Get room create lock to prevent races between receiving contact info and room creation. - portal.roomCreateLock.Lock() - meta(portal) - portal.roomCreateLock.Unlock() - } -} - -func (puppet *Puppet) UpdateName(info *discordgo.User) bool { - newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication) - if puppet.Name == newName && puppet.NameSet { - return false - } - puppet.Name = newName - puppet.NameSet = false - err := puppet.DefaultIntent().SetDisplayName(newName) - if err != nil { - puppet.log.Warn().Err(err).Msg("Failed to update displayname") - } else { - go puppet.updatePortalMeta(func(portal *Portal) { - if portal.UpdateNameDirect(puppet.Name, false) { - portal.Update() - portal.UpdateBridgeInfo() - } - }) - puppet.NameSet = true - } - return true -} - -func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) { - var downloadURL string - if guildID == "" { - if strings.HasPrefix(avatarID, "a_") { - downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID) - } else { - downloadURL = discordgo.EndpointUserAvatar(userID, avatarID) - } - } else { - if strings.HasPrefix(avatarID, "a_") { - downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID) - } else { - downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID) - } - } - url := br.DMA.AvatarMXC(guildID, userID, avatarID) - 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 { - avatarID := info.Avatar - if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars { - avatarID = "" - } - if puppet.Avatar == avatarID && puppet.AvatarSet { - return false - } - avatarChanged := avatarID != puppet.Avatar - puppet.Avatar = avatarID - puppet.AvatarSet = false - puppet.AvatarURL = id.ContentURI{} - - if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) { - 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 - } - puppet.AvatarURL = url - } - - err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) - if err != nil { - puppet.log.Warn().Err(err).Msg("Failed to update avatar") - } else { - go puppet.updatePortalMeta(func(portal *Portal) { - if portal.UpdateAvatarFromPuppet(puppet) { - portal.Update() - portal.UpdateBridgeInfo() - } - }) - puppet.AvatarSet = true - } - return true -} - -func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) { - puppet.syncLock.Lock() - defer puppet.syncLock.Unlock() - - if info == nil || len(info.Username) == 0 || len(info.Discriminator) == 0 { - if puppet.Name != "" || source == nil { - return - } - var err error - puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet") - info, err = source.Session.User(puppet.ID) - if err != nil { - puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user") - return - } - } - - err := puppet.DefaultIntent().EnsureRegistered() - if err != nil { - puppet.log.Error().Err(err).Msg("Failed to ensure registered") - } - - changed := false - if message != nil { - if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook { - puppet.log.Debug(). - Str("message_id", message.ID). - Str("webhook_id", message.WebhookID). - Msg("Found webhook ID in message, marking ghost as a webhook") - puppet.IsWebhook = true - changed = true - } - if message.ApplicationID != "" && !puppet.IsApplication { - puppet.log.Debug(). - Str("message_id", message.ID). - Str("application_id", message.ApplicationID). - Msg("Found application ID in message, marking ghost as an application") - puppet.IsApplication = true - puppet.IsWebhook = false - changed = true - } - } - changed = puppet.UpdateContactInfo(info) || changed - changed = puppet.UpdateName(info) || changed - changed = puppet.UpdateAvatar(info) || changed - if changed { - puppet.Update() - } -} - -func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool { - changed := false - if puppet.Username != info.Username { - puppet.Username = info.Username - changed = true - } - if puppet.GlobalName != info.GlobalName { - puppet.GlobalName = info.GlobalName - changed = true - } - if puppet.Discriminator != info.Discriminator { - puppet.Discriminator = info.Discriminator - changed = true - } - if puppet.IsBot != info.Bot { - puppet.IsBot = info.Bot - changed = true - } - if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet { - puppet.ContactInfoSet = false - puppet.ResendContactInfo() - return true - } - return false -} - -func (puppet *Puppet) ResendContactInfo() { - if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet { - return - } - discordUsername := puppet.Username - if puppet.Discriminator != "0" { - discordUsername += "#" + puppet.Discriminator - } - contactInfo := map[string]any{ - "com.beeper.bridge.identifiers": []string{ - fmt.Sprintf("discord:%s", discordUsername), - }, - "com.beeper.bridge.remote_id": puppet.ID, - "com.beeper.bridge.service": puppet.bridge.BeeperServiceName, - "com.beeper.bridge.network": puppet.bridge.BeeperNetworkName, - "com.beeper.bridge.is_network_bot": puppet.IsBot, - } - if puppet.IsWebhook { - contactInfo["com.beeper.bridge.identifiers"] = []string{} - } - err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo) - if err != nil { - puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile") - } else { - puppet.ContactInfoSet = true - } -} diff --git a/thread.go b/thread.go deleted file mode 100644 index 5de2410..0000000 --- a/thread.go +++ /dev/null @@ -1,157 +0,0 @@ -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" -) - -type Thread struct { - *database.Thread - Parent *Portal - - creationNoticeLock sync.Mutex - initialBackfillAttempted bool -} - -func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread { - br.threadsLock.Lock() - defer br.threadsLock.Unlock() - thread, ok := br.threadsByID[id] - if !ok { - return br.loadThread(br.DB.Thread.GetByDiscordID(id), id, root) - } - return thread -} - -func (br *DiscordBridge) GetThreadByRootMXID(mxid id.EventID) *Thread { - br.threadsLock.Lock() - defer br.threadsLock.Unlock() - thread, ok := br.threadsByRootMXID[mxid] - if !ok { - return br.loadThread(br.DB.Thread.GetByMatrixRootMsg(mxid), "", nil) - } - return thread -} - -func (br *DiscordBridge) GetThreadByRootOrCreationNoticeMXID(mxid id.EventID) *Thread { - br.threadsLock.Lock() - defer br.threadsLock.Unlock() - thread, ok := br.threadsByRootMXID[mxid] - if !ok { - thread, ok = br.threadsByCreationNoticeMXID[mxid] - if !ok { - return br.loadThread(br.DB.Thread.GetByMatrixRootOrCreationNoticeMsg(mxid), "", nil) - } - } - return thread -} - -func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *database.Message) *Thread { - if dbThread == nil { - if root == nil { - return nil - } - dbThread = br.DB.Thread.New() - dbThread.ID = id - dbThread.RootDiscordID = root.DiscordID - dbThread.RootMXID = root.MXID - dbThread.ParentID = root.Channel.ChannelID - dbThread.Insert() - } - thread := &Thread{ - Thread: dbThread, - } - thread.Parent = br.GetExistingPortalByID(database.NewPortalKey(thread.ParentID, "")) - br.threadsByID[thread.ID] = thread - br.threadsByRootMXID[thread.RootMXID] = thread - if thread.CreationNoticeMXID != "" { - br.threadsByCreationNoticeMXID[thread.CreationNoticeMXID] = thread - } - 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) - } else { - err = user.Session.ThreadJoin(thread.ID) - } - if err != nil { - log.Error().Err(err).Msg("Error joining thread") - } else { - user.MarkInPortal(database.UserPortal{ - DiscordID: thread.ID, - Type: database.UserPortalTypeThread, - Timestamp: time.Now(), - }) - if doBackfill { - go thread.Parent.forwardBackfillInitial(user, thread) - backfillStarted = true - } - } -} diff --git a/user.go b/user.go deleted file mode 100644 index 1f08fba..0000000 --- a/user.go +++ /dev/null @@ -1,1484 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "math/rand" - "net/http" - "os" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "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" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "go.mau.fi/mautrix-discord/database" -) - -var ( - ErrNotConnected = errors.New("not connected") - ErrNotLoggedIn = errors.New("not logged in") -) - -type User struct { - *database.User - - sync.Mutex - - bridge *DiscordBridge - log zerolog.Logger - - PermissionLevel bridgeconfig.PermissionLevel - - spaceCreateLock sync.Mutex - spaceMembershipChecked bool - dmSpaceMembershipChecked bool - - Session *discordgo.Session - - BridgeState *bridge.BridgeStateQueue - bridgeStateLock sync.Mutex - wasDisconnected bool - wasLoggedOut bool - - markedOpened map[string]time.Time - markedOpenedLock sync.Mutex - - pendingInteractions map[string]*WrappedCommandEvent - pendingInteractionsLock sync.Mutex - - nextDiscordUploadID atomic.Int32 - - relationships map[string]*discordgo.Relationship -} - -func (user *User) GetRemoteID() string { - return user.DiscordID -} - -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 -} - -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{}) { - discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf - } -} - -func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { - return user.PermissionLevel -} - -func (user *User) GetManagementRoomID() id.RoomID { - return user.ManagementRoom -} - -func (user *User) GetMXID() id.UserID { - return user.MXID -} - -func (user *User) GetCommandState() map[string]interface{} { - return nil -} - -func (user *User) GetIDoublePuppet() bridge.DoublePuppet { - p := user.bridge.GetPuppetByCustomMXID(user.MXID) - if p == nil || p.CustomIntent() == nil { - return nil - } - return p -} - -func (user *User) GetIGhost() bridge.Ghost { - if user.DiscordID == "" { - return nil - } - p := user.bridge.GetPuppetByID(user.DiscordID) - if p == nil { - return nil - } - return p -} - -var _ bridge.User = (*User)(nil) - -func (br *DiscordBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User { - if dbUser == nil { - if mxid == nil { - return nil - } - dbUser = br.DB.User.New() - dbUser.MXID = *mxid - dbUser.Insert() - } - - user := br.NewUser(dbUser) - br.usersByMXID[user.MXID] = user - if user.DiscordID != "" { - br.usersByID[user.DiscordID] = user - } - if user.ManagementRoom != "" { - br.managementRoomsLock.Lock() - br.managementRooms[user.ManagementRoom] = user - br.managementRoomsLock.Unlock() - } - return user -} - -func (br *DiscordBridge) GetUserByMXID(userID id.UserID) *User { - if userID == br.Bot.UserID || br.IsGhost(userID) { - return nil - } - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByMXID[userID] - if !ok { - return br.loadUser(br.DB.User.GetByMXID(userID), &userID) - } - return user -} - -func (br *DiscordBridge) GetUserByID(id string) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByID[id] - if !ok { - return br.loadUser(br.DB.User.GetByID(id), nil) - } - return user -} - -func (br *DiscordBridge) GetCachedUserByID(id string) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - return br.usersByID[id] -} - -func (br *DiscordBridge) GetCachedUserByMXID(userID id.UserID) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - return br.usersByMXID[userID] -} - -func (br *DiscordBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.ZLog.With().Str("user_id", string(dbUser.MXID)).Logger(), - - markedOpened: make(map[string]time.Time), - 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) - return user -} - -func (br *DiscordBridge) getAllUsersWithToken() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - dbUsers := br.DB.User.GetAllWithToken() - users := make([]*User, len(dbUsers)) - - for idx, dbUser := range dbUsers { - user, ok := br.usersByMXID[dbUser.MXID] - if !ok { - user = br.loadUser(dbUser, nil) - } - users[idx] = user - } - return users -} - -func (br *DiscordBridge) startUsers() { - br.ZLog.Debug().Msg("Starting users") - - usersWithToken := br.getAllUsersWithToken() - for _, u := range usersWithToken { - go u.startupTryConnect(0) - } - if len(usersWithToken) == 0 { - br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) - } - - br.ZLog.Debug().Msg("Starting custom puppets") - for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() { - go func(puppet *Puppet) { - br.ZLog.Debug().Str("user_id", puppet.CustomMXID.String()).Msg("Starting custom puppet") - - if err := puppet.StartCustomMXID(true); err != nil { - puppet.log.Error().Err(err).Msg("Failed to start custom puppet") - } - }(customPuppet) - } -} - -func (user *User) startupTryConnect(retryCount int) { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - err := user.Connect() - if err != nil { - user.log.Error().Err(err).Msg("Error connecting on startup") - closeErr := &websocket.CloseError{} - if errors.As(err, &closeErr) && closeErr.Code == 4004 { - 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 - user.log.Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection") - time.Sleep(time.Duration(retryInSeconds) * time.Second) - user.startupTryConnect(retryCount + 1) - } else { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "dc-unknown-websocket-error", Message: err.Error()}) - } - } -} - -func (user *User) SetManagementRoom(roomID id.RoomID) { - user.bridge.managementRoomsLock.Lock() - defer user.bridge.managementRoomsLock.Unlock() - - existing, ok := user.bridge.managementRooms[roomID] - if ok { - existing.ManagementRoom = "" - existing.Update() - } - - user.ManagementRoom = roomID - user.bridge.managementRooms[user.ManagementRoom] = user - user.Update() -} - -func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.RoomID) id.RoomID { - if len(*ptr) > 0 { - return *ptr - } - user.spaceCreateLock.Lock() - defer user.spaceCreateLock.Unlock() - if len(*ptr) > 0 { - return *ptr - } - - initialState := []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: user.bridge.Config.AppService.Bot.ParsedAvatar, - }, - }, - }} - - if parent != "" { - parentIDStr := parent.String() - initialState = append(initialState, &event.Event{ - Type: event.StateSpaceParent, - StateKey: &parentIDStr, - Content: event.Content{ - Parsed: &event.SpaceParentEventContent{ - Canonical: true, - Via: []string{user.bridge.AS.HomeserverDomain}, - }, - }, - }) - } - - resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ - Visibility: "private", - Name: name, - Topic: topic, - InitialState: initialState, - CreationContent: map[string]interface{}{ - "type": event.RoomTypeSpace, - }, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - user.bridge.Bot.UserID: 9001, - user.MXID: 50, - }, - }, - }) - - if err != nil { - user.log.Error().Err(err).Msg("Failed to auto-create space room") - } else { - *ptr = resp.RoomID - user.Update() - user.ensureInvited(nil, *ptr, false, true) - - if parent != "" { - _, err = user.bridge.Bot.SendStateEvent(parent, event.StateSpaceChild, resp.RoomID.String(), &event.SpaceChildEventContent{ - Via: []string{user.bridge.AS.HomeserverDomain}, - Order: " 0000", - }) - if err != nil { - user.log.Error().Err(err). - Str("created_space_id", resp.RoomID.String()). - Str("parent_space_id", parent.String()). - Msg("Failed to add created space room to parent space") - } - } - } - return *ptr -} - -func (user *User) GetSpaceRoom() id.RoomID { - return user.getSpaceRoom(&user.SpaceRoom, "Discord", "Your Discord bridged chats", "") -} - -func (user *User) GetDMSpaceRoom() id.RoomID { - return user.getSpaceRoom(&user.DMSpaceRoom, "Direct Messages", "Your Discord direct messages", user.GetSpaceRoom()) -} - -func (user *User) ViewingChannel(portal *Portal) bool { - if portal.GuildID != "" || !user.Session.IsUser { - return false - } - user.markedOpenedLock.Lock() - defer user.markedOpenedLock.Unlock() - ts := user.markedOpened[portal.Key.ChannelID] - // TODO is there an expiry time? - if ts.IsZero() { - user.markedOpened[portal.Key.ChannelID] = time.Now() - err := user.Session.MarkViewing(portal.Key.ChannelID) - if err != nil { - user.log.Error().Err(err). - Str("channel_id", portal.Key.ChannelID). - Msg("Failed to mark user as viewing channel") - } - return true - } - return false -} - -func (user *User) mutePortal(intent *appservice.IntentAPI, portal *Portal, unmute bool) { - if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteChannelsOnCreate { - return - } - var err error - if unmute { - user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Unmuting portal") - err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID)) - } else { - user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Muting portal") - err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ - Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, - }) - } - if err != nil && !errors.Is(err, mautrix.MNotFound) { - user.log.Warn().Err(err). - Str("room_id", portal.MXID.String()). - Msg("Failed to update push rule through double puppet") - } -} - -func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) { - doublePuppetIntent := portal.bridge.GetPuppetByCustomMXID(user.MXID).CustomIntent() - if doublePuppetIntent == nil || portal.MXID == "" { - return - } - - // TODO sync mute status properly - if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate && justCreated { - user.mutePortal(doublePuppetIntent, portal, false) - } -} - -func (user *User) NextDiscordUploadID() string { - val := user.nextDiscordUploadID.Add(2) - return strconv.Itoa(int(val)) -} - -func (user *User) Login(token string) error { - user.bridgeStateLock.Lock() - user.wasLoggedOut = false - user.bridgeStateLock.Unlock() - user.DiscordToken = token - var err error - const maxRetries = 3 -Loop: - for i := 0; i < maxRetries; i++ { - err = user.Connect() - if err == nil { - user.Update() - return nil - } - user.log.Error().Err(err).Msg("Error connecting for login") - closeErr := &websocket.CloseError{} - errors.As(err, &closeErr) - switch closeErr.Code { - case 4004, 4010, 4011, 4012, 4013, 4014: - break Loop - case 4000: - fallthrough - default: - if i < maxRetries-1 { - time.Sleep(time.Duration(i+1) * 2 * time.Second) - } - } - } - user.DiscordToken = "" - return err -} - -func (user *User) IsLoggedIn() bool { - user.Lock() - defer user.Unlock() - - return user.DiscordToken != "" -} - -func (user *User) Logout(isOverwriting bool) { - user.Lock() - defer user.Unlock() - - if user.DiscordID != "" { - puppet := user.bridge.GetPuppetByID(user.DiscordID) - if puppet.CustomMXID != "" { - err := puppet.SwitchCustomMXID("", "") - if err != nil { - user.log.Warn().Err(err).Msg("Failed to disable custom puppet while logging out of Discord") - } - } - } - - if user.Session != nil { - if err := user.Session.Close(); err != nil { - user.log.Warn().Err(err).Msg("Error closing session") - } - } - - user.Session = nil - user.DiscordToken = "" - user.ReadStateVersion = 0 - if !isOverwriting { - user.bridge.usersLock.Lock() - if user.bridge.usersByID[user.DiscordID] == user { - delete(user.bridge.usersByID, user.DiscordID) - } - user.bridge.usersLock.Unlock() - } - user.DiscordID = "" - user.Update() - user.log.Info().Msg("User logged out") -} - -func (user *User) Connected() bool { - user.Lock() - defer user.Unlock() - - return user.Session != nil -} - -const BotIntents = discordgo.IntentGuilds | - discordgo.IntentGuildMessages | - discordgo.IntentGuildMessageReactions | - discordgo.IntentGuildMessageTyping | - discordgo.IntentGuildBans | - discordgo.IntentGuildEmojis | - discordgo.IntentGuildIntegrations | - discordgo.IntentGuildInvites | - //discordgo.IntentGuildVoiceStates | - //discordgo.IntentGuildScheduledEvents | - discordgo.IntentDirectMessages | - discordgo.IntentDirectMessageTyping | - discordgo.IntentDirectMessageTyping | - // Privileged intents - discordgo.IntentMessageContent | - //discordgo.IntentGuildPresences | - discordgo.IntentGuildMembers - -func (user *User) Connect() error { - user.Lock() - defer user.Unlock() - - if user.DiscordToken == "" { - return ErrNotLoggedIn - } - - user.log.Debug().Msg("Connecting to discord") - - session, err := discordgo.New(user.DiscordToken) - if err != nil { - return err - } - // 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...) // zerolog-allow-msgf - } - if !session.IsUser { - session.Identify.Intents = BotIntents - } - session.EventHandler = user.eventHandlerSync - - user.Session = session - - for { - err = user.Session.Open() - if errors.Is(err, discordgo.ErrImmediateDisconnect) { - user.log.Warn().Err(err).Msg("Retrying initial connection in 5 seconds") - time.Sleep(5 * time.Second) - continue - } - return err - } -} - -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() - if user.Session == nil { - return ErrNotConnected - } - - user.log.Info().Msg("Disconnecting session manually") - if err := user.Session.Close(); err != nil { - return err - } - user.Session = nil - return nil -} - -func (user *User) getGuildBridgingMode(guildID string) database.GuildBridgingMode { - if guildID == "" { - return database.GuildBridgeEverything - } - guild := user.bridge.GetGuildByID(guildID, false) - if guild == nil { - return database.GuildBridgeNothing - } - return guild.BridgingMode -} - -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 - user.bridgeStateLock.Unlock() - - if user.DiscordID != r.User.ID { - user.bridge.usersLock.Lock() - user.DiscordID = r.User.ID - if previousUser, ok := user.bridge.usersByID[user.DiscordID]; ok && previousUser != user { - user.log.Warn(). - Str("previous_user_id", previousUser.MXID.String()). - Msg("Another user is logged in with same Discord ID, logging them out") - // TODO send notice? - previousUser.Logout(true) - } - user.bridge.usersByID[user.DiscordID] = user - user.bridge.usersLock.Unlock() - user.Update() - } - 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() { - portalsInSpace[guild.DiscordID] = guild.InSpace - } - 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]) - } - user.PrunePortalList(updateTS) - - 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(&discordgo.MessageAck{ - MessageID: string(entry.LastMessageID), - ChannelID: entry.ID, - }) - } - user.ReadStateVersion = r.ReadState.Version - user.Update() - } - - go user.subscribeGuilds(2 * time.Second) - - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) -} - -func (user *User) subscribeGuilds(delay time.Duration) { - if !user.Session.IsUser { - return - } - for _, guildMeta := range user.Session.State.Guilds { - guild := user.bridge.GetGuildByID(guildMeta.ID, false) - if guild != nil && guild.MXID != "" { - user.log.Debug().Str("guild_id", guild.ID).Msg("Subscribing to guild") - dat := discordgo.GuildSubscribeData{ - GuildID: guild.ID, - Typing: true, - Activities: true, - Threads: true, - } - err := user.Session.SubscribeGuild(dat) - if err != nil { - user.log.Warn().Err(err).Str("guild_id", guild.ID).Msg("Failed to subscribe to guild") - } - time.Sleep(delay) - } - } -} - -func (user *User) resumeHandler(_ *discordgo.Resumed) { - user.log.Debug().Msg("Discord connection resumed") - user.subscribeGuilds(0 * time.Second) -} - -func (user *User) addPrivateChannelToSpace(portal *Portal) bool { - if portal.MXID == "" { - return false - } - _, err := user.bridge.Bot.SendStateEvent(user.GetDMSpaceRoom(), event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{user.bridge.AS.HomeserverDomain}, - }) - if err != nil { - user.log.Error().Err(err). - Str("room_id", portal.MXID.String()). - Msg("Failed to add DMM room to user DM space") - return false - } else { - return true - } -} - -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) - if err != nil { - user.log.Error().Err(err). - Str("channel_id", portal.Key.ChannelID). - Msg("Failed to create portal for private channel in create handler") - } - } else { - portal.UpdateInfo(user, meta) - portal.ForwardBackfillMissed(user, meta.LastMessageID, nil) - } - user.MarkInPortal(database.UserPortal{ - DiscordID: portal.Key.ChannelID, - Type: database.UserPortalTypeDM, - Timestamp: timestamp, - InSpace: isInSpace || user.addPrivateChannelToSpace(portal), - }) -} - -func (user *User) addGuildToSpace(guild *Guild, isInSpace bool, timestamp time.Time) bool { - if len(guild.MXID) > 0 && !isInSpace { - _, err := user.bridge.Bot.SendStateEvent(user.GetSpaceRoom(), event.StateSpaceChild, guild.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{user.bridge.AS.HomeserverDomain}, - }) - if err != nil { - user.log.Error().Err(err). - Str("guild_space_id", guild.MXID.String()). - Msg("Failed to add guild space to user space") - } else { - isInSpace = true - } - } - user.MarkInPortal(database.UserPortal{ - DiscordID: guild.ID, - Type: database.UserPortalTypeGuild, - Timestamp: timestamp, - InSpace: isInSpace, - }) - return isInSpace -} - -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() - dbRole.ID = role.ID - dbRole.GuildID = guildID - changed = true - } else { - changed = dbRole.Name != role.Name || - dbRole.Icon != role.Icon || - dbRole.Mentionable != role.Mentionable || - dbRole.Managed != role.Managed || - dbRole.Hoist != role.Hoist || - dbRole.Color != role.Color || - dbRole.Position != role.Position || - dbRole.Permissions != role.Permissions - } - dbRole.Role = *role - if changed { - dbRole.Upsert(txn) - } - return changed -} - -func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) { - existingRoles := user.bridge.DB.Role.GetAll(guildID) - existingRoleMap := make(map[string]*database.Role, len(existingRoles)) - for _, role := range existingRoles { - existingRoleMap[role.ID] = role - } - txn, err := user.bridge.DB.Begin() - if err != nil { - user.log.Error().Err(err).Msg("Failed to start transaction for guild role sync") - panic(err) - } - for _, role := range newRoles { - user.discordRoleToDB(guildID, role, existingRoleMap[role.ID], txn) - delete(existingRoleMap, role.ID) - } - for _, removeRole := range existingRoleMap { - removeRole.Delete(txn) - } - err = txn.Commit() - if err != nil { - user.log.Error().Err(err).Msg("Failed to commit guild role sync transaction") - rollbackErr := txn.Rollback() - if rollbackErr != nil { - user.log.Error().Err(rollbackErr).Msg("Failed to rollback errored guild role sync transaction") - } - panic(err) - } -} - -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 == "" { - err := portal.CreateMatrixRoom(user, ch) - if err != nil { - user.log.Error().Err(err). - Str("guild_id", guild.ID). - Str("channel_id", ch.ID). - Msg("Failed to create portal for guild channel in guild handler") - } - } 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.LastMessageID, nil) - } - } - } - } - if len(meta.Roles) > 0 { - user.handleGuildRoles(meta.ID, meta.Roles) - } - user.addGuildToSpace(guild, isInSpace, timestamp) -} - -func (user *User) connectedHandler(_ *discordgo.Connect) { - user.bridgeStateLock.Lock() - defer user.bridgeStateLock.Unlock() - user.log.Debug().Msg("Connected to Discord") - if user.wasDisconnected { - user.wasDisconnected = false - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - } -} - -func (user *User) disconnectedHandler(_ *discordgo.Disconnect) { - user.bridgeStateLock.Lock() - defer user.bridgeStateLock.Unlock() - if user.wasLoggedOut { - user.log.Debug().Msg("Disconnected from Discord (not updating bridge state as user was just logged out)") - return - } - user.log.Debug().Msg("Disconnected from Discord") - user.wasDisconnected = true - 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.InvalidAuth) { - user.bridgeStateLock.Lock() - defer user.bridgeStateLock.Unlock() - user.log.Info().Msg("Got logged out from Discord due to invalid token") - user.wasLoggedOut = true - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-websocket-disconnect-4004", Message: "Discord access token is no longer valid, please log in again"}) - go user.Logout(false) -} - -func (user *User) guildCreateHandler(g *discordgo.GuildCreate) { - user.log.Info(). - Str("guild_id", g.ID). - Str("name", g.Name). - Bool("unavailable", g.Unavailable). - Msg("Got guild create event") - user.handleGuild(g.Guild, time.Now(), false) -} - -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) - if guild == nil || guild.MXID == "" { - return - } - if user.bridge.Config.Bridge.DeleteGuildOnLeave && !user.PortalHasOtherUsers(g.ID) { - user.log.Debug().Str("guild_id", g.ID).Msg("No other users in guild, cleaning up all portals") - err := user.unbridgeGuild(g.ID) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to unbridge guild that was deleted") - } - } -} - -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) 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). - Msg("Ignoring channel create event in unbridged guild") - return - } - user.log.Info(). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Got channel create event") - portal := user.GetPortalByMeta(c.Channel) - if portal.MXID != "" { - return - } - if c.GuildID == "" { - user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String())) - } else if user.channelIsBridgeable(c.Channel) { - err := portal.CreateMatrixRoom(user, c.Channel) - if err != nil { - user.log.Error().Err(err). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Error creating Matrix room after channel create event") - } - } else { - user.log.Debug(). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Got channel create event, but it's not bridgeable, ignoring") - } -} - -func (user *User) channelDeleteHandler(c *discordgo.ChannelDelete) { - portal := user.GetExistingPortalByID(c.ID) - if portal == nil { - user.log.Debug(). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Ignoring channel delete event of unknown channel") - return - } - user.log.Info(). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Got channel delete event, cleaning up portal") - portal.Delete() - portal.cleanup(!user.bridge.Config.Bridge.DeletePortalOnChannelDelete) - if c.GuildID == "" { - user.MarkNotInPortal(portal.Key.ChannelID) - } - user.log.Debug(). - Str("guild_id", c.GuildID).Str("channel_id", c.ID). - Msg("Completed cleaning up channel") -} - -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())) - } else { - portal.UpdateInfo(user, c.Channel) - } -} - -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 { - return portal, nil - } - thread := user.bridge.GetThreadByID(channelID, nil) - if thread != nil && thread.Parent != nil { - return thread.Parent, thread - } - if !user.Session.IsUser { - channel, _ := user.Session.State.Channel(channelID) - if channel == nil { - user.log.Debug().Str("channel_id", channelID).Msg("Fetching info of unknown channel to handle message") - var err error - channel, err = user.Session.Channel(channelID) - if err != nil { - user.log.Warn().Err(err).Str("channel_id", channelID).Msg("Failed to get info of unknown channel") - } else { - user.log.Debug().Str("channel_id", channelID).Msg("Got info for channel to handle message") - _ = user.Session.State.ChannelAdd(channel) - } - } - if channel != nil && user.channelIsBridgeable(channel) { - user.log.Debug().Str("channel_id", channelID).Msg("Creating portal and updating info to handle message") - portal = user.GetPortalByMeta(channel) - if channel.GuildID == "" { - user.handlePrivateChannel(portal, channel, time.Now(), false, false) - } else { - user.log.Warn(). - Str("channel_id", channel.ID).Str("guild_id", channel.GuildID). - Msg("Unexpected unknown guild channel") - } - return portal, nil - } - } - return nil, nil -} - -func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildID string) { - if user.getGuildBridgingMode(guildID) <= database.GuildBridgeNothing { - // If guild bridging mode is nothing, don't even check if the portal exists - return - } - - portal, thread := user.findPortal(channelID) - if portal == nil { - user.log.Debug(). - Str("discord_event", typeName). - Str("guild_id", guildID). - Str("channel_id", channelID). - Msg("Dropping event in unknown channel") - return - } - if mode := user.getGuildBridgingMode(portal.GuildID); mode <= database.GuildBridgeNothing || (portal.MXID == "" && mode <= database.GuildBridgeIfPortalExists) { - return - } - - wrappedMsg := portalDiscordMessage{ - msg: msg, - user: user, - thread: thread, - } - 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 { - Timestamp int64 `json:"ts,omitempty"` - DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` -} - -type CustomReadMarkers struct { - mautrix.ReqSetReadMarkers - ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"` - FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"` -} - -func (user *User) makeReadMarkerContent(eventID id.EventID) *CustomReadMarkers { - var extra CustomReadReceipt - extra.DoublePuppetSource = user.bridge.Name - return &CustomReadMarkers{ - ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ - Read: eventID, - FullyRead: eventID, - }, - ReadExtra: extra, - FullyReadExtra: extra, - } -} - -func (user *User) messageAckHandler(m *discordgo.MessageAck) { - portal := user.GetExistingPortalByID(m.ChannelID) - if portal == nil || portal.MXID == "" { - return - } - dp := user.GetIDoublePuppet() - if dp == nil { - return - } - msg := user.bridge.DB.Message.GetLastByDiscordID(portal.Key, m.MessageID) - if msg == nil { - user.log.Debug(). - Str("channel_id", m.ChannelID).Str("message_id", m.MessageID). - Msg("Dropping message ack event for unknown message") - return - } - err := dp.CustomIntent().SetReadMarkers(portal.MXID, user.makeReadMarkerContent(msg.MXID)) - if err != nil { - user.log.Error().Err(err). - Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID). - Msg("Failed to mark event as read") - } else { - user.log.Debug(). - Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID). - Msg("Marked event as read after Discord message ack") - if user.ReadStateVersion < m.Version { - user.ReadStateVersion = m.Version - // TODO maybe don't update every time? - user.Update() - } - } -} - -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(s *discordgo.InteractionSuccess) { - user.pendingInteractionsLock.Lock() - defer user.pendingInteractionsLock.Unlock() - ce, ok := user.pendingInteractions[s.Nonce] - if !ok { - user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for unknown interaction") - } else { - user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for pending interaction") - ce.React("✅") - delete(user.pendingInteractions, s.Nonce) - } -} - -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{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipInvite, - IsDirect: isDirect, - }, - Raw: map[string]interface{}{}, - } - - customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - inviteContent.Raw["fi.mau.will_auto_accept"] = true - } - - _, err := intent.SendStateEvent(roomID, event.StateMember, user.MXID.String(), &inviteContent) - - var httpErr mautrix.HTTPError - if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { - user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin) - ret = true - } else if err != nil { - user.log.Error().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room") - } else { - ret = true - } - - if customPuppet != nil && customPuppet.CustomIntent() != nil { - err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) - if err != nil { - user.log.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room") - ret = false - } else { - ret = true - } - } - - return ret -} - -func (user *User) getDirectChats() map[id.UserID][]id.RoomID { - chats := map[id.UserID][]id.RoomID{} - - privateChats := user.bridge.DB.Portal.FindPrivateChatsOf(user.DiscordID) - for _, portal := range privateChats { - if portal.MXID != "" { - puppetMXID := user.bridge.FormatPuppetMXID(portal.Key.Receiver) - - chats[puppetMXID] = []id.RoomID{portal.MXID} - } - } - - return chats -} - -func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) { - if !user.bridge.Config.Bridge.SyncDirectChatList { - return - } - - puppet := user.bridge.GetPuppetByMXID(user.MXID) - if puppet == nil { - return - } - - intent := puppet.CustomIntent() - if intent == nil { - return - } - - method := http.MethodPatch - if chats == nil { - chats = user.getDirectChats() - method = http.MethodPut - } - - user.log.Debug().Msg("Updating m.direct list on homeserver") - - var err error - if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux { - urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"}) - _, err = intent.MakeFullRequest(mautrix.FullRequest{ - Method: method, - URL: urlPath, - Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}}, - RequestJSON: chats, - }) - } else { - existingChats := map[id.UserID][]id.RoomID{} - - err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to get m.direct event to update it") - return - } - - for userID, rooms := range existingChats { - if _, ok := user.bridge.ParsePuppetMXID(userID); !ok { - // This is not a ghost user, include it in the new list - chats[userID] = rooms - } else if _, ok := chats[userID]; !ok && method == http.MethodPatch { - // This is a ghost user, but we're not replacing the whole list, so include it too - chats[userID] = rooms - } - } - - err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats) - } - - if err != nil { - user.log.Warn().Err(err).Msg("Failed to update m.direct event") - } -} - -func (user *User) bridgeGuild(guildID string, everything bool) error { - guild := user.bridge.GetGuildByID(guildID, false) - if guild == nil { - return errors.New("guild not found") - } - meta, _ := user.Session.State.Guild(guildID) - err := guild.CreateMatrixRoom(user, meta) - if err != nil { - return err - } - log := user.log.With().Str("guild_id", guild.ID).Logger() - user.addGuildToSpace(guild, false, time.Now()) - for _, ch := range meta.Channels { - portal := user.GetPortalByMeta(ch) - if (everything && user.channelIsBridgeable(ch)) || ch.Type == discordgo.ChannelTypeGuildCategory { - err = portal.CreateMatrixRoom(user, ch) - if err != nil { - log.Error().Err(err).Str("channel_id", ch.ID). - Msg("Failed to create room for guild channel while bridging guild") - } - } - } - if everything { - guild.BridgingMode = database.GuildBridgeEverything - } else { - guild.BridgingMode = database.GuildBridgeCreateOnMessage - } - guild.Update() - - if user.Session.IsUser { - log.Debug().Msg("Subscribing to guild after bridging") - err = user.Session.SubscribeGuild(discordgo.GuildSubscribeData{ - GuildID: guild.ID, - Typing: true, - Activities: true, - Threads: true, - }) - if err != nil { - log.Warn().Err(err).Msg("Failed to subscribe to guild") - } - } - - return nil -} - -func (user *User) unbridgeGuild(guildID string) error { - if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin && user.PortalHasOtherUsers(guildID) { - return errors.New("only bridge admins can unbridge guilds with other users") - } - guild := user.bridge.GetGuildByID(guildID, false) - if guild == nil { - return errors.New("guild not found") - } - guild.roomCreateLock.Lock() - defer guild.roomCreateLock.Unlock() - if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" { - return errors.New("that guild is not bridged") - } - guild.BridgingMode = database.GuildBridgeNothing - guild.Update() - for _, portal := range user.bridge.GetAllPortalsInGuild(guild.ID) { - portal.cleanup(false) - portal.RemoveMXID() - } - guild.cleanup() - guild.RemoveMXID() - return nil -} From 2182c0d38fe714efcd173895aab08b900829229c Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Tue, 28 Oct 2025 08:39:43 -0700 Subject: [PATCH 03/99] Only send `CONNECTED` bridge state on `READY` or `RESUMED` (#199) --- user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user.go b/user.go index f209b33..2dd727a 100644 --- a/user.go +++ b/user.go @@ -807,6 +807,7 @@ func (user *User) subscribeGuilds(delay time.Duration) { func (user *User) resumeHandler(_ *discordgo.Resumed) { user.log.Debug().Msg("Discord connection resumed") user.subscribeGuilds(0 * time.Second) + user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } func (user *User) addPrivateChannelToSpace(portal *Portal) bool { @@ -1007,7 +1008,6 @@ func (user *User) connectedHandler(_ *discordgo.Connect) { user.log.Debug().Msg("Connected to Discord") if user.wasDisconnected { user.wasDisconnected = false - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } } From c8c00a42bba772b72c5055687641fa7fb182446e Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Thu, 30 Oct 2025 08:06:21 -0700 Subject: [PATCH 04/99] Bump discordgo (#200) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3cfd6aa..5cb664e 100644 --- a/go.mod +++ b/go.mod @@ -42,4 +42,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd diff --git a/go.sum b/go.sum index 51356d2..a16ce05 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd h1:RXB0a8lTNN9vB838lZXm11inXwvILpOzXi3j978P8RE= +github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From 9eaf213091efddcfd68e7dad2952db08317c416c Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Wed, 19 Nov 2025 11:10:08 -0800 Subject: [PATCH 05/99] user: send `errUserNotLoggedIn` if we can't bridge event from logged-out user (#204) --- portal.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/portal.go b/portal.go index 3a5db83..0d860d6 100644 --- a/portal.go +++ b/portal.go @@ -1522,9 +1522,15 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return + if portal.IsPrivateChat() { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + if sender.DiscordID != portal.Key.Receiver { + go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") + return + } } content, ok := evt.Content.Parsed.(*event.MessageEventContent) @@ -1918,12 +1924,13 @@ func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { } func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return - } else if !sender.IsLoggedIn() { - //go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring") - return } reaction := evt.Content.AsReaction() @@ -2101,9 +2108,15 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess } func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return + if portal.IsPrivateChat() { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + if sender.DiscordID != portal.Key.Receiver { + go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") + return + } } sess := sender.Session From d7292a0706f787a84537ee5a281c3652543efbf4 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Wed, 19 Nov 2025 14:33:26 -0800 Subject: [PATCH 06/99] bump discordgo and add support for heartbeat sessions (#203) --- database/json.go | 20 +++++++++++++ database/upgrades/00-latest-revision.sql | 5 ++-- .../upgrades/24-user-heartbeat-session.sql | 2 ++ database/user.go | 30 ++++++++++--------- go.mod | 3 +- go.sum | 6 ++-- user.go | 16 +++++++++- 7 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 database/json.go create mode 100644 database/upgrades/24-user-heartbeat-session.sql diff --git a/database/json.go b/database/json.go new file mode 100644 index 0000000..566a6c4 --- /dev/null +++ b/database/json.go @@ -0,0 +1,20 @@ +package database + +import ( + "go.mau.fi/util/dbutil" +) + +// Backported from mautrix/go-util@e5cb5e96d15cb87ffe6e5970c2f90ee47980e715. + +// JSONPtr is a convenience function for wrapping a pointer to a value in the JSON utility, but removing typed nils +// (i.e. preventing nils from turning into the string "null" in the database). +func JSONPtr[T any](val *T) dbutil.JSON { + return dbutil.JSON{Data: UntypedNil(val)} +} + +func UntypedNil[T any](val *T) any { + if val == nil { + return nil + } + return val +} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql index 46fbb73..d794530 100644 --- a/database/upgrades/00-latest-revision.sql +++ b/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v23 (compatible with v19+): Latest revision +-- v0 -> v24 (compatible with v19+): Latest revision CREATE TABLE guild ( dcid TEXT PRIMARY KEY, @@ -92,7 +92,8 @@ CREATE TABLE "user" ( space_room TEXT, dm_space_room TEXT, - read_state_version INTEGER NOT NULL DEFAULT 0 + read_state_version INTEGER NOT NULL DEFAULT 0, + heartbeat_session jsonb ); CREATE TABLE user_portal ( diff --git a/database/upgrades/24-user-heartbeat-session.sql b/database/upgrades/24-user-heartbeat-session.sql new file mode 100644 index 0000000..ccb44f9 --- /dev/null +++ b/database/upgrades/24-user-heartbeat-session.sql @@ -0,0 +1,2 @@ +-- v24 (compatible with v19+): Add persisted heartbeat sessions +ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb; diff --git a/database/user.go b/database/user.go index 763625d..eff661b 100644 --- a/database/user.go +++ b/database/user.go @@ -3,6 +3,7 @@ package database import ( "database/sql" + "github.com/bwmarrin/discordgo" "go.mau.fi/util/dbutil" log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/id" @@ -21,18 +22,18 @@ func (uq *UserQuery) New() *User { } func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE mxid=$1` return uq.New().Scan(uq.db.QueryRow(query, userID)) } func (uq *UserQuery) GetByID(id string) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE dcid=$1` return uq.New().Scan(uq.db.QueryRow(query, id)) } func (uq *UserQuery) GetAllWithToken() []*User { query := ` - SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version + SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE discord_token IS NOT NULL ` rows, err := uq.db.Query(query) @@ -54,19 +55,20 @@ type User struct { db *Database log log.Logger - MXID id.UserID - DiscordID string - DiscordToken string - ManagementRoom id.RoomID - SpaceRoom id.RoomID - DMSpaceRoom id.RoomID + MXID id.UserID + DiscordID string + DiscordToken string + ManagementRoom id.RoomID + SpaceRoom id.RoomID + DMSpaceRoom id.RoomID + HeartbeatSession *discordgo.HeartbeatSession ReadStateVersion int } func (u *User) Scan(row dbutil.Scannable) *User { var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString - err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion) + err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession}) if err != nil { if err != sql.ErrNoRows { u.log.Errorln("Database scan failed:", err) @@ -83,8 +85,8 @@ func (u *User) Scan(row dbutil.Scannable) *User { } func (u *User) Insert() { - query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion) + query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession)) if err != nil { u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) panic(err) @@ -92,8 +94,8 @@ func (u *User) Insert() { } func (u *User) Update() { - query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7` - _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID) + query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6, heartbeat_session=$7 WHERE mxid=$8` + _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession), u.MXID) if err != nil { u.log.Warnfln("Failed to update %q: %v", u.MXID, err) panic(err) diff --git a/go.mod b/go.mod index 5cb664e..255ce83 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -42,4 +43,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec diff --git a/go.sum b/go.sum index a16ce05..aeac7db 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd h1:RXB0a8lTNN9vB838lZXm11inXwvILpOzXi3j978P8RE= -github.com/beeper/discordgo v0.0.0-20251029151721-c53d6229e2fd/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -11,6 +11,8 @@ github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFA github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= diff --git a/user.go b/user.go index 2dd727a..b5ff572 100644 --- a/user.go +++ b/user.go @@ -550,6 +550,17 @@ func (user *User) Connect() error { if err != nil { return err } + + if user.HeartbeatSession == nil || user.HeartbeatSession.IsExpired() { + user.log.Debug().Msg("Creating new heartbeat session") + sess := discordgo.NewHeartbeatSession() + user.HeartbeatSession = &sess + } + user.HeartbeatSession.BumpLastUsed() + user.Update() + // make discordgo use our session instead of the one it creates automatically + session.HeartbeatSession = *user.HeartbeatSession + if user.bridge.Config.Bridge.Proxy != "" { u, _ := url.Parse(user.bridge.Config.Bridge.Proxy) tlsConf := &tls.Config{ @@ -569,7 +580,10 @@ func (user *User) Connect() error { } else { session.LogLevel = discordgo.LogInformational } - userDiscordLog := user.log.With().Str("component", "discordgo").Logger() + userDiscordLog := user.log.With(). + Str("component", "discordgo"). + Str("heartbeat_session", session.HeartbeatSession.ID.String()). + Logger() session.Logger = func(msgL, caller int, format string, a ...interface{}) { userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf } From fed9bc7655e2abd3acdc6effb05c855d1ce03e19 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 20 Nov 2025 10:12:52 +0200 Subject: [PATCH 07/99] Set correct values for GetName --- pkg/connector/connector.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 241d37f..bb8bf05 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -38,6 +38,12 @@ func (d *DiscordConnector) Start(ctx context.Context) error { } func (d *DiscordConnector) GetName() bridgev2.BridgeName { - //TODO implement me - panic("implement me") + return bridgev2.BridgeName{ + DisplayName: "Discord", + NetworkURL: "https://discord.com", + NetworkIcon: "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC", + NetworkID: "discord", + BeeperBridgeType: "discordgo", + DefaultPort: 29334, + } } From cda3a84ea5f8268f2403476df5008b70a4dfc245 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 20 Nov 2025 10:18:03 +0200 Subject: [PATCH 08/99] ci: fix lint job --- .github/workflows/go.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8f6875c..ae5f5aa 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,6 +2,9 @@ name: Go on: [push, pull_request] +env: + GOTOOLCHAIN: local + jobs: lint: runs-on: ubuntu-latest @@ -29,8 +32,5 @@ jobs: go install honnef.co/go/tools/cmd/staticcheck@latest export PATH="$HOME/go/bin:$PATH" - - name: Install pre-commit - run: pip install pre-commit - - - name: Lint - run: pre-commit run -a + - name: Run pre-commit + uses: pre-commit/action@v3.0.1 From e0e18d78226733f44fa615a05e577c8174e02903 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 10:27:34 -0800 Subject: [PATCH 09/99] connector/config: impl --- pkg/connector/config.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 9b0fa42..d40198c 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -21,6 +21,5 @@ import ( ) func (d *DiscordConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { - //TODO implement me - panic("implement me") + return "", nil, configupgrade.NoopUpgrader } From 586cb2bfe63b919bfaa11e75752af9324d343b8a Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 10:28:43 -0800 Subject: [PATCH 10/99] initial pass at wiring up login to discordgo --- go.mod | 3 +- go.sum | 6 +- pkg/connector/client.go | 112 +++++++++++++++++++++++++++--- pkg/connector/connector.go | 7 +- pkg/connector/dbmeta.go | 15 +++- pkg/connector/login.go | 137 +++++++++++++++++++++++++++++++++++-- pkg/connector/userinfo.go | 5 +- 7 files changed, 259 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index d5ef0f8..7d80acc 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -40,4 +41,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec diff --git a/go.sum b/go.sum index 85e2dd4..3f4e757 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -11,6 +11,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 5be113b..b55f0fc 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -18,36 +18,128 @@ package connector import ( "context" + "errors" + "fmt" + "time" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" ) type DiscordClient struct { + UserLogin *bridgev2.UserLogin + Session *discordgo.Session } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { - //TODO implement me - panic("implement me") + meta := login.Metadata.(*UserLoginMetadata) + + session, err := discordgo.New(meta.Token) + if meta.HeartbeatSession.IsExpired() { + log.Ctx(ctx).Info().Msg("Heartbeat session expired, creating a new one") + meta.HeartbeatSession = discordgo.NewHeartbeatSession() + } + meta.HeartbeatSession.BumpLastUsed() + session.HeartbeatSession = meta.HeartbeatSession + login.Save(ctx) + + if err != nil { + return err + } + + // FIXME(skip): Implement. + session.EventHandler = func(evt any) {} + + login.Client = &DiscordClient{ + UserLogin: login, + Session: session, + } + + return nil } var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) func (d *DiscordClient) Connect(ctx context.Context) { - //TODO implement me - panic("implement me") + log := zerolog.Ctx(ctx) + + if d.Session == nil { + log.Error().Msg("No session present") + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "discord-not-logged-in", + }) + return + } + + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnecting, + }) + if err := d.connect(ctx); err != nil { + log.Err(err).Msg("Couldn't connect to Discord") + } + // TODO(skip): Use event handler and send this in response to READY/RESUMED instead? + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnected, + }) +} + +func (cl *DiscordClient) connect(ctx context.Context) error { + log := log.Ctx(ctx) + log.Info().Msg("Opening session") + + err := cl.Session.Open() + for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 { + log.Err(err).Int("attempts", attempts).Msg("Immediately disconnected while trying to open session, trying again in 5 seconds") + time.Sleep(5 * time.Second) + err = cl.Session.Open() + } + if err != nil { + log.Err(err).Msg("Failed to connect to Discord") + return err + } + + // Ensure that we actually have a user. + if !cl.IsLoggedIn() { + return fmt.Errorf("unknown identity even after connecting to Discord") + } + user := cl.Session.State.User + log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord") + + if cl.UserLogin != nil { + // Feels a bit hacky to check for this here, but it should be true when + // logging in initially. The UserLogin is only ever created if we know + // that we connected successfully. We _do_ know that by now here, but we're + // not tasked with creating the UserLogin; the login code is. Alas. + + // FIXME(skip): Avatar. + cl.UserLogin.RemoteProfile = status.RemoteProfile{ + Email: user.Email, + Phone: user.Phone, + Name: user.String(), + } + if err := cl.UserLogin.Save(ctx); err != nil { + log.Err(err).Msg("Couldn't save UserLogin after connecting") + } + } + + return nil } func (d *DiscordClient) Disconnect() { - //TODO implement me - panic("implement me") + log.Debug().Msg("Disconnecting session") + d.Session.Close() + d.Session = nil } func (d *DiscordClient) IsLoggedIn() bool { - //TODO implement me - panic("implement me") + return d.Session != nil && d.Session.State != nil && d.Session.State.User != nil && d.Session.State.User.ID != "" } func (d *DiscordClient) LogoutRemote(ctx context.Context) { - //TODO implement me - panic("implement me") + // FIXME(skip): Implement. + d.Disconnect() } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index bb8bf05..e3a16d8 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -23,18 +23,17 @@ import ( ) type DiscordConnector struct { + bridge *bridgev2.Bridge } var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { - //TODO implement me - panic("implement me") + d.bridge = bridge } func (d *DiscordConnector) Start(ctx context.Context) error { - //TODO implement me - panic("implement me") + return nil } func (d *DiscordConnector) GetName() bridgev2.BridgeName { diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index f397ec4..903e99a 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -17,10 +17,19 @@ package connector import ( + "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2/database" ) -func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { - //TODO implement me - panic("implement me") +type UserLoginMetadata struct { + Token string `json:"token"` + HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` +} + +func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { + return database.MetaTypes{ + UserLogin: func() any { + return &UserLoginMetadata{} + }, + } } diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 71ee401..8802642 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -18,16 +18,145 @@ package connector import ( "context" + "fmt" + "strings" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const ( + LoginFlowIDToken = "fi.mau.discord.login.token" ) func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { - //TODO implement me - panic("implement me") + // FIXME(skip): Provide actually user-friendly login flows. + return []bridgev2.LoginFlow{ + { + ID: LoginFlowIDToken, + Name: "Token", + Description: "Provide a Discord user token to connect with.", + }, + } } func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - //TODO implement me - panic("implement me") + if flowID != LoginFlowIDToken { + return nil, fmt.Errorf("unknown login flow ID") + } + + return &DiscordLogin{User: user}, nil +} + +type DiscordLogin struct { + User *bridgev2.User + Token string + Session *discordgo.Session +} + +var _ bridgev2.LoginProcessUserInput = (*DiscordLogin)(nil) + +func (dl *DiscordLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: "fi.mau.discord.enter_token", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "token", + Name: "Discord user account token", + // Cribbed from https://regex101.com/r/1GMR0y/1. + Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`, + }, + }, + }, + }, nil +} + +func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + token := input["token"] + if token == "" { + return nil, fmt.Errorf("no token provided") + } + + log := zerolog.Ctx(ctx) + + log.Info().Msg("Creating session from provided token") + dl.Token = token + + session, err := discordgo.New(token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + // FIXME(skip): Implement. + session.EventHandler = func(evt any) {} + + // Set up logging. + session.LogLevel = discordgo.LogInformational + session.Logger = func(msgL, caller int, format string, a ...any) { + // FIXME(skip): Hook up zerolog properly. + log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf + } + + cl := DiscordClient{ + Session: session, + } + err = cl.connect(ctx) + if err != nil { + dl.softlyCloseSession() + return nil, err + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + user := session.State.User + + dl.Session = session + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.ID), + Metadata: &UserLoginMetadata{ + Token: token, + HeartbeatSession: session.HeartbeatSession, + }, + }, &bridgev2.NewLoginParams{ + // We already have a Session; call this instead of the connector's main LoadUserLogin method and thread the Session through. + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &cl + return nil + }, + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + dl.softlyCloseSession() + return nil, fmt.Errorf("couldn't create login: %w", err) + } + zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: "fi.mau.discord.complete", + Instructions: fmt.Sprintf("Logged in as %s", user), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (dl *DiscordLogin) softlyCloseSession() { + log.Debug().Msg("Closing session") + err := dl.Session.Close() + if err != nil { + log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + } +} + +func (dl *DiscordLogin) Cancel() { + dl.softlyCloseSession() } diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index cdf0ee4..bedb53f 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -24,8 +24,9 @@ import ( ) func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { - //TODO implement me - panic("implement me") + // We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map + // directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison. + return userID == networkid.UserID(d.UserLogin.ID) } func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { From bc13724b0a0d2d7324f221f6dacd25ed7633e1d2 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 11:14:48 -0800 Subject: [PATCH 11/99] use `zerolog`/local loggers instead of `zerolog/log` --- pkg/connector/client.go | 8 ++++---- pkg/connector/login.go | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index b55f0fc..b65cdb4 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -24,7 +24,6 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/status" ) @@ -35,11 +34,12 @@ type DiscordClient struct { } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { + log := login.Log meta := login.Metadata.(*UserLoginMetadata) session, err := discordgo.New(meta.Token) if meta.HeartbeatSession.IsExpired() { - log.Ctx(ctx).Info().Msg("Heartbeat session expired, creating a new one") + log.Info().Msg("Heartbeat session expired, creating a new one") meta.HeartbeatSession = discordgo.NewHeartbeatSession() } meta.HeartbeatSession.BumpLastUsed() @@ -88,7 +88,7 @@ func (d *DiscordClient) Connect(ctx context.Context) { } func (cl *DiscordClient) connect(ctx context.Context) error { - log := log.Ctx(ctx) + log := zerolog.Ctx(ctx) log.Info().Msg("Opening session") err := cl.Session.Open() @@ -130,7 +130,7 @@ func (cl *DiscordClient) connect(ctx context.Context) error { } func (d *DiscordClient) Disconnect() { - log.Debug().Msg("Disconnecting session") + d.UserLogin.Log.Info().Msg("Disconnecting session") d.Session.Close() d.Session = nil } diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 8802642..f8992bd 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -23,7 +23,6 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -150,10 +149,10 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st } func (dl *DiscordLogin) softlyCloseSession() { - log.Debug().Msg("Closing session") + dl.User.Log.Debug().Msg("Closing session") err := dl.Session.Close() if err != nil { - log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") } } From 4e41c2f2270e737f9363bf551e6714f63dc17e6c Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 12:41:40 -0800 Subject: [PATCH 12/99] sync private channels and their members --- pkg/connector/client.go | 61 ++++++++++++++++++++++++++++++++++-- pkg/connector/events.go | 65 +++++++++++++++++++++++++++++++++++++++ pkg/connector/id.go | 30 ++++++++++++++++++ pkg/connector/userinfo.go | 25 +++++++++++++-- 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 pkg/connector/events.go create mode 100644 pkg/connector/id.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index b65cdb4..b4a2099 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -25,12 +25,16 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" ) type DiscordClient struct { - UserLogin *bridgev2.UserLogin - Session *discordgo.Session + connector *DiscordConnector + usersFromReady map[string]*discordgo.User + UserLogin *bridgev2.UserLogin + Session *discordgo.Session } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -54,6 +58,7 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us session.EventHandler = func(evt any) {} login.Client = &DiscordClient{ + connector: d, UserLogin: login, Session: session, } @@ -126,6 +131,15 @@ func (cl *DiscordClient) connect(ctx context.Context) error { } } + // Stash all of the users we received in READY so we can perform quick lookups + // keyed by user ID. + cl.usersFromReady = make(map[string]*discordgo.User) + for _, user := range cl.Session.State.Ready.Users { + cl.usersFromReady[user.ID] = user + } + + go cl.syncChannels(ctx) + return nil } @@ -143,3 +157,46 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) { // FIXME(skip): Implement. d.Disconnect() } + +func (d *DiscordClient) syncChannels(ctx context.Context) { + for _, dm := range d.Session.State.PrivateChannels { + d.UserLogin.Log.Debug().Str("channel_id", dm.ID).Msg("Syncing private channel") + d.syncChannel(ctx, dm) + } +} + +func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel) { + isGroup := len(ch.RecipientIDs) > 1 + + var roomType database.RoomType + if isGroup { + roomType = database.RoomTypeGroupDM + } else { + roomType = database.RoomTypeDM + } + + var members bridgev2.ChatMemberList + members.IsFull = true + members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) + if len(ch.Recipients) > 0 { + for _, recipient := range ch.Recipients { + sender := bridgev2.EventSender{ + IsFromMe: recipient.ID == d.Session.State.User.ID, + SenderLogin: d.UserLogin.ID, + Sender: networkid.UserID(recipient.ID), + } + members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} + } + members.TotalMemberCount = len(ch.Recipients) + } + + d.connector.bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ + channel: ch, + portalKey: d.makePortalKey(ch, d.UserLogin.ID, true), + info: &bridgev2.ChatInfo{ + Name: &ch.Name, + Members: &members, + Type: &roomType, + }, + }) +} diff --git a/pkg/connector/events.go b/pkg/connector/events.go new file mode 100644 index 0000000..ad4cca9 --- /dev/null +++ b/pkg/connector/events.go @@ -0,0 +1,65 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type DiscordChatResync struct { + channel *discordgo.Channel + portalKey networkid.PortalKey + info *bridgev2.ChatInfo +} + +var ( + _ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil) + _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil) +) + +func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context { + c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type)) + return c +} + +func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey { + return d.portalKey +} + +func (d *DiscordChatResync) GetSender() bridgev2.EventSender { + return bridgev2.EventSender{} +} + +func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType { + return bridgev2.RemoteEventChatResync +} + +func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + if d.info == nil { + return nil, nil + } + return d.info, nil +} + +func (d *DiscordChatResync) ShouldCreatePortal() bool { + return true +} diff --git a/pkg/connector/id.go b/pkg/connector/id.go new file mode 100644 index 0000000..7376061 --- /dev/null +++ b/pkg/connector/id.go @@ -0,0 +1,30 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "github.com/bwmarrin/discordgo" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +func (d *DiscordClient) makePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { + key.ID = networkid.PortalID(ch.ID) + if wantReceiver { + key.Receiver = userLoginID + } + return +} diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index bedb53f..bd09c1c 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -18,7 +18,10 @@ package connector import ( "context" + "fmt" + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -30,6 +33,24 @@ func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) } func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { - //TODO implement me - panic("implement me") + log := zerolog.Ctx(ctx) + + if ghost.ID == "" { + log.Warn().Msg("Tried to get user info for ghost with no ID") + return nil, nil + } + + // FIXME(skip): This won't work for users in guilds. + + user, ok := d.usersFromReady[string(ghost.ID)] + if !ok { + log.Error().Str("ghost_id", string(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost") + return nil, nil + } + + return &bridgev2.UserInfo{ + Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)}, + Name: ptr.Ptr(user.DisplayName()), + IsBot: &user.Bot, + }, nil } From f04a8658d9811ecd4a9611b5a9863fe4ca97d21e Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 13:03:40 -0800 Subject: [PATCH 13/99] always add self when creating DM portals Clients will leave rooms automatically when they realize that they aren't actually members of the channel. --- pkg/connector/client.go | 17 ++++++++++------- pkg/connector/id.go | 9 +++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index b4a2099..d1a21ec 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -161,11 +161,11 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) { func (d *DiscordClient) syncChannels(ctx context.Context) { for _, dm := range d.Session.State.PrivateChannels { d.UserLogin.Log.Debug().Str("channel_id", dm.ID).Msg("Syncing private channel") - d.syncChannel(ctx, dm) + d.syncChannel(ctx, dm, true) } } -func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel) { +func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 var roomType database.RoomType @@ -175,18 +175,21 @@ func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel) roomType = database.RoomTypeDM } + selfEventSender := d.makeEventSender(d.Session.State.User) + var members bridgev2.ChatMemberList members.IsFull = true members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) if len(ch.Recipients) > 0 { + if selfIsInChannel { + members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} + } + for _, recipient := range ch.Recipients { - sender := bridgev2.EventSender{ - IsFromMe: recipient.ID == d.Session.State.User.ID, - SenderLogin: d.UserLogin.ID, - Sender: networkid.UserID(recipient.ID), - } + sender := d.makeEventSender(recipient) members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} } + members.TotalMemberCount = len(ch.Recipients) } diff --git a/pkg/connector/id.go b/pkg/connector/id.go index 7376061..76e4486 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -18,6 +18,7 @@ package connector import ( "github.com/bwmarrin/discordgo" + "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -28,3 +29,11 @@ func (d *DiscordClient) makePortalKey(ch *discordgo.Channel, userLoginID network } return } + +func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { + return bridgev2.EventSender{ + IsFromMe: user.ID == d.Session.State.User.ID, + SenderLogin: d.UserLogin.ID, + Sender: networkid.UserID(user.ID), + } +} From 1442b356f2c738a6fb5a9441f4abaf2bbf5867be Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 13:03:40 -0800 Subject: [PATCH 14/99] sync channel avatars --- pkg/connector/client.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index d1a21ec..3554a1c 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -20,6 +20,8 @@ import ( "context" "errors" "fmt" + "io" + "net/http" "time" "github.com/bwmarrin/discordgo" @@ -165,6 +167,36 @@ func (d *DiscordClient) syncChannels(ctx context.Context) { } } +func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to prepare request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download %s: %w", thing, err) + } + + data, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read %s data: %w", thing, err) + } + return data, nil +} + +func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { + return &bridgev2.Avatar{ + ID: networkid.AvatarID(ch.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) + return simpleDownload(ctx, url, "group dm icon") + }, + Remove: ch.Icon == "", + } +} + func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 @@ -199,6 +231,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se info: &bridgev2.ChatInfo{ Name: &ch.Name, Members: &members, + Avatar: makeChannelAvatar(ch), Type: &roomType, }, }) From 91edeb6054c12de000db943b573def0eec95a7e5 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 13:22:32 -0800 Subject: [PATCH 15/99] connector/userinfo: implement user avatars --- pkg/connector/userinfo.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index bd09c1c..9be88ed 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" @@ -32,6 +33,17 @@ func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) return userID == networkid.UserID(d.UserLogin.ID) } +func makeUserAvatar(u *discordgo.User) *bridgev2.Avatar { + url := u.AvatarURL("256") + + return &bridgev2.Avatar{ + ID: networkid.AvatarID(url), + Get: func(ctx context.Context) ([]byte, error) { + return simpleDownload(ctx, url, "user avatar") + }, + } +} + func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { log := zerolog.Ctx(ctx) @@ -51,6 +63,7 @@ func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) return &bridgev2.UserInfo{ Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)}, Name: ptr.Ptr(user.DisplayName()), + Avatar: makeUserAvatar(user), IsBot: &user.Bot, }, nil } From 1c599a33bc1270d1c35b1a3a1751e92247839cd0 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 13:22:32 -0800 Subject: [PATCH 16/99] add aggressive info updating for dev --- pkg/connector/capabilities.go | 5 +++++ pkg/connector/client.go | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 4728513..90c5e2f 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -24,7 +24,12 @@ import ( "maunium.net/go/mautrix/event" ) +// Whether to aggressively update user info. Only relevant during initial development +// of this bridge. +var aggressivelyUpdateInfoForBridgeDevelopment = true + var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{ + AggressiveUpdateInfo: aggressivelyUpdateInfoForBridgeDevelopment, Provisioning: bridgev2.ProvisioningCapabilities{ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{}, GroupCreation: map[string]bridgev2.GroupTypeCapabilities{}, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 3554a1c..8bcc8ee 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -197,7 +197,7 @@ func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { } } -func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { +func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 var roomType database.RoomType @@ -220,6 +220,12 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se for _, recipient := range ch.Recipients { sender := d.makeEventSender(recipient) members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} + + if aggressivelyUpdateInfoForBridgeDevelopment { + if ghost, err := d.connector.bridge.GetGhostByID(ctx, networkid.UserID(recipient.ID)); err == nil { + ghost.UpdateInfoIfNecessary(ctx, d.UserLogin, bridgev2.RemoteEventUnknown) + } + } } members.TotalMemberCount = len(ch.Recipients) From aecc5234e64cf353a4564238bed23ae5312c9d0e Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 15:07:21 -0800 Subject: [PATCH 17/99] remove test aggressive updates --- pkg/connector/capabilities.go | 5 ----- pkg/connector/client.go | 6 ------ 2 files changed, 11 deletions(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 90c5e2f..4728513 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -24,12 +24,7 @@ import ( "maunium.net/go/mautrix/event" ) -// Whether to aggressively update user info. Only relevant during initial development -// of this bridge. -var aggressivelyUpdateInfoForBridgeDevelopment = true - var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{ - AggressiveUpdateInfo: aggressivelyUpdateInfoForBridgeDevelopment, Provisioning: bridgev2.ProvisioningCapabilities{ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{}, GroupCreation: map[string]bridgev2.GroupTypeCapabilities{}, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8bcc8ee..8067914 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -220,12 +220,6 @@ func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel, for _, recipient := range ch.Recipients { sender := d.makeEventSender(recipient) members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} - - if aggressivelyUpdateInfoForBridgeDevelopment { - if ghost, err := d.connector.bridge.GetGhostByID(ctx, networkid.UserID(recipient.ID)); err == nil { - ghost.UpdateInfoIfNecessary(ctx, d.UserLogin, bridgev2.RemoteEventUnknown) - } - } } members.TotalMemberCount = len(ch.Recipients) From c15fd3fc8274f7b14969fe21e8de00e86b9b5792 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 15:07:21 -0800 Subject: [PATCH 18/99] implement naive, incomplete backfill --- pkg/connector/backfill.go | 129 ++++++++++++++++++++++++++++++++++++++ pkg/connector/events.go | 9 +++ 2 files changed, 138 insertions(+) create mode 100644 pkg/connector/backfill.go diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go new file mode 100644 index 0000000..28bfafa --- /dev/null +++ b/pkg/connector/backfill.go @@ -0,0 +1,129 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "slices" + "strconv" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +var ( + _ bridgev2.BackfillingNetworkAPI = (*DiscordClient)(nil) +) + +func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + if !dc.IsLoggedIn() { + return nil, bridgev2.ErrNotLoggedIn + } + + channelID := string(fetchParams.Portal.ID) + log := zerolog.Ctx(ctx).With(). + Str("channel_id", channelID). + Int("desired_count", fetchParams.Count). + Bool("forward", fetchParams.Forward).Logger() + + var beforeID string + var afterID string + + if fetchParams.AnchorMessage != nil { + anchorID := string(fetchParams.AnchorMessage.ID) + + if fetchParams.Forward { + afterID = anchorID + } else { + beforeID = anchorID + } + } + + // ChannelMessages returns messages ordered from newest to oldest. + count := min(fetchParams.Count, 100) + log.Debug().Msg("Fetching channel history for backfill") + msgs, err := dc.Session.ChannelMessages(channelID, count, beforeID, afterID, "") + if err != nil { + return nil, err + } + + converted := make([]*bridgev2.BackfillMessage, 0, len(msgs)) + for _, msg := range msgs { + streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) + ts, _ := discordgo.SnowflakeTimestamp(msg.ID) + + // FIXME(skip): Backfill reactions. + + converted = append(converted, &bridgev2.BackfillMessage{ + ConvertedMessage: dc.convertMessage(msg), + Sender: dc.makeEventSender(msg.Author), + Timestamp: ts, + StreamOrder: streamOrder, + }) + } + // FetchMessagesResponse expects messages to always be ordered from oldest to newest. + slices.Reverse(converted) + + log.Debug().Int("converted_count", len(converted)).Msg("Finished fetching and converting, returning backfill response") + + return &bridgev2.FetchMessagesResponse{ + Messages: converted, + Forward: fetchParams.Forward, + // This might not actually be true if the channel's total number of messages is itself a multiple + // of `count`, but that's probably okay. + HasMore: len(msgs) == count, + }, nil +} + +func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.ConvertedMessage { + // FIXME(skip): This isn't bridging a lot of things (replies, forwards, voice messages, attachments, webhooks, embeds, etc.). Copy from main branch. + + var parts []*bridgev2.ConvertedMessagePart + switch msg.Type { + case discordgo.MessageTypeCall: + parts = append(parts, &bridgev2.ConvertedMessagePart{ + Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "started a call", + }, + }) + case discordgo.MessageTypeGuildMemberJoin: + parts = append(parts, &bridgev2.ConvertedMessagePart{ + Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "joined the server", + }, + }) + } + + if msg.Content != "" { + // FIXME(skip): This needs to render into HTML. + parts = append(parts, &bridgev2.ConvertedMessagePart{ + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: msg.Content, + }, + }) + } + + return &bridgev2.ConvertedMessage{ + // TODO(skip): Replies. + Parts: parts, + } +} diff --git a/pkg/connector/events.go b/pkg/connector/events.go index ad4cca9..631bb06 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -22,6 +22,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -33,6 +34,7 @@ type DiscordChatResync struct { var ( _ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil) + _ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil) _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil) ) @@ -63,3 +65,10 @@ func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Po func (d *DiscordChatResync) ShouldCreatePortal() bool { return true } + +func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) { + if latestBridged == nil { + return true, nil + } + return latestBridged.ID < networkid.MessageID(d.channel.LastMessageID), nil +} From 11b1ea5aa6f7fd38fb31437ce218153fad184d11 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Tue, 25 Nov 2025 11:16:51 -0800 Subject: [PATCH 19/99] bump discordgo (#206) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 255ce83..5dd9c96 100644 --- a/go.mod +++ b/go.mod @@ -43,4 +43,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 diff --git a/go.sum b/go.sum index aeac7db..8bd33f8 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= -github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= +github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 h1:uLpNLE/Avs+XMOzbjh49MfWuqm2lo+Z8Kv07CjOdRWQ= +github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From ae98d58dbe1e665e3cc9d04a6d01df9cc285333a Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 13:48:45 -0800 Subject: [PATCH 20/99] connector: set `CanBackfill` on rooms --- pkg/connector/client.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8067914..fbbef81 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -229,10 +229,11 @@ func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel, channel: ch, portalKey: d.makePortalKey(ch, d.UserLogin.ID, true), info: &bridgev2.ChatInfo{ - Name: &ch.Name, - Members: &members, - Avatar: makeChannelAvatar(ch), - Type: &roomType, + Name: &ch.Name, + Members: &members, + Avatar: makeChannelAvatar(ch), + Type: &roomType, + CanBackfill: true, }, }) } From 8c8f029e11a345f29763e2d3feee1c1254faa8cd Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 13:48:45 -0800 Subject: [PATCH 21/99] connector: do not forward backfill empty rooms We'll quickly hit ratelimits if we have a bunch of empty rooms, since forward backfilling apparently doesn't go through the queue. --- pkg/connector/events.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 631bb06..6e55404 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -68,7 +68,8 @@ func (d *DiscordChatResync) ShouldCreatePortal() bool { func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) { if latestBridged == nil { - return true, nil + zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling") + return false, nil } return latestBridged.ID < networkid.MessageID(d.channel.LastMessageID), nil } From 063b9d00ddb3c84c7fd85d18a82224fab3ec7029 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 13:48:45 -0800 Subject: [PATCH 22/99] connector/login: rename variable --- pkg/connector/login.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index f8992bd..bce24a8 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -103,10 +103,10 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf } - cl := DiscordClient{ + client := DiscordClient{ Session: session, } - err = cl.connect(ctx) + err = client.connect(ctx) if err != nil { dl.softlyCloseSession() return nil, err @@ -125,7 +125,7 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st }, &bridgev2.NewLoginParams{ // We already have a Session; call this instead of the connector's main LoadUserLogin method and thread the Session through. LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { - login.Client = &cl + login.Client = &client return nil }, DeleteOnConflict: true, From ab68fae8dd6e135816e92d8e93275452343d1d49 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:25:17 -0800 Subject: [PATCH 23/99] connector: fix UserLogin lifecycle during provisioning Bridge provisioning would crash because we wouldn't thread the necessary database models through. --- pkg/connector/client.go | 59 ++++++++++++++++++++++++++--------------- pkg/connector/login.go | 19 ++++++++----- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index fbbef81..b6e24ba 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -33,10 +33,11 @@ import ( ) type DiscordClient struct { - connector *DiscordConnector - usersFromReady map[string]*discordgo.User - UserLogin *bridgev2.UserLogin - Session *discordgo.Session + connector *DiscordConnector + usersFromReady map[string]*discordgo.User + UserLogin *bridgev2.UserLogin + Session *discordgo.Session + hasBegunSyncing bool } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -116,23 +117,6 @@ func (cl *DiscordClient) connect(ctx context.Context) error { user := cl.Session.State.User log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord") - if cl.UserLogin != nil { - // Feels a bit hacky to check for this here, but it should be true when - // logging in initially. The UserLogin is only ever created if we know - // that we connected successfully. We _do_ know that by now here, but we're - // not tasked with creating the UserLogin; the login code is. Alas. - - // FIXME(skip): Avatar. - cl.UserLogin.RemoteProfile = status.RemoteProfile{ - Email: user.Email, - Phone: user.Phone, - Name: user.String(), - } - if err := cl.UserLogin.Save(ctx); err != nil { - log.Err(err).Msg("Couldn't save UserLogin after connecting") - } - } - // Stash all of the users we received in READY so we can perform quick lookups // keyed by user ID. cl.usersFromReady = make(map[string]*discordgo.User) @@ -140,7 +124,11 @@ func (cl *DiscordClient) connect(ctx context.Context) error { cl.usersFromReady[user.ID] = user } - go cl.syncChannels(ctx) + // We won't have a UserLogin during provisioning, because the UserLogin can + // only be properly constructed once we know what the Discord user ID is + // (i.e. we have returned from this function). Thus, rely on the login + // process calling this method manually. + cl.BeginSyncingIfUserLoginPresent(ctx) return nil } @@ -160,6 +148,33 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) { d.Disconnect() } +func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { + if cl.UserLogin == nil { + cl.connector.bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin") + return + } + if cl.hasBegunSyncing { + cl.connector.bridge.Log.Warn().Msg("Not beginning sync more than once") + return + } + cl.hasBegunSyncing = true + + log := cl.UserLogin.Log + user := cl.Session.State.User + + // FIXME(skip): Avatar. + cl.UserLogin.RemoteProfile = status.RemoteProfile{ + Email: user.Email, + Phone: user.Phone, + Name: user.String(), + } + if err := cl.UserLogin.Save(ctx); err != nil { + log.Err(err).Msg("Couldn't save UserLogin after connecting") + } + + go cl.syncChannels(ctx) +} + func (d *DiscordClient) syncChannels(ctx context.Context) { for _, dm := range d.Session.State.PrivateChannels { d.UserLogin.Log.Debug().Str("channel_id", dm.ID).Msg("Syncing private channel") diff --git a/pkg/connector/login.go b/pkg/connector/login.go index bce24a8..efea9d1 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -48,13 +48,14 @@ func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, return nil, fmt.Errorf("unknown login flow ID") } - return &DiscordLogin{User: user}, nil + return &DiscordLogin{connector: d, User: user}, nil } type DiscordLogin struct { - User *bridgev2.User - Token string - Session *discordgo.Session + connector *DiscordConnector + User *bridgev2.User + Token string + Session *discordgo.Session } var _ bridgev2.LoginProcessUserInput = (*DiscordLogin)(nil) @@ -104,7 +105,8 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st } client := DiscordClient{ - Session: session, + connector: dl.connector, + Session: session, } err = client.connect(ctx) if err != nil { @@ -123,9 +125,14 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st HeartbeatSession: session.HeartbeatSession, }, }, &bridgev2.NewLoginParams{ - // We already have a Session; call this instead of the connector's main LoadUserLogin method and thread the Session through. + // We already have a Session; let's call this instead of the connector's + // main LoadUserLogin method, and thread the Session through. LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { login.Client = &client + client.UserLogin = login + + // Only now that we have a UserLogin can we begin syncing. + client.BeginSyncingIfUserLoginPresent(ctx) return nil }, DeleteOnConflict: true, From 61ef0c1051d65c90ca9badb37e4837a704a4f1ed Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:25:17 -0800 Subject: [PATCH 24/99] connector: limit the amount of private channels initially synced Otherwise, we'll hit the ratelimit pretty easily. --- pkg/connector/client.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index b6e24ba..521186a 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -17,11 +17,13 @@ package connector import ( + "cmp" "context" "errors" "fmt" "io" "net/http" + "slices" "time" "github.com/bwmarrin/discordgo" @@ -172,12 +174,18 @@ func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { log.Err(err).Msg("Couldn't save UserLogin after connecting") } - go cl.syncChannels(ctx) + go cl.syncPrivateChannels(ctx) } -func (d *DiscordClient) syncChannels(ctx context.Context) { - for _, dm := range d.Session.State.PrivateChannels { - d.UserLogin.Log.Debug().Str("channel_id", dm.ID).Msg("Syncing private channel") +func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { + dms := slices.Clone(d.Session.State.PrivateChannels) + // Only sync the top n private channels with recent activity. + slices.SortFunc(dms, func(a, b *discordgo.Channel) int { + return cmp.Compare(b.LastMessageID, a.LastMessageID) + }) + // TODO(skip): This is startup_private_channel_create_limit. Support this in the config. + for _, dm := range dms[:10] { + zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity") d.syncChannel(ctx, dm, true) } } From f8b65fe1f0e4928bbe87bb96973be83d177f1d40 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:31:39 -0800 Subject: [PATCH 25/99] clarify comment --- pkg/connector/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 521186a..8bf5d97 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -126,10 +126,10 @@ func (cl *DiscordClient) connect(ctx context.Context) error { cl.usersFromReady[user.ID] = user } - // We won't have a UserLogin during provisioning, because the UserLogin can - // only be properly constructed once we know what the Discord user ID is - // (i.e. we have returned from this function). Thus, rely on the login - // process calling this method manually. + // NOTE: We won't have a UserLogin during provisioning, because the UserLogin + // can only be properly constructed once we know what the Discord user ID is + // (i.e. we have returned from this function). We'll rely on the login + // process calling this method manually instead. cl.BeginSyncingIfUserLoginPresent(ctx) return nil From 56f05bc02c476e293b0356903d82183c0c66c83a Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:46:12 -0800 Subject: [PATCH 26/99] backfill: make messages actually have IDs --- pkg/connector/backfill.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 28bfafa..357c6b0 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -24,6 +24,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" ) @@ -69,9 +70,9 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 ts, _ := discordgo.SnowflakeTimestamp(msg.ID) // FIXME(skip): Backfill reactions. - converted = append(converted, &bridgev2.BackfillMessage{ ConvertedMessage: dc.convertMessage(msg), + ID: networkid.MessageID(msg.ID), Sender: dc.makeEventSender(msg.Author), Timestamp: ts, StreamOrder: streamOrder, From 31c1cdda0cf411f80dd48f542a910795b7c054dd Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:46:12 -0800 Subject: [PATCH 27/99] connector: sort recent private channels properly --- pkg/connector/client.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8bf5d97..f880702 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -17,7 +17,6 @@ package connector import ( - "cmp" "context" "errors" "fmt" @@ -181,7 +180,9 @@ func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { dms := slices.Clone(d.Session.State.PrivateChannels) // Only sync the top n private channels with recent activity. slices.SortFunc(dms, func(a, b *discordgo.Channel) int { - return cmp.Compare(b.LastMessageID, a.LastMessageID) + ats, _ := discordgo.SnowflakeTimestamp(a.LastMessageID) + bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID) + return bts.Compare(ats) }) // TODO(skip): This is startup_private_channel_create_limit. Support this in the config. for _, dm := range dms[:10] { From 114df5f2a2f1e2a3c1c88a2e84c4cfc9f3456eea Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 14:46:12 -0800 Subject: [PATCH 28/99] tidy --- pkg/connector/client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index f880702..490ab08 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -221,7 +221,7 @@ func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { } } -func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel, selfIsInChannel bool) { +func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 var roomType database.RoomType @@ -237,6 +237,8 @@ func (d *DiscordClient) syncChannel(ctx context.Context, ch *discordgo.Channel, members.IsFull = true members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) if len(ch.Recipients) > 0 { + // Private channels' array of participants doesn't include ourselves, + // so this boolean can be used to inject ourselves as a member. if selfIsInChannel { members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} } From 5fa964501292bd7769b11aedba0e5d12b0d0b9aa Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 16:09:22 -0800 Subject: [PATCH 29/99] connector/id: actually use user as `UserLogin` --- pkg/connector/id.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/id.go b/pkg/connector/id.go index 76e4486..984f07d 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -33,7 +33,7 @@ func (d *DiscordClient) makePortalKey(ch *discordgo.Channel, userLoginID network func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: user.ID == d.Session.State.User.ID, - SenderLogin: d.UserLogin.ID, + SenderLogin: networkid.UserLoginID(user.ID), Sender: networkid.UserID(user.ID), } } From 45dae8fafb388d670575262368c25da750751803 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 16:09:22 -0800 Subject: [PATCH 30/99] backfill: set `ConvertedMessagePart.Type` --- pkg/connector/backfill.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 357c6b0..617c0f1 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -116,6 +116,7 @@ func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.Conver if msg.Content != "" { // FIXME(skip): This needs to render into HTML. parts = append(parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgText, Body: msg.Content, From 66d9ca6394f750bfd78986f78524197ddd77115b Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 16:18:59 -0800 Subject: [PATCH 31/99] backfill: set `ConvertedMessagePart.Type` for call/guild join --- pkg/connector/backfill.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 617c0f1..e76b26f 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -99,6 +99,7 @@ func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.Conver switch msg.Type { case discordgo.MessageTypeCall: parts = append(parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "started a call", @@ -106,6 +107,7 @@ func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.Conver }) case discordgo.MessageTypeGuildMemberJoin: parts = append(parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "joined the server", From 17fed9aca5b02b845be0b8ce04c261ceb09c6c04 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 25 Nov 2025 17:40:48 -0800 Subject: [PATCH 32/99] msgconv: initial impl of `ToMatrix`, porting `convertDiscordTextMessage` --- pkg/connector/client.go | 2 +- pkg/connector/id.go | 7 +- pkg/msgconv/embed.go | 97 +++++++++++++ pkg/msgconv/from-discord.go | 266 ++++++++++++++++++++++++++++++++++++ pkg/msgconv/msgconv.go | 23 ++++ 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 pkg/msgconv/embed.go create mode 100644 pkg/msgconv/from-discord.go create mode 100644 pkg/msgconv/msgconv.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 490ab08..4eef523 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -253,7 +253,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se d.connector.bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ channel: ch, - portalKey: d.makePortalKey(ch, d.UserLogin.ID, true), + portalKey: MakePortalKey(ch, d.UserLogin.ID, true), info: &bridgev2.ChatInfo{ Name: &ch.Name, Members: &members, diff --git a/pkg/connector/id.go b/pkg/connector/id.go index 984f07d..5519ddd 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -22,7 +22,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" ) -func (d *DiscordClient) makePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { +func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { key.ID = networkid.PortalID(ch.ID) if wantReceiver { key.Receiver = userLoginID @@ -30,6 +30,11 @@ func (d *DiscordClient) makePortalKey(ch *discordgo.Channel, userLoginID network return } +func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) { + key.ID = networkid.PortalID(channelID) + return +} + func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: user.ID == d.Session.State.User.ID, diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go new file mode 100644 index 0000000..79c1f96 --- /dev/null +++ b/pkg/msgconv/embed.go @@ -0,0 +1,97 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "regexp" + + "github.com/bwmarrin/discordgo" +) + +type BridgeEmbedType int + +const ( + EmbedUnknown BridgeEmbedType = iota + EmbedRich + EmbedLinkPreview + EmbedVideo +) + +const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]` + +// Discord links start with http:// or https://, contain at least two characters afterwards, +// don't contain < or whitespace anywhere, and don't end with "'),.:;] +// +// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason +var discordLinkRegex = regexp.MustCompile(discordLinkPattern) +var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") + +func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { + // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview, + // so this is a hacky way to detect those. + return embed.Video != nil && embed.Video.ProxyURL == "" +} + +// isPlainGifMessage returns whether a Discord message consists entirely of a +// link to a GIF-like animated image. A single embed must also be present on the +// message. +// +// This helps replicate Discord first-party client behavior, where the link is +// hidden when these same conditions are fulfilled. +func isPlainGifMessage(msg *discordgo.Message) bool { + if len(msg.Embeds) != 1 { + return false + } + embed := msg.Embeds[0] + isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil + isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == "" + contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content) + return contentIsOnlyURL && (isGifVideo || isGifImage) +} + +// getEmbedType determines how a Discord embed should be bridged to Matrix by +// returning a BridgeEmbedType. +func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType { + switch embed.Type { + case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle: + return EmbedLinkPreview + case discordgo.EmbedTypeVideo: + if isActuallyLinkPreview(embed) { + return EmbedLinkPreview + } + return EmbedVideo + case discordgo.EmbedTypeGifv: + return EmbedVideo + case discordgo.EmbedTypeImage: + if msg != nil && isPlainGifMessage(msg) { + return EmbedVideo + } else if embed.Image == nil && embed.Thumbnail != nil { + return EmbedLinkPreview + } + return EmbedRich + case discordgo.EmbedTypeRich: + return EmbedRich + default: + return EmbedUnknown + } +} + +var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`) + +func isReplyEmbed(embed *discordgo.MessageEmbed) bool { + return hackyReplyPattern.MatchString(embed.Description) +} diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go new file mode 100644 index 0000000..075828f --- /dev/null +++ b/pkg/msgconv/from-discord.go @@ -0,0 +1,266 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-discord/pkg/connector" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +func (mc *MessageConverter) ToMatrix( + ctx context.Context, + portal *bridgev2.Portal, + intent bridgev2.MatrixAPI, + source *bridgev2.UserLogin, + msg *discordgo.Message, +) *bridgev2.ConvertedMessage { + predictedLength := len(msg.Attachments) + len(msg.StickerItems) + if msg.Content != "" { + predictedLength++ + } + parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength) + if textPart := mc.renderDiscordTextMessage(ctx, intent, msg, source); textPart != nil { + parts = append(parts, textPart) + } + + log := zerolog.Ctx(ctx) + handledIDs := make(map[string]struct{}) + + for _, att := range msg.Attachments { + if _, handled := handledIDs[att.ID]; handled { + continue + } + handledIDs[att.ID] = struct{}{} + + log := log.With().Str("attachment_id", att.ID).Logger() + if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil { + parts = append(parts, part) + } + } + + for _, sticker := range msg.StickerItems { + if _, handled := handledIDs[sticker.ID]; handled { + continue + } + handledIDs[sticker.ID] = struct{}{} + + log := log.With().Str("sticker_id", sticker.ID).Logger() + if part := mc.renderDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil { + parts = append(parts, part) + } + } + + for i, embed := range msg.Embeds { + // Ignore non-video embeds, they're handled in convertDiscordTextMessage + if getEmbedType(msg, embed) != EmbedVideo { + continue + } + // Discord deduplicates embeds by URL. It makes things easier for us too. + if _, handled := handledIDs[embed.URL]; handled { + continue + } + handledIDs[embed.URL] = struct{}{} + + log := log.With(). + Str("computed_embed_type", "video"). + Str("embed_type", string(embed.Type)). + Int("embed_index", i). + Logger() + part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, embed) + if part != nil { + parts = append(parts, part) + } + } + + if len(parts) == 0 && msg.Thread != nil { + parts = append(parts, &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name), + }}) + } + + // TODO(skip): Add extra metadata. + // for _, part := range parts { + // puppet.addWebhookMeta(part, msg) + // puppet.addMemberMeta(part, msg) + // } + + return &bridgev2.ConvertedMessage{Parts: parts} +} + +const forwardTemplateHTML = `
    +

    ↷ Forwarded

    +%s +

    %s

    +
    ` + +const msgInteractionTemplateHTML = `
    +%s used /%s +
    ` + +const msgComponentTemplateHTML = `

    This message contains interactive elements. Use the Discord app to interact with the message.

    ` + +func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { + log := zerolog.Ctx(ctx) + if msg.Type == discordgo.MessageTypeCall { + return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "started a call", + }} + } else if msg.Type == discordgo.MessageTypeGuildMemberJoin { + return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "joined the server", + }} + } + + var htmlParts []string + + if msg.Interaction != nil { + ghost, err := mc.bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) + // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. + if err == nil { + htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) + } else { + log.Err(err).Msg("Couldn't get ghost by ID while bridging interaction") + } + } + + if msg.Content != "" && !isPlainGifMessage(msg) { + // Bridge basic text messages. + htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(msg.Content, true)) + } else if msg.MessageReference != nil && + msg.MessageReference.Type == discordgo.MessageReferenceTypeForward && + len(msg.MessageSnapshots) > 0 && + msg.MessageSnapshots[0].Message != nil { + // Bridge forwarded messages. + + forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true) + msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") + origLink := fmt.Sprintf("unknown channel • %s", msgTSText) + if forwardedFromPortal, err := mc.bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { + if origMessage, err := mc.bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + // We've bridged the message that was forwarded, so we can link to it directly. + origLink = fmt.Sprintf( + `#%s • %s`, + forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.bridge.Matrix.ServerName()), + forwardedFromPortal.Name, + msgTSText, + ) + } else if err != nil { + log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message") + } else if forwardedFromPortal.MXID != "" { + // We don't have the message but we have the portal, so link to that. + origLink = fmt.Sprintf( + `#%s • %s`, + forwardedFromPortal.MXID.URI(mc.bridge.Matrix.ServerName()), + forwardedFromPortal.Name, + msgTSText, + ) + } else if forwardedFromPortal.Name != "" { + // We only have the name of the portal. + origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText) + } + } else { + log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message") + } + + htmlParts = append(htmlParts, fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink)) + } + + previews := make([]*event.BeeperLinkPreview, 0) + for i, embed := range msg.Embeds { + if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) { + continue + } + + with := log.With(). + Str("embed_type", string(embed.Type)). + Int("embed_index", i) + + switch getEmbedType(msg, embed) { + case EmbedRich: + log := with.Str("computed_embed_type", "rich").Logger() + htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i)) + case EmbedLinkPreview: + log := with.Str("computed_embed_type", "link preview").Logger() + previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, embed)) + case EmbedVideo: + // Ignore video embeds, they're handled as separate messages. + default: + log := with.Logger() + log.Warn().Msg("Unknown embed type in message") + } + } + + if len(msg.Components) > 0 { + htmlParts = append(htmlParts, msgComponentTemplateHTML) + } + + if len(htmlParts) == 0 { + return nil + } + + fullHTML := strings.Join(htmlParts, "\n") + if !msg.MentionEveryone { + fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om") + } + + content := format.HTMLToContent(fullHTML) + extraContent := map[string]any{ + "com.beeper.linkpreviews": previews, + } + + return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} +} + +func (mc *MessageConverter) renderDiscordVideoEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent bridgev2.MatrixAPI, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordRichEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed, messageID string, i int) string { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordLinkEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { + panic("unimplemented") +} + +func (mc *MessageConverter) renderDiscordAttachment(context context.Context, intent bridgev2.MatrixAPI, d string, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { + panic("unimplemented") +} diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go new file mode 100644 index 0000000..03a4aee --- /dev/null +++ b/pkg/msgconv/msgconv.go @@ -0,0 +1,23 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import "maunium.net/go/mautrix/bridgev2" + +type MessageConverter struct { + bridge *bridgev2.Bridge +} From 86e18c1f7d3f2459e17b23f1d364ac60fcba3874 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 26 Nov 2025 15:07:40 -0800 Subject: [PATCH 33/99] msgconv: port the majority of embed and attachment bridging We still need to implement direct media support, so for now we encrypt and reupload to Matrix. Notably remaining is conversion to HTML. --- pkg/connector/attachments.go | 126 ++++++++++++ pkg/connector/client.go | 6 +- pkg/connector/connector.go | 4 +- pkg/msgconv/formatter_tag.go | 40 ++++ pkg/msgconv/from-discord.go | 359 +++++++++++++++++++++++++++++++++-- pkg/msgconv/msgconv.go | 6 +- 6 files changed, 514 insertions(+), 27 deletions(-) create mode 100644 pkg/connector/attachments.go create mode 100644 pkg/msgconv/formatter_tag.go diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go new file mode 100644 index 0000000..69d3b55 --- /dev/null +++ b/pkg/connector/attachments.go @@ -0,0 +1,126 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + for key, value := range discordgo.DroidDownloadHeaders { + req.Header.Set(key, value) + } + + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode > 300 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data) + } + if resp.Header.Get("Content-Length") != "" { + length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse content length: %w", err) + } else if length > maxSize { + return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize) + } + return io.ReadAll(resp.Body) + } else { + var mbe *http.MaxBytesError + data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize)) + if err != nil && errors.As(err, &mbe) { + return nil, fmt.Errorf("attachment too large (over %d)", maxSize) + } + return data, err + } +} + +type AttachmentReupload struct { + DownloadingURL string + FileName string + MimeType string +} + +type ReuploadedAttachment struct { + AttachmentReupload + DownloadedSize int + MXC id.ContentURIString + EncryptedFile *event.EncryptedFileInfo +} + +func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload AttachmentReupload) (*ReuploadedAttachment, error) { + log := zerolog.Ctx(ctx) + // TODO(skip): Do we need to check if we've already downloaded this media before? + // TODO(skip): Read a maximum size from the config. + data, err := downloadDiscordAttachment(http.DefaultClient, upload.DownloadingURL, 1_024*1_024*50) + if err != nil { + return nil, fmt.Errorf("couldn't download attachment for reupload: %w", err) + } + + if upload.FileName == "" { + url, err := url.Parse(upload.DownloadingURL) + if err != nil { + return nil, fmt.Errorf("couldn't parse URL to download for media reupload: %w", err) + } + fileName := path.Base(url.Path) + upload.FileName = fileName + log.Trace().Str("detected_file_name", fileName).Msg("Inferred the file name of the media we're reuploading") + } + + if upload.MimeType == "" { + mime := http.DetectContentType(data) + upload.MimeType = mime + log.Trace().Str("detected_mime_type", mime).Msg("Inferred the mime type of the media we're reuploading") + } + + log.Trace().Stringer("portal_mxid", portal.MXID). + Int("attachment_size", len(data)). + Str("file_name", upload.FileName). + Str("mime_type", upload.MimeType). + Msg("Uploading downloaded media") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, upload.FileName, upload.MimeType) + if err != nil { + return nil, err + } + + return &ReuploadedAttachment{ + AttachmentReupload: upload, + DownloadedSize: len(data), + MXC: mxc, + EncryptedFile: file, + }, nil +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4eef523..ac04139 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -151,11 +151,11 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) { func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { if cl.UserLogin == nil { - cl.connector.bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin") + cl.connector.Bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin") return } if cl.hasBegunSyncing { - cl.connector.bridge.Log.Warn().Msg("Not beginning sync more than once") + cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once") return } cl.hasBegunSyncing = true @@ -251,7 +251,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se members.TotalMemberCount = len(ch.Recipients) } - d.connector.bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ + d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ channel: ch, portalKey: MakePortalKey(ch, d.UserLogin.ID, true), info: &bridgev2.ChatInfo{ diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e3a16d8..56c156b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -23,13 +23,13 @@ import ( ) type DiscordConnector struct { - bridge *bridgev2.Bridge + Bridge *bridgev2.Bridge } var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { - d.bridge = bridge + d.Bridge = bridge } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go new file mode 100644 index 0000000..b03bf19 --- /dev/null +++ b/pkg/msgconv/formatter_tag.go @@ -0,0 +1,40 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +// TODO(skip): Port the rest of this. + +type discordTimestampStyle rune + +func (dts discordTimestampStyle) Format() string { + switch dts { + case 't': + return "15:04 MST" + case 'T': + return "15:04:05 MST" + case 'd': + return "2006-01-02 MST" + case 'D': + return "2 January 2006 MST" + case 'F': + return "Monday, 2 January 2006 15:04 MST" + case 'f': + fallthrough + default: + return "2 January 2006 15:04 MST" + } +} diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 075828f..66cbb4f 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -19,7 +19,10 @@ package msgconv import ( "context" "fmt" + "html" + "strconv" "strings" + "time" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" @@ -42,7 +45,7 @@ func (mc *MessageConverter) ToMatrix( predictedLength++ } parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength) - if textPart := mc.renderDiscordTextMessage(ctx, intent, msg, source); textPart != nil { + if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil { parts = append(parts, textPart) } @@ -56,7 +59,7 @@ func (mc *MessageConverter) ToMatrix( handledIDs[att.ID] = struct{}{} log := log.With().Str("attachment_id", att.ID).Logger() - if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil { + if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, portal, att); part != nil { parts = append(parts, part) } } @@ -89,7 +92,7 @@ func (mc *MessageConverter) ToMatrix( Str("embed_type", string(embed.Type)). Int("embed_index", i). Logger() - part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, embed) + part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, portal, embed) if part != nil { parts = append(parts, part) } @@ -123,7 +126,7 @@ const msgInteractionTemplateHTML = `
    const msgComponentTemplateHTML = `

    This message contains interactive elements. Use the Discord app to interact with the message.

    ` -func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { +func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { log := zerolog.Ctx(ctx) if msg.Type == discordgo.MessageTypeCall { return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ @@ -140,7 +143,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent var htmlParts []string if msg.Interaction != nil { - ghost, err := mc.bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) + ghost, err := mc.connector.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. if err == nil { htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) @@ -161,12 +164,12 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true) msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") origLink := fmt.Sprintf("unknown channel • %s", msgTSText) - if forwardedFromPortal, err := mc.bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { - if origMessage, err := mc.bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + if forwardedFromPortal, err := mc.connector.Bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { + if origMessage, err := mc.connector.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { // We've bridged the message that was forwarded, so we can link to it directly. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -176,7 +179,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent // We don't have the message but we have the portal, so link to that. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.URI(mc.bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.URI(mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -204,12 +207,12 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent switch getEmbedType(msg, embed) { case EmbedRich: log := with.Str("computed_embed_type", "rich").Logger() - htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i)) + htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, portal, embed)) case EmbedLinkPreview: log := with.Str("computed_embed_type", "link preview").Logger() - previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, embed)) + previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, portal, embed)) case EmbedVideo: - // Ignore video embeds, they're handled as separate messages. + // Video embeds are handled as separate messages via renderDiscordVideoEmbed. default: log := with.Logger() log.Warn().Msg("Unknown embed type in message") @@ -237,8 +240,81 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} } -func (mc *MessageConverter) renderDiscordVideoEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { - panic("unimplemented") +func mediaFailedMessage(err error) *event.MessageEventContent { + return &event.MessageEventContent{ + Body: fmt.Sprintf("Failed to bridge media: %v", err), + MsgType: event.MsgNotice, + } +} + +func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { + var proxyURL string + if embed.Video != nil { + proxyURL = embed.Video.ProxyURL + } else if embed.Thumbnail != nil { + proxyURL = embed.Thumbnail.ProxyURL + } else { + zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed", + MsgType: event.MsgNotice, + }, + } + } + + upload := connector.AttachmentReupload{ + DownloadingURL: proxyURL, + } + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, upload) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: mediaFailedMessage(err), + } + } + + content := &event.MessageEventContent{ + Body: embed.URL, + Info: &event.FileInfo{ + MimeType: reupload.MimeType, + Size: reupload.DownloadedSize, + }, + File: &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + }, + } + + if embed.Video != nil { + content.MsgType = event.MsgVideo + content.Info.Width = embed.Video.Width + content.Info.Height = embed.Video.Height + } else { + content.MsgType = event.MsgImage + content.Info.Width = embed.Thumbnail.Width + content.Info.Height = embed.Thumbnail.Height + } + + extra := map[string]any{} + if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv { + extra["info"] = map[string]any{ + "fi.mau.discord.gifv": true, + "fi.mau.gif": true, + "fi.mau.loop": true, + "fi.mau.autoplay": true, + "fi.mau.hide_controls": true, + "fi.mau.no_audio": true, + } + } + + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + Extra: extra, + } } func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent bridgev2.MatrixAPI, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { @@ -253,14 +329,257 @@ func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, a panic("unimplemented") } -func (mc *MessageConverter) renderDiscordRichEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed, messageID string, i int) string { - panic("unimplemented") +const ( + embedHTMLWrapper = `
    %s
    ` + embedHTMLWrapperColor = `
    %s
    ` + embedHTMLAuthorWithImage = `

     %s

    ` + embedHTMLAuthorPlain = `

    %s

    ` + embedHTMLAuthorLink = `%s` + embedHTMLTitleWithLink = `

    %s

    ` + embedHTMLTitlePlain = `

    %s

    ` + embedHTMLDescription = `

    %s

    ` + embedHTMLFieldName = `%s` + embedHTMLFieldValue = `%s` + embedHTMLFields = `%s%s
    ` + embedHTMLLinearField = `

    %s
    %s

    ` + embedHTMLImage = `

    ` + embedHTMLFooterWithImage = `` + embedHTMLFooterPlain = `` + embedHTMLFooterOnlyDate = `` + embedHTMLDate = `` + embedFooterDateSeparator = ` • ` +) + +func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) string { + log := zerolog.Ctx(ctx) + var htmlParts []string + if embed.Author != nil { + var authorHTML string + authorNameHTML := html.EscapeString(embed.Author.Name) + if embed.Author.URL != "" { + authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML) + } + authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) + if embed.Author.ProxyIconURL != "" { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Author.ProxyIconURL, + }) + + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload author icon in embed") + } else { + authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, reupload.MXC, authorNameHTML) + } + } + htmlParts = append(htmlParts, authorHTML) + } + + if embed.Title != "" { + var titleHTML string + baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(embed.Title, false) + if embed.URL != "" { + titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) + } else { + titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML) + } + htmlParts = append(htmlParts, titleHTML) + } + + if embed.Description != "" { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(embed.Description, true))) + } + + for i := 0; i < len(embed.Fields); i++ { + item := embed.Fields[i] + // TODO(skip): Port EmbedFieldsAsTables. + if false { + splitItems := []*discordgo.MessageEmbedField{item} + if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { + splitItems = append(splitItems, embed.Fields[i+1]) + i++ + if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { + splitItems = append(splitItems, embed.Fields[i+1]) + i++ + } + } + headerParts := make([]string, len(splitItems)) + contentParts := make([]string, len(splitItems)) + for j, splitItem := range splitItems { + headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) + contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(splitItem.Value, true)) + } + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) + } else { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, + strconv.FormatBool(item.Inline), + mc.renderDiscordMarkdownOnlyHTML(item.Name, false), + mc.renderDiscordMarkdownOnlyHTML(item.Value, true), + )) + } + } + + if embed.Image != nil { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Image.ProxyURL, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload image in embed") + } else { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, reupload.MXC)) + } + } + + var embedDateHTML string + if embed.Timestamp != "" { + formattedTime := embed.Timestamp + parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) + if err != nil { + log.Warn().Err(err).Msg("Failed to parse timestamp in embed") + } else { + formattedTime = parsedTS.Format(discordTimestampStyle('F').Format()) + } + embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime) + } + + if embed.Footer != nil { + var footerHTML string + var datePart string + if embedDateHTML != "" { + datePart = embedFooterDateSeparator + embedDateHTML + } + footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) + if embed.Footer.ProxyIconURL != "" { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Footer.ProxyIconURL, + }) + + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload footer icon in embed") + } else { + footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, reupload.MXC, html.EscapeString(embed.Footer.Text), datePart) + } + } + htmlParts = append(htmlParts, footerHTML) + } else if embed.Timestamp != "" { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML)) + } + + if len(htmlParts) == 0 { + return "" + } + + compiledHTML := strings.Join(htmlParts, "") + if embed.Color != 0 { + compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML) + } else { + compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML) + } + return compiledHTML } -func (mc *MessageConverter) renderDiscordLinkEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { - panic("unimplemented") +func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: url, + }) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring") + return + } + + if width != 0 || height != 0 { + preview.ImageWidth = event.IntOrString(width) + preview.ImageHeight = event.IntOrString(height) + } + preview.ImageSize = event.IntOrString(reupload.DownloadedSize) + preview.ImageType = reupload.MimeType + preview.ImageEncryption = &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + } } -func (mc *MessageConverter) renderDiscordAttachment(context context.Context, intent bridgev2.MatrixAPI, d string, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { - panic("unimplemented") +func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { + var preview event.BeeperLinkPreview + preview.MatchedURL = embed.URL + preview.Title = embed.Title + preview.Description = embed.Description + if embed.Image != nil { + mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) + } else if embed.Thumbnail != nil { + mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) + } + return &preview +} + +func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { + content := &event.MessageEventContent{ + Body: att.Filename, + Info: &event.FileInfo{ + Width: att.Width, + Height: att.Height, + MimeType: att.ContentType, + + // This gets overwritten later after the file is uploaded to the homeserver + Size: att.Size, + }, + } + + var extra = make(map[string]any) + + if strings.HasPrefix(att.Filename, "SPOILER_") { + extra["page.codeberg.everypizza.msc4193.spoiler"] = true + } + + if att.Description != "" { + content.Body = att.Description + content.FileName = att.Filename + } + + switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { + case "audio": + content.MsgType = event.MsgAudio + if att.Waveform != nil { + // Bridge a voice message. + + // TODO convert waveform + extra["org.matrix.msc1767.audio"] = map[string]any{ + "duration": int(att.DurationSeconds * 1000), + } + extra["org.matrix.msc3245.voice"] = map[string]any{} + } + case "image": + content.MsgType = event.MsgImage + case "video": + content.MsgType = event.MsgVideo + default: + content.MsgType = event.MsgFile + } + + // TODO(skip): Support direct media. + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: att.URL, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: mediaFailedMessage(err), + } + } + + content.Info.Size = reupload.DownloadedSize + if content.Info.Width == 0 && content.Info.Height == 0 { + content.Info.Width = att.Width + content.Info.Height = att.Height + } + content.File = &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + } + + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + Extra: extra, + } } diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 03a4aee..15237df 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -16,8 +16,10 @@ package msgconv -import "maunium.net/go/mautrix/bridgev2" +import ( + "go.mau.fi/mautrix-discord/pkg/connector" +) type MessageConverter struct { - bridge *bridgev2.Bridge + connector *connector.DiscordConnector } From b5e6db06f83faf5a6addc9af37b41e4753399e5b Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 26 Nov 2025 18:09:00 -0800 Subject: [PATCH 34/99] msgconv: port most of attachment and text message bridging * Created a separate discordid package to avoid import cycles. * Implemented attachment bridging. We still need to implement direct media, but this will do for now. * Corrected how encrypted files (e.g. embed images and attachments) were bridged. Previously, the URL field would be empty. Still a lot of missing pieces. Thoughts: * Mentions to roles and custom emoji are not rendered properly. We need to maintain our own DB. * We might not need the "attachments" leaf package anymore? It's just there to avoid an import cycle. Bridging actual events (i.e. wiring up discordgo's event handlers) is probably next. --- pkg/attachment/attachment.go | 38 ++++ pkg/connector/attachments.go | 20 +- pkg/connector/backfill.go | 64 ++---- pkg/connector/client.go | 11 +- pkg/{connector => discordid}/id.go | 11 +- pkg/msgconv/formatter.go | 99 ++++++++++ pkg/msgconv/formatter_everyone.go | 110 +++++++++++ pkg/msgconv/formatter_tag.go | 306 ++++++++++++++++++++++++++++- pkg/msgconv/from-discord.go | 67 +++---- pkg/msgconv/msgconv.go | 14 +- 10 files changed, 629 insertions(+), 111 deletions(-) create mode 100644 pkg/attachment/attachment.go rename pkg/{connector => discordid}/id.go (79%) create mode 100644 pkg/msgconv/formatter.go create mode 100644 pkg/msgconv/formatter_everyone.go diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go new file mode 100644 index 0000000..3e573cb --- /dev/null +++ b/pkg/attachment/attachment.go @@ -0,0 +1,38 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package attachment + +import ( + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// TODO(skip): These types are only in a leaf package to avoid import cycles. +// Perhaps figure out a better way to structure this so that this package is unnecessary. + +type AttachmentReupload struct { + DownloadingURL string + FileName string + MimeType string +} + +type ReuploadedAttachment struct { + AttachmentReupload + DownloadedSize int + MXC id.ContentURIString + EncryptedFile *event.EncryptedFileInfo +} diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go index 69d3b55..eada633 100644 --- a/pkg/connector/attachments.go +++ b/pkg/connector/attachments.go @@ -28,9 +28,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" + "go.mau.fi/mautrix-discord/pkg/attachment" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" ) func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { @@ -69,20 +68,7 @@ func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]b } } -type AttachmentReupload struct { - DownloadingURL string - FileName string - MimeType string -} - -type ReuploadedAttachment struct { - AttachmentReupload - DownloadedSize int - MXC id.ContentURIString - EncryptedFile *event.EncryptedFileInfo -} - -func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload AttachmentReupload) (*ReuploadedAttachment, error) { +func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) { log := zerolog.Ctx(ctx) // TODO(skip): Do we need to check if we've already downloaded this media before? // TODO(skip): Read a maximum size from the config. @@ -117,7 +103,7 @@ func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.Ma return nil, err } - return &ReuploadedAttachment{ + return &attachment.ReuploadedAttachment{ AttachmentReupload: upload, DownloadedSize: len(data), MXC: mxc, diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index e76b26f..99dee51 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -23,9 +23,9 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" + "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" ) var ( @@ -65,15 +65,33 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 } converted := make([]*bridgev2.BackfillMessage, 0, len(msgs)) + mc := msgconv.MessageConverter{ + Bridge: dc.connector.Bridge, + ReuploadMedia: dc.connector.ReuploadMedia, + } for _, msg := range msgs { streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) ts, _ := discordgo.SnowflakeTimestamp(msg.ID) // FIXME(skip): Backfill reactions. + sender := dc.makeEventSender(msg.Author) + + // Use the ghost's intent, falling back to the bridge's. + ghost, err := dc.connector.Bridge.GetGhostByID(ctx, sender.Sender) + if err != nil { + log.Err(err).Msg("Failed to look up ghost while converting backfilled message") + } + var intent bridgev2.MatrixAPI + if ghost == nil { + intent = fetchParams.Portal.Bridge.Bot + } else { + intent = ghost.Intent + } + converted = append(converted, &bridgev2.BackfillMessage{ - ConvertedMessage: dc.convertMessage(msg), ID: networkid.MessageID(msg.ID), - Sender: dc.makeEventSender(msg.Author), + ConvertedMessage: mc.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg), + Sender: sender, Timestamp: ts, StreamOrder: streamOrder, }) @@ -91,43 +109,3 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 HasMore: len(msgs) == count, }, nil } - -func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.ConvertedMessage { - // FIXME(skip): This isn't bridging a lot of things (replies, forwards, voice messages, attachments, webhooks, embeds, etc.). Copy from main branch. - - var parts []*bridgev2.ConvertedMessagePart - switch msg.Type { - case discordgo.MessageTypeCall: - parts = append(parts, &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "started a call", - }, - }) - case discordgo.MessageTypeGuildMemberJoin: - parts = append(parts, &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "joined the server", - }, - }) - } - - if msg.Content != "" { - // FIXME(skip): This needs to render into HTML. - parts = append(parts, &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: msg.Content, - }, - }) - } - - return &bridgev2.ConvertedMessage{ - // TODO(skip): Replies. - Parts: parts, - } -} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index ac04139..f43b115 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -27,6 +27,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" + "go.mau.fi/mautrix-discord/pkg/discordid" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -221,6 +222,14 @@ func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { } } +func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { + return bridgev2.EventSender{ + IsFromMe: user.ID == d.Session.State.User.ID, + SenderLogin: networkid.UserLoginID(user.ID), + Sender: networkid.UserID(user.ID), + } +} + func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 @@ -253,7 +262,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ channel: ch, - portalKey: MakePortalKey(ch, d.UserLogin.ID, true), + portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true), info: &bridgev2.ChatInfo{ Name: &ch.Name, Members: &members, diff --git a/pkg/connector/id.go b/pkg/discordid/id.go similarity index 79% rename from pkg/connector/id.go rename to pkg/discordid/id.go index 5519ddd..e329aa2 100644 --- a/pkg/connector/id.go +++ b/pkg/discordid/id.go @@ -14,11 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package connector +package discordid import ( "github.com/bwmarrin/discordgo" - "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -34,11 +33,3 @@ func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) { key.ID = networkid.PortalID(channelID) return } - -func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { - return bridgev2.EventSender{ - IsFromMe: user.ID == d.Session.State.User.ID, - SenderLogin: networkid.UserLoginID(user.ID), - Sender: networkid.UserID(user.ID), - } -} diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go new file mode 100644 index 0000000..7065bc6 --- /dev/null +++ b/pkg/msgconv/formatter.go @@ -0,0 +1,99 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "fmt" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/util" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/format/mdext" +) + +// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement +// +// Discord allows escaping with just one backslash, e.g. \__a__, +// but standard markdown requires both to be escaped (\_\_a__) +var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) + +func escapeReplacement(s string) string { + return s[:2] + `\` + s[2:] +} + +// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. +// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). +type indentableParagraphParser struct { + parser.BlockParser +} + +var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} + +func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { + return true +} + +var removeFeaturesExceptLinks = []any{ + parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), + parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(), + parser.NewCodeBlockParser(), +} +var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser()) +var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) +var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag) + +var discordRenderer = goldmark.New( + goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)), + fixIndentedParagraphs, format.HTMLOptions, discordExtensions, +) +var discordRendererWithInlineLinks = goldmark.New( + goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)), + fixIndentedParagraphs, format.HTMLOptions, discordExtensions, +) + +// renderDiscordMarkdownOnlyHTML converts Discord-flavored Markdown text to HTML. +// +// After conversion, if the text is surrounded by a single outermost paragraph +// tag, it is unwrapped. +func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(portal *bridgev2.Portal, text string, allowInlineLinks bool) string { + return format.UnwrapSingleParagraph(mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, text, allowInlineLinks)) +} + +// renderDiscordMarkdownOnlyHTMLNoUnwrap converts Discord-flavored Markdown text to HTML. +func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridgev2.Portal, text string, allowInlineLinks bool) string { + text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement) + + var buf strings.Builder + ctx := parser.NewContext() + ctx.Set(parserContextPortal, portal) + renderer := discordRenderer + if allowInlineLinks { + renderer = discordRendererWithInlineLinks + } + err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx)) + if err != nil { + panic(fmt.Errorf("markdown parser errored: %w", err)) + } + return buf.String() +} + +// TODO(skip): Stopping here for now. Continue at formatterContextPortalKey. diff --git a/pkg/msgconv/formatter_everyone.go b/pkg/msgconv/formatter_everyone.go new file mode 100644 index 0000000..8e87013 --- /dev/null +++ b/pkg/msgconv/formatter_everyone.go @@ -0,0 +1,110 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "fmt" + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type astDiscordEveryone struct { + ast.BaseInline + onlyHere bool +} + +var _ ast.Node = (*astDiscordEveryone)(nil) +var astKindDiscordEveryone = ast.NewNodeKind("DiscordEveryone") + +func (n *astDiscordEveryone) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func (n *astDiscordEveryone) Kind() ast.NodeKind { + return astKindDiscordEveryone +} + +func (n *astDiscordEveryone) String() string { + if n.onlyHere { + return "@here" + } + return "@everyone" +} + +type discordEveryoneParser struct{} + +var discordEveryoneRegex = regexp.MustCompile(`@(everyone|here)`) +var defaultDiscordEveryoneParser = &discordEveryoneParser{} + +func (s *discordEveryoneParser) Trigger() []byte { + return []byte{'@'} +} + +func (s *discordEveryoneParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, _ := block.PeekLine() + match := discordEveryoneRegex.FindSubmatch(line) + if match == nil { + return nil + } + block.Advance(len(match[0])) + return &astDiscordEveryone{ + onlyHere: string(match[1]) == "here", + } +} + +func (s *discordEveryoneParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type discordEveryoneHTMLRenderer struct{} + +func (r *discordEveryoneHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(astKindDiscordEveryone, r.renderDiscordEveryone) +} + +func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { + status = ast.WalkContinue + if !entering { + return + } + mention, _ := n.(*astDiscordEveryone) + class := "everyone" + if mention != nil && mention.onlyHere { + class = "here" + } + _, _ = fmt.Fprintf(w, `@room`, class) + return +} + +type discordEveryone struct{} + +var ExtDiscordEveryone = &discordEveryone{} + +func (e *discordEveryone) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(defaultDiscordEveryoneParser, 600), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(&discordEveryoneHTMLRenderer{}, 600), + )) +} diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go index b03bf19..3252a8f 100644 --- a/pkg/msgconv/formatter_tag.go +++ b/pkg/msgconv/formatter_tag.go @@ -16,7 +16,77 @@ package msgconv -// TODO(skip): Port the rest of this. +import ( + "context" + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + "go.mau.fi/mautrix-discord/pkg/discordid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type astDiscordTag struct { + ast.BaseInline + portal *bridgev2.Portal + id int64 +} + +var _ ast.Node = (*astDiscordTag)(nil) +var astKindDiscordTag = ast.NewNodeKind("DiscordTag") + +func (n *astDiscordTag) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func (n *astDiscordTag) Kind() ast.NodeKind { + return astKindDiscordTag +} + +type astDiscordUserMention struct { + astDiscordTag + hasNick bool +} + +func (n *astDiscordUserMention) String() string { + if n.hasNick { + return fmt.Sprintf("<@!%d>", n.id) + } + return fmt.Sprintf("<@%d>", n.id) +} + +type astDiscordRoleMention struct { + astDiscordTag +} + +func (n *astDiscordRoleMention) String() string { + return fmt.Sprintf("<@&%d>", n.id) +} + +type astDiscordChannelMention struct { + astDiscordTag + + guildID int64 + name string +} + +func (n *astDiscordChannelMention) String() string { + if n.guildID != 0 { + return fmt.Sprintf("<#%d:%d:%s>", n.id, n.guildID, n.name) + } + return fmt.Sprintf("<#%d>", n.id) +} type discordTimestampStyle rune @@ -38,3 +108,237 @@ func (dts discordTimestampStyle) Format() string { return "2 January 2006 15:04 MST" } } + +type astDiscordTimestamp struct { + astDiscordTag + + timestamp int64 + style discordTimestampStyle +} + +func (n *astDiscordTimestamp) String() string { + if n.style == 'f' { + return fmt.Sprintf("", n.timestamp) + } + return fmt.Sprintf("", n.timestamp, n.style) +} + +type astDiscordCustomEmoji struct { + astDiscordTag + name string + animated bool +} + +func (n *astDiscordCustomEmoji) String() string { + if n.animated { + return fmt.Sprintf("", n.name, n.id) + } + return fmt.Sprintf("<%s%d>", n.name, n.id) +} + +type discordTagParser struct{} + +// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting +var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`) +var defaultDiscordTagParser = &discordTagParser{} + +func (s *discordTagParser) Trigger() []byte { + return []byte{'<'} +} + +var parserContextPortal = parser.NewContextKey() + +func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + portal := pc.Get(parserContextPortal).(*bridgev2.Portal) + //before := block.PrecendingCharacter() + line, _ := block.PeekLine() + match := discordTagRegex.FindSubmatch(line) + if match == nil { + return nil + } + //seg := segment.WithStop(segment.Start + len(match[0])) + block.Advance(len(match[0])) + + id, err := strconv.ParseInt(string(match[2]), 10, 64) + if err != nil { + return nil + } + tag := astDiscordTag{id: id, portal: portal} + tagName := string(match[1]) + switch { + case tagName == "@": + return &astDiscordUserMention{astDiscordTag: tag} + case tagName == "@!": + return &astDiscordUserMention{astDiscordTag: tag, hasNick: true} + case tagName == "@&": + return &astDiscordRoleMention{astDiscordTag: tag} + case tagName == "#": + var guildID int64 + var channelName string + if len(match[4]) > 0 && len(match[5]) > 0 { + guildID, _ = strconv.ParseInt(string(match[4]), 10, 64) + channelName = string(match[5]) + } + return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName} + case tagName == "t:": + var style discordTimestampStyle + if len(match[3]) == 0 { + style = 'f' + } else { + style = discordTimestampStyle(match[3][0]) + } + return &astDiscordTimestamp{ + astDiscordTag: tag, + timestamp: id, + style: style, + } + case strings.HasPrefix(tagName, ":"): + return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag} + case strings.HasPrefix(tagName, "a:"): + return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true} + default: + return nil + } +} + +func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) { + // nothing to do +} + +type discordTagHTMLRenderer struct{} + +var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{} + +func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(astKindDiscordTag, r.renderDiscordMention) +} + +func relativeTimeFormat(ts time.Time) string { + now := time.Now() + if ts.Year() >= 2262 { + return "date out of range for relative format" + } + duration := ts.Sub(now) + word := "in %s" + if duration < 0 { + duration = -duration + word = "%s ago" + } + var count int + var unit string + switch { + case duration < time.Second: + count = int(duration.Milliseconds()) + unit = "millisecond" + case duration < time.Minute: + count = int(math.Round(duration.Seconds())) + unit = "second" + case duration < time.Hour: + count = int(math.Round(duration.Minutes())) + unit = "minute" + case duration < 24*time.Hour: + count = int(math.Round(duration.Hours())) + unit = "hour" + case duration < 30*24*time.Hour: + count = int(math.Round(duration.Hours() / 24)) + unit = "day" + case duration < 365*24*time.Hour: + count = int(math.Round(duration.Hours() / 24 / 30)) + unit = "month" + default: + count = int(math.Round(duration.Hours() / 24 / 365)) + unit = "year" + } + var diff string + if count == 1 { + diff = fmt.Sprintf("a %s", unit) + } else { + diff = fmt.Sprintf("%d %ss", count, unit) + } + return fmt.Sprintf(word, diff) +} + +func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { + status = ast.WalkContinue + if !entering { + return + } + + ctx := context.TODO() + + switch node := n.(type) { + case *astDiscordUserMention: + var mxid id.UserID + var name string + if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, networkid.UserID(strconv.FormatInt(node.id, 10))); ghost != nil { + mxid = ghost.Intent.GetMXID() + name = ghost.Name + } + _, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), name) + return + case *astDiscordRoleMention: + // FIXME(skip): Implement. + // role := node.portal.Bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10)) + // if role != nil { + _, _ = fmt.Fprintf(w, `@unknown-role`) + // _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name) + return + // } + case *astDiscordChannelMention: + if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID( + strconv.FormatInt(node.id, 10), + )); portal != nil { + if portal.MXID != "" { + _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.Bridge.Matrix.ServerName()).MatrixToURL(), portal.Name) + } else { + _, _ = w.WriteString(portal.Name) + } + return + } + case *astDiscordCustomEmoji: + // FIXME(skip): Implement. + _, _ = fmt.Fprintf(w, `(emoji)`) + // reactionMXC := node.portal.Bridge.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated) + // if !reactionMXC.IsEmpty() { + // attrs := "data-mx-emoticon" + // if node.animated { + // attrs += " data-mau-animated-emoji" + // } + // _, _ = fmt.Fprintf(w, `%[2]s`, reactionMXC.String(), node.name, attrs) + // return + // } + case *astDiscordTimestamp: + ts := time.Unix(node.timestamp, 0).UTC() + var formatted string + if node.style == 'R' { + formatted = relativeTimeFormat(ts) + } else { + formatted = ts.Format(node.style.Format()) + } + // https://github.com/matrix-org/matrix-spec-proposals/pull/3160 + const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700" + fullRFC := ts.Format(fullDatetimeFormat) + fullHumanReadable := ts.Format(discordTimestampStyle('F').Format()) + _, _ = fmt.Fprintf(w, ``, fullHumanReadable, fullRFC, node.style, formatted) + } + stringifiable, ok := n.(fmt.Stringer) + if ok { + _, _ = w.WriteString(stringifiable.String()) + } else { + _, _ = w.Write(source) + } + return +} + +type discordTag struct{} + +var ExtDiscordTag = &discordTag{} + +func (e *discordTag) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(defaultDiscordTagParser, 600), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(defaultDiscordTagHTMLRenderer, 600), + )) +} diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 66cbb4f..63156b3 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -26,7 +26,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/mautrix-discord/pkg/connector" + "go.mau.fi/mautrix-discord/pkg/attachment" + "go.mau.fi/mautrix-discord/pkg/discordid" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" @@ -111,6 +112,11 @@ func (mc *MessageConverter) ToMatrix( // puppet.addMemberMeta(part, msg) // } + // Assign incrementing part IDs. + for i, part := range parts { + part.ID = networkid.PartID(strconv.Itoa(i)) + } + return &bridgev2.ConvertedMessage{Parts: parts} } @@ -143,7 +149,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent var htmlParts []string if msg.Interaction != nil { - ghost, err := mc.connector.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) + ghost, err := mc.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. if err == nil { htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) @@ -154,22 +160,22 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent if msg.Content != "" && !isPlainGifMessage(msg) { // Bridge basic text messages. - htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(msg.Content, true)) + htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(portal, msg.Content, true)) } else if msg.MessageReference != nil && msg.MessageReference.Type == discordgo.MessageReferenceTypeForward && len(msg.MessageSnapshots) > 0 && msg.MessageSnapshots[0].Message != nil { // Bridge forwarded messages. - forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true) + forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") origLink := fmt.Sprintf("unknown channel • %s", msgTSText) - if forwardedFromPortal, err := mc.connector.Bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { - if origMessage, err := mc.connector.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { + if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { // We've bridged the message that was forwarded, so we can link to it directly. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.connector.Bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -179,7 +185,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent // We don't have the message but we have the portal, so link to that. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.URI(mc.connector.Bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -264,10 +270,10 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent } } - upload := connector.AttachmentReupload{ + upload := attachment.AttachmentReupload{ DownloadingURL: proxyURL, } - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, upload) + reupload, err := mc.ReuploadMedia(ctx, intent, portal, upload) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") return &bridgev2.ConvertedMessagePart{ @@ -282,10 +288,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent MimeType: reupload.MimeType, Size: reupload.DownloadedSize, }, - File: &event.EncryptedFileInfo{ - EncryptedFile: reupload.EncryptedFile.EncryptedFile, - URL: reupload.MXC, - }, + File: reupload.EncryptedFile, } if embed.Video != nil { @@ -321,14 +324,6 @@ func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent panic("unimplemented") } -func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string { - panic("unimplemented") -} - -func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string { - panic("unimplemented") -} - const ( embedHTMLWrapper = `
    %s
    ` embedHTMLWrapperColor = `
    %s
    ` @@ -361,7 +356,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) if embed.Author.ProxyIconURL != "" { - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Author.ProxyIconURL, }) @@ -376,7 +371,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b if embed.Title != "" { var titleHTML string - baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(embed.Title, false) + baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false) if embed.URL != "" { titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) } else { @@ -386,7 +381,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } if embed.Description != "" { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(embed.Description, true))) + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(portal, embed.Description, true))) } for i := 0; i < len(embed.Fields); i++ { @@ -405,21 +400,21 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b headerParts := make([]string, len(splitItems)) contentParts := make([]string, len(splitItems)) for j, splitItem := range splitItems { - headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) - contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(splitItem.Value, true)) + headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false)) + contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true)) } htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) } else { htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, strconv.FormatBool(item.Inline), - mc.renderDiscordMarkdownOnlyHTML(item.Name, false), - mc.renderDiscordMarkdownOnlyHTML(item.Value, true), + mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false), + mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true), )) } } if embed.Image != nil { - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Image.ProxyURL, }) if err != nil { @@ -449,7 +444,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) if embed.Footer.ProxyIconURL != "" { - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Footer.ProxyIconURL, }) @@ -478,7 +473,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: url, }) if err != nil { @@ -556,7 +551,7 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent } // TODO(skip): Support direct media. - reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: att.URL, }) if err != nil { @@ -572,10 +567,8 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent content.Info.Width = att.Width content.Info.Height = att.Height } - content.File = &event.EncryptedFileInfo{ - EncryptedFile: reupload.EncryptedFile.EncryptedFile, - URL: reupload.MXC, - } + content.URL = reupload.MXC + content.File = reupload.EncryptedFile return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 15237df..89c9012 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -17,9 +17,19 @@ package msgconv import ( - "go.mau.fi/mautrix-discord/pkg/connector" + "context" + + "go.mau.fi/mautrix-discord/pkg/attachment" + "maunium.net/go/mautrix/bridgev2" ) type MessageConverter struct { - connector *connector.DiscordConnector + Bridge *bridgev2.Bridge + + // ReuploadMedia is called when the message converter wants to upload some + // media it is attempting to bridge. + // + // This can be directly forwarded to the ReuploadMedia method on DiscordConnector. + // The indirection is only necessary to prevent an import cycle. + ReuploadMedia func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) } From 7b32aad13fbfb82ad78dc9396a1b71d652fb0b08 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 26 Nov 2025 18:28:50 -0800 Subject: [PATCH 35/99] attachments: remove trace logging This was for debugging in development only. Also, bridges default to the DEBUG level, so this would not be logged at all unless you intentionally modified your logging configuration. --- pkg/connector/attachments.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go index eada633..ae12af1 100644 --- a/pkg/connector/attachments.go +++ b/pkg/connector/attachments.go @@ -69,7 +69,6 @@ func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]b } func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) { - log := zerolog.Ctx(ctx) // TODO(skip): Do we need to check if we've already downloaded this media before? // TODO(skip): Read a maximum size from the config. data, err := downloadDiscordAttachment(http.DefaultClient, upload.DownloadingURL, 1_024*1_024*50) @@ -84,20 +83,13 @@ func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.Ma } fileName := path.Base(url.Path) upload.FileName = fileName - log.Trace().Str("detected_file_name", fileName).Msg("Inferred the file name of the media we're reuploading") } if upload.MimeType == "" { mime := http.DetectContentType(data) upload.MimeType = mime - log.Trace().Str("detected_mime_type", mime).Msg("Inferred the mime type of the media we're reuploading") } - log.Trace().Stringer("portal_mxid", portal.MXID). - Int("attachment_size", len(data)). - Str("file_name", upload.FileName). - Str("mime_type", upload.MimeType). - Msg("Uploading downloaded media") mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, upload.FileName, upload.MimeType) if err != nil { return nil, err From d464cb8b6619eb87040de1631db21a0a5f38e9b8 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 9 Dec 2025 17:43:48 -0800 Subject: [PATCH 36/99] bridge discord messages to matrix --- pkg/connector/attachments.go | 1 - pkg/connector/backfill.go | 7 +- pkg/connector/client.go | 20 ++++-- pkg/connector/connector.go | 8 ++- pkg/connector/handlediscord.go | 118 +++++++++++++++++++++++++++++++++ pkg/connector/login.go | 3 - 6 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 pkg/connector/handlediscord.go diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go index ae12af1..fe2ccc3 100644 --- a/pkg/connector/attachments.go +++ b/pkg/connector/attachments.go @@ -27,7 +27,6 @@ import ( "strconv" "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" "go.mau.fi/mautrix-discord/pkg/attachment" "maunium.net/go/mautrix/bridgev2" ) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 99dee51..e7606e8 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -23,7 +23,6 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -65,10 +64,6 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 } converted := make([]*bridgev2.BackfillMessage, 0, len(msgs)) - mc := msgconv.MessageConverter{ - Bridge: dc.connector.Bridge, - ReuploadMedia: dc.connector.ReuploadMedia, - } for _, msg := range msgs { streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) ts, _ := discordgo.SnowflakeTimestamp(msg.ID) @@ -90,7 +85,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 converted = append(converted, &bridgev2.BackfillMessage{ ID: networkid.MessageID(msg.ID), - ConvertedMessage: mc.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg), + ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg), Sender: sender, Timestamp: ts, StreamOrder: streamOrder, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index f43b115..5fd5f08 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -28,6 +28,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "go.mau.fi/mautrix-discord/pkg/discordid" + "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -40,6 +41,7 @@ type DiscordClient struct { UserLogin *bridgev2.UserLogin Session *discordgo.Session hasBegunSyncing bool + MsgConv msgconv.MessageConverter } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -59,14 +61,16 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us return err } - // FIXME(skip): Implement. - session.EventHandler = func(evt any) {} - - login.Client = &DiscordClient{ + cl := DiscordClient{ connector: d, UserLogin: login, Session: session, + MsgConv: msgconv.MessageConverter{ + Bridge: d.Bridge, + ReuploadMedia: d.ReuploadMedia, + }, } + login.Client = &cl return nil } @@ -97,10 +101,18 @@ func (d *DiscordClient) Connect(ctx context.Context) { }) } +func (cl *DiscordClient) handleDiscordEventSync(event any) { + go cl.handleDiscordEvent(event) +} + func (cl *DiscordClient) connect(ctx context.Context) error { log := zerolog.Ctx(ctx) log.Info().Msg("Opening session") + cl.Session.EventHandler = func(event any) { + go cl.handleDiscordEvent(event) + } + err := cl.Session.Open() for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 { log.Err(err).Int("attempts", attempts).Msg("Immediately disconnected while trying to open session, trying again in 5 seconds") diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 56c156b..cf3a1fd 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -19,17 +19,23 @@ package connector import ( "context" + "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" ) type DiscordConnector struct { - Bridge *bridgev2.Bridge + Bridge *bridgev2.Bridge + MsgConv *msgconv.MessageConverter } var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge + d.MsgConv = &msgconv.MessageConverter{ + Bridge: bridge, + ReuploadMedia: d.ReuploadMedia, + } } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go new file mode 100644 index 0000000..7ea84f0 --- /dev/null +++ b/pkg/connector/handlediscord.go @@ -0,0 +1,118 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "runtime/debug" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type DiscordEventMeta struct { + Type bridgev2.RemoteEventType + PortalKey networkid.PortalKey + LogContext func(c zerolog.Context) zerolog.Context +} + +func (em *DiscordEventMeta) AddLogContext(c zerolog.Context) zerolog.Context { + if em.LogContext == nil { + return c + } + c = em.LogContext(c) + return c +} + +func (em *DiscordEventMeta) GetType() bridgev2.RemoteEventType { + return em.Type +} + +func (em *DiscordEventMeta) GetPortalKey() networkid.PortalKey { + return em.PortalKey +} + +type DiscordMessage struct { + *DiscordEventMeta + Data *discordgo.Message + Client *DiscordClient +} + +var ( + _ bridgev2.RemoteMessage = (*DiscordMessage)(nil) + // _ bridgev2.RemoteEdit = (*DiscordMessage)(nil) + // _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil) +) + +func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { + return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Data), nil +} + +func (m *DiscordMessage) GetID() networkid.MessageID { + return networkid.MessageID(m.Data.ID) +} + +func (m *DiscordMessage) GetSender() bridgev2.EventSender { + return m.Client.makeEventSender(m.Data.Author) +} + +func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) DiscordMessage { + return DiscordMessage{ + DiscordEventMeta: &DiscordEventMeta{ + Type: bridgev2.RemoteEventMessage, + PortalKey: networkid.PortalKey{ + ID: networkid.PortalID(evt.ChannelID), + Receiver: d.UserLogin.ID, + }, + }, + Data: evt.Message, + Client: d, + } +} + +func (d *DiscordClient) handleDiscordEvent(rawEvt any) { + defer func() { + err := recover() + if err != nil { + d.UserLogin.Log.Error(). + Bytes(zerolog.ErrorStackFieldName, debug.Stack()). + Any(zerolog.ErrorFieldName, err). + Msg("Panic in Discord event handler") + } + }() + + log := d.UserLogin.Log.With().Str("action", "handle discord event"). + Type("event_type", rawEvt). + Logger() + + switch evt := rawEvt.(type) { + case *discordgo.MessageCreate: + wrappedEvt := d.wrapDiscordMessage(evt) + d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) + case *discordgo.PresenceUpdate: + return + case *discordgo.Event: + // For presently unknown reasons sometimes discordgo won't unmarshal + // events into their proper corresponding structs. + if evt.Type == "PRESENCE_UPDATE" || evt.Type == "PASSIVE_UPDATE_V2" || evt.Type == "CONVERSATION_SUMMARY_UPDATE" { + return + } + log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event") + } +} diff --git a/pkg/connector/login.go b/pkg/connector/login.go index efea9d1..b5579da 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -94,9 +94,6 @@ func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]st return nil, fmt.Errorf("couldn't create discord session: %w", err) } - // FIXME(skip): Implement. - session.EventHandler = func(evt any) {} - // Set up logging. session.LogLevel = discordgo.LogInformational session.Logger = func(msgL, caller int, format string, a ...any) { From 25b73bd7cb585519ffe41d2235e6281bdc97fba1 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 11 Dec 2025 18:32:31 -0800 Subject: [PATCH 37/99] handlediscord: bail if we're in the middle of provisioning --- pkg/connector/handlediscord.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 7ea84f0..7aaaf9d 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -87,6 +87,16 @@ func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) Discord } func (d *DiscordClient) handleDiscordEvent(rawEvt any) { + if d.UserLogin == nil { + // Our event handlers are able to assume that a UserLogin is available. + // We respond to special events like READY outside of this function, + // by virtue of methods like Session.Open only returning control flow + // after RESUME or READY. + log := zerolog.Ctx(context.TODO()) + log.Trace().Msg("Dropping Discord event received before UserLogin creation") + return + } + defer func() { err := recover() if err != nil { From 506f42f93b300b4ffb12c79fb2ee395d11a687f6 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 11 Dec 2025 19:17:57 -0800 Subject: [PATCH 38/99] bridge basic emoji reactions from gateway to matrix --- pkg/connector/client.go | 12 +++-- pkg/connector/handlediscord.go | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 5fd5f08..4d3e333 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -234,14 +234,18 @@ func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { } } -func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { +func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender { return bridgev2.EventSender{ - IsFromMe: user.ID == d.Session.State.User.ID, - SenderLogin: networkid.UserLoginID(user.ID), - Sender: networkid.UserID(user.ID), + IsFromMe: userID == d.Session.State.User.ID, + SenderLogin: networkid.UserLoginID(userID), + Sender: networkid.UserID(userID), } } +func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { + return d.makeEventSenderWithID(user.ID) +} + func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { isGroup := len(ch.RecipientIDs) > 1 diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 7aaaf9d..1c68c16 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -18,6 +18,7 @@ package connector import ( "context" + "fmt" "runtime/debug" "github.com/bwmarrin/discordgo" @@ -86,6 +87,79 @@ func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) Discord } } +type DiscordReaction struct { + *DiscordEventMeta + Reaction *discordgo.MessageReaction + Client *DiscordClient +} + +func (r *DiscordReaction) GetSender() bridgev2.EventSender { + return r.Client.makeEventSenderWithID(r.Reaction.UserID) +} + +func (r *DiscordReaction) GetTargetMessage() networkid.MessageID { + return networkid.MessageID(r.Reaction.MessageID) +} + +func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID { + return networkid.EmojiID(r.Reaction.Emoji.Name) +} + +var ( + _ bridgev2.RemoteReaction = (*DiscordReaction)(nil) + _ bridgev2.RemoteReactionRemove = (*DiscordReaction)(nil) + _ bridgev2.RemoteReactionWithExtraContent = (*DiscordReaction)(nil) +) + +func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) { + // name is either a grapheme cluster consisting of a Unicode emoji, or the + // name of a custom emoji. + name := r.Reaction.Emoji.Name + return name, networkid.EmojiID(name) +} + +func (r *DiscordReaction) GetReactionExtraContent() map[string]any { + extra := make(map[string]any) + + reaction := r.Reaction + emoji := reaction.Emoji + + if emoji.ID != "" { + // The emoji is a custom emoji. + + extra["fi.mau.discord.reaction"] = map[string]any{ + "id": emoji.ID, + "name": emoji.Name, + // FIXME Handle custom emoji. + // "mxc": reaction, + } + + wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name) + extra["com.beeper.reaction.shortcode"] = wrappedShortcode + } + + return extra +} + +func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction, beingAdded bool) DiscordReaction { + evtType := bridgev2.RemoteEventReaction + if !beingAdded { + evtType = bridgev2.RemoteEventReactionRemove + } + + return DiscordReaction{ + DiscordEventMeta: &DiscordEventMeta{ + Type: evtType, + PortalKey: networkid.PortalKey{ + ID: networkid.PortalID(reaction.ChannelID), + Receiver: d.UserLogin.ID, + }, + }, + Reaction: reaction, + Client: d, + } +} + func (d *DiscordClient) handleDiscordEvent(rawEvt any) { if d.UserLogin == nil { // Our event handlers are able to assume that a UserLogin is available. @@ -97,6 +171,13 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { return } + if d.Session == nil || d.Session.State == nil || d.Session.State.User == nil { + // Our event handlers are able to assume that we've fully connected to the + // gateway. + d.UserLogin.Log.Debug().Msg("Dropping Discord event received before READY or RESUMED") + return + } + defer func() { err := recover() if err != nil { @@ -115,6 +196,14 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { case *discordgo.MessageCreate: wrappedEvt := d.wrapDiscordMessage(evt) d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) + case *discordgo.MessageReactionAdd: + wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true) + d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) + case *discordgo.MessageReactionRemove: + wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, false) + d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) + // TODO case *discordgo.MessageReactionRemoveAll: + // TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo) case *discordgo.PresenceUpdate: return case *discordgo.Event: From ab82f8b131834c05fc2a62b5a8fd48137b892c8f Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 11 Dec 2025 19:50:16 -0800 Subject: [PATCH 39/99] backfill: document why we aren't backfilling reactions (for now) --- pkg/connector/backfill.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index e7606e8..05d4961 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -68,7 +68,22 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) ts, _ := discordgo.SnowflakeTimestamp(msg.ID) - // FIXME(skip): Backfill reactions. + // NOTE: For now, we aren't backfilling reactions. This is because: + // + // - Discord does not provide enough historical reaction data in the + // response from the message history endpoint to construct valid + // BackfillReactions. + // - Fetching the reaction data would be prohibitively expensive for + // messages with many reactions. Messages in large guilds can have + // tens of thousands of reactions. + // - Indicating aggregated child events[1] from BackfillMessage doesn't + // seem possible due to how portal backfilling batching currently + // works. + // + // [1]: https://spec.matrix.org/v1.16/client-server-api/#reference-relations + // + // It might be worth fetching the reaction data anyways if we observe + // a small overall number of reactions. sender := dc.makeEventSender(msg.Author) // Use the ghost's intent, falling back to the bridge's. From 60171b4fca9a95f2af771327c2e4e20d4dbf43e9 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 11 Dec 2025 19:55:00 -0800 Subject: [PATCH 40/99] lint --- pkg/connector/attachments.go | 3 ++- pkg/connector/client.go | 9 ++++----- pkg/connector/connector.go | 3 ++- pkg/msgconv/embed.go | 3 ++- pkg/msgconv/formatter_tag.go | 3 ++- pkg/msgconv/from-discord.go | 5 +++-- pkg/msgconv/msgconv.go | 3 ++- 7 files changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go index fe2ccc3..cec764e 100644 --- a/pkg/connector/attachments.go +++ b/pkg/connector/attachments.go @@ -27,8 +27,9 @@ import ( "strconv" "github.com/bwmarrin/discordgo" - "go.mau.fi/mautrix-discord/pkg/attachment" "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-discord/pkg/attachment" ) func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4d3e333..42fed05 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -27,12 +27,13 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/mautrix-discord/pkg/discordid" - "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" + + "go.mau.fi/mautrix-discord/pkg/discordid" + "go.mau.fi/mautrix-discord/pkg/msgconv" ) type DiscordClient struct { @@ -109,9 +110,7 @@ func (cl *DiscordClient) connect(ctx context.Context) error { log := zerolog.Ctx(ctx) log.Info().Msg("Opening session") - cl.Session.EventHandler = func(event any) { - go cl.handleDiscordEvent(event) - } + cl.Session.EventHandler = cl.handleDiscordEventSync err := cl.Session.Open() for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index cf3a1fd..ba1fd09 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -19,8 +19,9 @@ package connector import ( "context" - "go.mau.fi/mautrix-discord/pkg/msgconv" "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-discord/pkg/msgconv" ) type DiscordConnector struct { diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go index 79c1f96..9f63f7d 100644 --- a/pkg/msgconv/embed.go +++ b/pkg/msgconv/embed.go @@ -37,7 +37,8 @@ const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff // don't contain < or whitespace anywhere, and don't end with "'),.:;] // // Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason -var discordLinkRegex = regexp.MustCompile(discordLinkPattern) +// FIXME(skip): This will be unused until we port `escapeDiscordMarkdown`. +// var discordLinkRegex = regexp.MustCompile(discordLinkPattern) var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go index 3252a8f..cc107a9 100644 --- a/pkg/msgconv/formatter_tag.go +++ b/pkg/msgconv/formatter_tag.go @@ -31,10 +31,11 @@ import ( "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" - "go.mau.fi/mautrix-discord/pkg/discordid" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) type astDiscordTag struct { diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 63156b3..95519a3 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -26,12 +26,13 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/mautrix-discord/pkg/attachment" - "go.mau.fi/mautrix-discord/pkg/discordid" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" + + "go.mau.fi/mautrix-discord/pkg/attachment" + "go.mau.fi/mautrix-discord/pkg/discordid" ) func (mc *MessageConverter) ToMatrix( diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 89c9012..7d51f84 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -19,8 +19,9 @@ package msgconv import ( "context" - "go.mau.fi/mautrix-discord/pkg/attachment" "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-discord/pkg/attachment" ) type MessageConverter struct { From d82b74fb29cad47282507fc85335fca6d45d0aa6 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 16 Dec 2025 18:35:29 -0800 Subject: [PATCH 41/99] handlematrix: handle basic matrix rich text messages Added the necessary room capabilities, too. Support for replies, editing, deletion, and attachments are forthcoming. --- pkg/connector/capabilities.go | 26 +++++++++ pkg/connector/handlematrix.go | 38 ++++++++++++- pkg/msgconv/embed.go | 3 +- pkg/msgconv/formatter.go | 76 ++++++++++++++++++++++++- pkg/msgconv/from-matrix.go | 104 ++++++++++++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 pkg/msgconv/from-matrix.go diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 4728513..aceb5d5 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -56,6 +56,32 @@ func capID() string { var discordCaps = &event.RoomFeatures{ ID: capID(), + Formatting: event.FormattingFeatureMap{ + event.FmtBold: event.CapLevelFullySupported, + event.FmtItalic: event.CapLevelFullySupported, + event.FmtStrikethrough: event.CapLevelFullySupported, + event.FmtInlineCode: event.CapLevelFullySupported, + event.FmtCodeBlock: event.CapLevelFullySupported, + event.FmtSyntaxHighlighting: event.CapLevelFullySupported, + event.FmtBlockquote: event.CapLevelFullySupported, + event.FmtInlineLink: event.CapLevelFullySupported, + event.FmtUserLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support. + event.FmtUnorderedList: event.CapLevelFullySupported, + event.FmtOrderedList: event.CapLevelFullySupported, + event.FmtListStart: event.CapLevelFullySupported, + event.FmtListJumpValue: event.CapLevelUnsupported, + event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support. + }, + LocationMessage: event.CapLevelUnsupported, + // TODO: This limit is increased depending on Discord subscription (Nitro). + MaxTextLength: 2000, + // TODO: Support reactions. + // TODO: Support threads. + // TODO: Support editing. + // TODO: Support message deletion. } func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index d8084f9..ab6d89f 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -19,8 +19,10 @@ package connector import ( "context" + "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" ) var ( @@ -31,9 +33,39 @@ var ( _ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil) ) -func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { - //TODO implement me - panic("implement me") +func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + if d.Session == nil { + return nil, bridgev2.ErrNotLoggedIn + } + + portal := msg.Portal + channelID := string(portal.ID) + + // TODO: Support replies. + + sendReq, err := d.connector.MsgConv.ToDiscord(ctx, msg) + if err != nil { + return nil, err + } + + var options []discordgo.RequestOption + // TODO: When supporting threads (and not a bot user), send a thread referer. + // TODO: Pass the guild ID when send messages in guild channels. + options = append(options, discordgo.WithChannelReferer("", channelID)) + + sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), &sendReq, options...) + if err != nil { + return nil, err + } + sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID) + + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: networkid.MessageID(sentMsg.ID), + SenderID: networkid.UserID(sentMsg.Author.ID), + Timestamp: sentMsgTimestamp, + }, + }, nil } func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go index 9f63f7d..79c1f96 100644 --- a/pkg/msgconv/embed.go +++ b/pkg/msgconv/embed.go @@ -37,8 +37,7 @@ const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff // 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 -// FIXME(skip): This will be unused until we port `escapeDiscordMarkdown`. -// var discordLinkRegex = regexp.MustCompile(discordLinkPattern) +var discordLinkRegex = regexp.MustCompile(discordLinkPattern) var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go index 7065bc6..8a9fa3f 100644 --- a/pkg/msgconv/formatter.go +++ b/pkg/msgconv/formatter.go @@ -19,6 +19,7 @@ package msgconv import ( "fmt" "regexp" + "slices" "strings" "github.com/yuin/goldmark" @@ -96,4 +97,77 @@ func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridge return buf.String() } -// TODO(skip): Stopping here for now. Continue at formatterContextPortalKey. +const formatterContextPortalKey = "fi.mau.discord.portal" +const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions" +const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions" +const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews" + +var discordMarkdownEscaper = strings.NewReplacer( + `\`, `\\`, + `_`, `\_`, + `*`, `\*`, + `~`, `\~`, + "`", "\\`", + `|`, `\|`, + `<`, `\<`, + `#`, `\#`, +) + +func escapeDiscordMarkdown(s string) string { + submatches := discordLinkRegex.FindAllStringIndex(s, -1) + if submatches == nil { + return discordMarkdownEscaper.Replace(s) + } + var builder strings.Builder + offset := 0 + for _, match := range submatches { + start := match[0] + end := match[1] + builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start])) + builder.WriteString(s[start:end]) + offset = end + } + builder.WriteString(discordMarkdownEscaper.Replace(s[offset:])) + return builder.String() +} + +var matrixHTMLParser = &format.HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + HorizontalLine: "\n---\n", + ItalicConverter: func(s string, ctx format.Context) string { + return fmt.Sprintf("*%s*", s) + }, + UnderlineConverter: func(s string, ctx format.Context) string { + return fmt.Sprintf("__%s__", s) + }, + TextConverter: func(s string, ctx format.Context) string { + if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") { + // If we're in a code block, don't escape markdown + return s + } + return escapeDiscordMarkdown(s) + }, + SpoilerConverter: func(text, reason string, ctx format.Context) string { + if reason != "" { + return fmt.Sprintf("(%s) ||%s||", reason, text) + } + return fmt.Sprintf("||%s||", text) + }, + LinkConverter: func(text, href string, ctx format.Context) string { + linkPreviews := ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey].([]string) + allowPreview := linkPreviews == nil || slices.Contains(linkPreviews, href) + if text == href { + if !allowPreview { + return fmt.Sprintf("<%s>", text) + } + return text + } else if !discordLinkRegexFull.MatchString(href) { + return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href)) + } else if !allowPreview { + return fmt.Sprintf("[%s](<%s>)", escapeDiscordMarkdown(text), href) + } else { + return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href) + } + }, +} diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go new file mode 100644 index 0000000..5340757 --- /dev/null +++ b/pkg/msgconv/from-matrix.go @@ -0,0 +1,104 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/bwmarrin/discordgo" + "go.mau.fi/util/variationselector" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +const discordEpochMillis = 1420070400000 + +func generateMessageNonce() string { + snowflake := (time.Now().UnixMilli() - discordEpochMillis) << 22 + // Nonce snowflakes don't have internal IDs or increments + return strconv.FormatInt(snowflake, 10) +} + +func parseAllowedLinkPreviews(raw map[string]any) []string { + if raw == nil { + return nil + } + linkPreviews, ok := raw["com.beeper.linkpreviews"].([]any) + if !ok { + return nil + } + allowedLinkPreviews := make([]string, 0, len(linkPreviews)) + for _, preview := range linkPreviews { + previewMap, ok := preview.(map[string]any) + if !ok { + continue + } + matchedURL, _ := previewMap["matched_url"].(string) + if matchedURL != "" { + allowedLinkPreviews = append(allowedLinkPreviews, matchedURL) + } + } + return allowedLinkPreviews +} + +// ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate +// for bridging the message to Discord. +func (mc *MessageConverter) ToDiscord( + ctx context.Context, + msg *bridgev2.MatrixMessage, +) (discordgo.MessageSend, error) { + var req discordgo.MessageSend + req.Nonce = generateMessageNonce() + + switch msg.Content.MsgType { + case event.MsgText, event.MsgEmote, event.MsgNotice: + req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, msg.Content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) + if msg.Content.MsgType == event.MsgEmote { + req.Content = fmt.Sprintf("_%s_", req.Content) + } + // TODO: Handle attachments. + } + + // TODO: Handle (silent) replies and allowed mentions. + + return req, nil +} + +func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) { + allowedMentions := &discordgo.MessageAllowedMentions{ + Parse: []discordgo.AllowedMentionType{}, + Users: []string{}, + RepliedUser: true, + } + + if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { + ctx := format.NewContext(ctx) + ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews + ctx.ReturnData[formatterContextPortalKey] = portal + ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions + if content.Mentions != nil { + ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs + } + return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions + } else { + return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions + } +} From 09414cb59d584b1d866a1d2e86397cffabb752c6 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 17 Dec 2025 17:49:49 -0800 Subject: [PATCH 42/99] handlediscord: drop messages lacking an author DiscordMessage's GetSender is dereferencing nil sometimes and I'm not sure why. --- pkg/connector/handlediscord.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 1c68c16..bbef78f 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -194,6 +194,14 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { switch evt := rawEvt.(type) { case *discordgo.MessageCreate: + if evt.Author == nil { + log.Trace().Int("message_type", int(evt.Message.Type)). + Str("guild_id", evt.GuildID). + Str("message_id", evt.ID). + Str("channel_id", evt.ChannelID). + Msg("Dropping message that lacks an author") + return + } wrappedEvt := d.wrapDiscordMessage(evt) d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) case *discordgo.MessageReactionAdd: From 2cacd4ec8182c396ddd2bbf9eb903407f3c63d4a Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 17 Dec 2025 18:21:57 -0800 Subject: [PATCH 43/99] msgconv: bridge outgoing replies --- pkg/connector/capabilities.go | 3 ++- pkg/msgconv/from-matrix.go | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index aceb5d5..b9c891f 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -55,7 +55,8 @@ func capID() string { } var discordCaps = &event.RoomFeatures{ - ID: capID(), + ID: capID(), + Reply: event.CapLevelFullySupported, Formatting: event.FormattingFeatureMap{ event.FmtBold: event.CapLevelFullySupported, event.FmtItalic: event.CapLevelFullySupported, diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 5340757..3729c31 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -68,6 +68,13 @@ func (mc *MessageConverter) ToDiscord( var req discordgo.MessageSend req.Nonce = generateMessageNonce() + if msg.ReplyTo != nil { + req.Reference = &discordgo.MessageReference{ + ChannelID: string(msg.ReplyTo.Room.ID), + MessageID: string(msg.ReplyTo.ID), + } + } + switch msg.Content.MsgType { case event.MsgText, event.MsgEmote, event.MsgNotice: req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, msg.Content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) From e030c9548cfcee25686917a68c506afb9d237689 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 17 Dec 2025 18:44:52 -0800 Subject: [PATCH 44/99] handlematrix: bridge outgoing reactions --- pkg/connector/capabilities.go | 5 +++-- pkg/connector/handlematrix.go | 34 +++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index b9c891f..aff026e 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -55,8 +55,9 @@ func capID() string { } var discordCaps = &event.RoomFeatures{ - ID: capID(), - Reply: event.CapLevelFullySupported, + ID: capID(), + Reply: event.CapLevelFullySupported, + Reaction: event.CapLevelFullySupported, Formatting: event.FormattingFeatureMap{ event.FmtBold: event.CapLevelFullySupported, event.FmtItalic: event.CapLevelFullySupported, diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index ab6d89f..c33017a 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -73,19 +73,35 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr panic("implement me") } -func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { - //TODO implement me - panic("implement me") +func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { + key := reaction.Content.RelatesTo.Key + // TODO: Handle custom emoji. + + return bridgev2.MatrixReactionPreResponse{ + SenderID: networkid.UserID(d.UserLogin.ID), + EmojiID: networkid.EmojiID(key), + }, nil } -func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { - //TODO implement me - panic("implement me") +func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) { + key := reaction.Content.RelatesTo.Key + portal := reaction.Portal + // TODO: Support guilds. + guildID := "" + + err := d.Session.MessageReactionAddUser(guildID, string(portal.ID), string(reaction.TargetMessage.ID), key) + return nil, err } -func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { - //TODO implement me - panic("implement me") +func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error { + removing := removal.TargetReaction + emojiID := removing.EmojiID + channelID := string(removing.Room.ID) + // TODO: Support guilds. + guildID := "" + + err := d.Session.MessageReactionRemoveUser(guildID, channelID, string(removing.MessageID), string(emojiID), string(d.UserLogin.ID)) + return err } func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { From 7a19f09683881c48861747bfe62e779bfd105030 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 17 Dec 2025 19:04:46 -0800 Subject: [PATCH 45/99] handlematrix: bridge outgoing message redactions --- pkg/connector/capabilities.go | 1 + pkg/connector/handlematrix.go | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index aff026e..f55a701 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -58,6 +58,7 @@ var discordCaps = &event.RoomFeatures{ ID: capID(), Reply: event.CapLevelFullySupported, Reaction: event.CapLevelFullySupported, + Delete: event.CapLevelFullySupported, Formatting: event.FormattingFeatureMap{ event.FmtBold: event.CapLevelFullySupported, event.FmtItalic: event.CapLevelFullySupported, diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index c33017a..e757dba 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -104,9 +104,10 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal return err } -func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { - //TODO implement me - panic("implement me") +func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error { + channelID := string(removal.Portal.ID) + messageID := string(removal.TargetMessage.ID) + return d.Session.ChannelMessageDelete(channelID, messageID) } func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { From d79406e05b8a1c39c4d036e25d16d69204bd06db Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 17 Dec 2025 19:17:29 -0800 Subject: [PATCH 46/99] handlematrix: clean up stale comment --- pkg/connector/handlematrix.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index e757dba..7e13fd7 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -41,8 +41,6 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M portal := msg.Portal channelID := string(portal.ID) - // TODO: Support replies. - sendReq, err := d.connector.MsgConv.ToDiscord(ctx, msg) if err != nil { return nil, err From 0c82f6551d021e35e14a715d031edcfc3400ba2f Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:27:05 -0800 Subject: [PATCH 47/99] login: move token login to own file, rename To make room for other login flows. --- pkg/connector/login.go | 125 +----------------------------- pkg/connector/login_token.go | 144 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 124 deletions(-) create mode 100644 pkg/connector/login_token.go diff --git a/pkg/connector/login.go b/pkg/connector/login.go index b5579da..3b46079 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -19,21 +19,11 @@ package connector import ( "context" "fmt" - "strings" - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" -) - -const ( - LoginFlowIDToken = "fi.mau.discord.login.token" ) func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { - // FIXME(skip): Provide actually user-friendly login flows. return []bridgev2.LoginFlow{ { ID: LoginFlowIDToken, @@ -48,118 +38,5 @@ func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, return nil, fmt.Errorf("unknown login flow ID") } - return &DiscordLogin{connector: d, User: user}, nil -} - -type DiscordLogin struct { - connector *DiscordConnector - User *bridgev2.User - Token string - Session *discordgo.Session -} - -var _ bridgev2.LoginProcessUserInput = (*DiscordLogin)(nil) - -func (dl *DiscordLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { - return &bridgev2.LoginStep{ - Type: bridgev2.LoginStepTypeUserInput, - StepID: "fi.mau.discord.enter_token", - UserInputParams: &bridgev2.LoginUserInputParams{ - Fields: []bridgev2.LoginInputDataField{ - { - Type: bridgev2.LoginInputFieldTypePassword, - ID: "token", - Name: "Discord user account token", - // Cribbed from https://regex101.com/r/1GMR0y/1. - Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`, - }, - }, - }, - }, nil -} - -func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { - token := input["token"] - if token == "" { - return nil, fmt.Errorf("no token provided") - } - - log := zerolog.Ctx(ctx) - - log.Info().Msg("Creating session from provided token") - dl.Token = token - - session, err := discordgo.New(token) - if err != nil { - return nil, fmt.Errorf("couldn't create discord session: %w", err) - } - - // Set up logging. - session.LogLevel = discordgo.LogInformational - session.Logger = func(msgL, caller int, format string, a ...any) { - // FIXME(skip): Hook up zerolog properly. - log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf - } - - client := DiscordClient{ - connector: dl.connector, - Session: session, - } - err = client.connect(ctx) - if err != nil { - dl.softlyCloseSession() - return nil, err - } - // At this point we've opened a WebSocket connection to the gateway, received - // a READY packet, and know who we are. - user := session.State.User - - dl.Session = session - ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ - ID: networkid.UserLoginID(user.ID), - Metadata: &UserLoginMetadata{ - Token: token, - HeartbeatSession: session.HeartbeatSession, - }, - }, &bridgev2.NewLoginParams{ - // We already have a Session; let's call this instead of the connector's - // main LoadUserLogin method, and thread the Session through. - LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { - login.Client = &client - client.UserLogin = login - - // Only now that we have a UserLogin can we begin syncing. - client.BeginSyncingIfUserLoginPresent(ctx) - return nil - }, - DeleteOnConflict: true, - DontReuseExisting: false, - }) - if err != nil { - dl.softlyCloseSession() - return nil, fmt.Errorf("couldn't create login: %w", err) - } - zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") - - return &bridgev2.LoginStep{ - Type: bridgev2.LoginStepTypeComplete, - StepID: "fi.mau.discord.complete", - Instructions: fmt.Sprintf("Logged in as %s", user), - CompleteParams: &bridgev2.LoginCompleteParams{ - UserLoginID: ul.ID, - UserLogin: ul, - }, - }, nil -} - -func (dl *DiscordLogin) softlyCloseSession() { - dl.User.Log.Debug().Msg("Closing session") - err := dl.Session.Close() - if err != nil { - dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") - } -} - -func (dl *DiscordLogin) Cancel() { - dl.softlyCloseSession() + return &DiscordTokenLogin{connector: d, User: user}, nil } diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go new file mode 100644 index 0000000..611b4f5 --- /dev/null +++ b/pkg/connector/login_token.go @@ -0,0 +1,144 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const LoginFlowIDToken = "fi.mau.discord.login.token" + +type DiscordTokenLogin struct { + connector *DiscordConnector + User *bridgev2.User + Token string + Session *discordgo.Session +} + +var _ bridgev2.LoginProcessUserInput = (*DiscordTokenLogin)(nil) + +func (dl *DiscordTokenLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: "fi.mau.discord.enter_token", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "token", + Name: "Discord user account token", + // Cribbed from https://regex101.com/r/1GMR0y/1. + Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`, + }, + }, + }, + }, nil +} + +func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + token := input["token"] + if token == "" { + return nil, fmt.Errorf("no token provided") + } + + log := zerolog.Ctx(ctx) + + log.Info().Msg("Creating session from provided token") + dl.Token = token + + session, err := discordgo.New(token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + // Set up logging. + session.LogLevel = discordgo.LogInformational + session.Logger = func(msgL, caller int, format string, a ...any) { + // FIXME(skip): Hook up zerolog properly. + log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf + } + + client := DiscordClient{ + connector: dl.connector, + Session: session, + } + err = client.connect(ctx) + if err != nil { + dl.softlyCloseSession() + return nil, err + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + user := session.State.User + + dl.Session = session + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.ID), + Metadata: &UserLoginMetadata{ + Token: token, + HeartbeatSession: session.HeartbeatSession, + }, + }, &bridgev2.NewLoginParams{ + // We already have a Session; let's call this instead of the connector's + // main LoadUserLogin method, and thread the Session through. + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &client + client.UserLogin = login + + // Only now that we have a UserLogin can we begin syncing. + client.BeginSyncingIfUserLoginPresent(ctx) + return nil + }, + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + dl.softlyCloseSession() + return nil, fmt.Errorf("couldn't create login: %w", err) + } + zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: "fi.mau.discord.complete", + Instructions: fmt.Sprintf("Logged in as %s", user), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (dl *DiscordTokenLogin) softlyCloseSession() { + dl.User.Log.Debug().Msg("Closing session") + err := dl.Session.Close() + if err != nil { + dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + } +} + +func (dl *DiscordTokenLogin) Cancel() { + dl.softlyCloseSession() +} From 2c669413cc94e65a8dfaab4725b459edbd765441 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:28:07 -0800 Subject: [PATCH 48/99] login/token: remove misleading comment about `LoadUserLogin` This comment seems to imply that specifying this function is a shortcut of sorts, when it's actually required and mautrix-go doesn't fallback to the connector in the way described. --- pkg/connector/login_token.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index 611b4f5..7a9ce90 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -101,8 +101,6 @@ func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[stri HeartbeatSession: session.HeartbeatSession, }, }, &bridgev2.NewLoginParams{ - // We already have a Session; let's call this instead of the connector's - // main LoadUserLogin method, and thread the Session through. LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { login.Client = &client client.UserLogin = login From 776ddd7c96f98a4552181eaceed286d2a123c4a7 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:28:07 -0800 Subject: [PATCH 49/99] login: make complete step id a constant --- pkg/connector/login.go | 2 ++ pkg/connector/login_token.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 3b46079..123328c 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -23,6 +23,8 @@ import ( "maunium.net/go/mautrix/bridgev2" ) +const LoginStepIDComplete = "fi.mau.discord.login.complete" + func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ { diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index 7a9ce90..fd479fa 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -120,7 +120,7 @@ func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[stri return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, - StepID: "fi.mau.discord.complete", + StepID: LoginStepIDComplete, Instructions: fmt.Sprintf("Logged in as %s", user), CompleteParams: &bridgev2.LoginCompleteParams{ UserLoginID: ul.ID, From 2075a4b853eec8aeffdd9f3928250898b1ebdc5a Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:28:07 -0800 Subject: [PATCH 50/99] client: don't `nil` out `discordgo.Session` when disconnecting I don't have the logs anymore unfortunately, but I witnessed a (seemingly?) rare nil dereference _within discordgo's event handling code_ after Disconnect on the client was called (caused by SIGINT). My thinking is that this caused the Session to get garbage collected, so the method receiver became nil out from under it. To let discordgo clean up after itself, keep a reference to it in the client. --- pkg/connector/client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 42fed05..cb92dd5 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -149,7 +149,6 @@ func (cl *DiscordClient) connect(ctx context.Context) error { func (d *DiscordClient) Disconnect() { d.UserLogin.Log.Info().Msg("Disconnecting session") d.Session.Close() - d.Session = nil } func (d *DiscordClient) IsLoggedIn() bool { From 099b464f84b13d8b8446d2631de8c2689f3be989 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:28:07 -0800 Subject: [PATCH 51/99] client: refactor boot logic into `SetUp` method This method only has heartbeat session population logic for now, so it's actually a no-op during provisioning. However, there's probably some value in "mandating" that clients call this shortly after construction, so we have a chance to run any setup logic that we might need in the future. This _feels_ unidiomatic...? --- pkg/connector/client.go | 29 ++++++++++++++++++++++------- pkg/connector/login_token.go | 1 + 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index cb92dd5..66d3a91 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -46,16 +46,9 @@ type DiscordClient struct { } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { - log := login.Log meta := login.Metadata.(*UserLoginMetadata) session, err := discordgo.New(meta.Token) - if meta.HeartbeatSession.IsExpired() { - log.Info().Msg("Heartbeat session expired, creating a new one") - meta.HeartbeatSession = discordgo.NewHeartbeatSession() - } - meta.HeartbeatSession.BumpLastUsed() - session.HeartbeatSession = meta.HeartbeatSession login.Save(ctx) if err != nil { @@ -71,6 +64,8 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us ReuploadMedia: d.ReuploadMedia, }, } + cl.SetUp(ctx, meta) + login.Client = &cl return nil @@ -78,6 +73,26 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) +// SetUp performs basic bookkeeping and initialization that should be done +// immediately after a DiscordClient has been created. +// +// nil may be passed for meta, especially during provisioning where we need to +// connect to the Discord gateway, but don't have a UserLogin yet. +func (d *DiscordClient) SetUp(ctx context.Context, meta *UserLoginMetadata) { + log := zerolog.Ctx(ctx) + + // We'll have UserLogin metadata if this UserLogin is being loaded from the + // database, i.e. it hasn't just been provisioned. + if meta != nil { + if meta.HeartbeatSession.IsExpired() { + log.Info().Msg("Heartbeat session expired, creating a new one") + meta.HeartbeatSession = discordgo.NewHeartbeatSession() + } + meta.HeartbeatSession.BumpLastUsed() + d.Session.HeartbeatSession = meta.HeartbeatSession + } +} + func (d *DiscordClient) Connect(ctx context.Context) { log := zerolog.Ctx(ctx) diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index fd479fa..d499343 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -84,6 +84,7 @@ func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[stri connector: dl.connector, Session: session, } + client.SetUp(ctx, nil) err = client.connect(ctx) if err != nil { dl.softlyCloseSession() From b18d908489030ca4869ebbbacd387c3c21a16c66 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 18:28:07 -0800 Subject: [PATCH 52/99] login: implement remoteauth (QR code login) --- pkg/connector/login.go | 16 ++- pkg/connector/login_remoteauth.go | 189 ++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 pkg/connector/login_remoteauth.go diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 123328c..d555e3e 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -27,6 +27,11 @@ const LoginStepIDComplete = "fi.mau.discord.login.complete" func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ + { + ID: LoginFlowIDRemoteAuth, + Name: "QR Code", + Description: "Scan a QR code with the Discord mobile app to log in.", + }, { ID: LoginFlowIDToken, Name: "Token", @@ -36,9 +41,12 @@ func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { } func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - if flowID != LoginFlowIDToken { - return nil, fmt.Errorf("unknown login flow ID") + switch flowID { + case LoginFlowIDToken: + return &DiscordTokenLogin{connector: d, User: user}, nil + case LoginFlowIDRemoteAuth: + return &DiscordRemoteAuthLogin{connector: d, User: user}, nil + default: + return nil, fmt.Errorf("unknown discord login flow id") } - - return &DiscordTokenLogin{connector: d, User: user}, nil } diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go new file mode 100644 index 0000000..c2ae2fc --- /dev/null +++ b/pkg/connector/login_remoteauth.go @@ -0,0 +1,189 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-discord/pkg/remoteauth" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const LoginFlowIDRemoteAuth = "fi.mau.discord.login.remote_auth" + +type DiscordRemoteAuthLogin struct { + connector *DiscordConnector + User *bridgev2.User + + Session *discordgo.Session + + remoteAuthClient *remoteauth.Client + qrChan chan string + doneChan chan struct{} +} + +var _ bridgev2.LoginProcessDisplayAndWait = (*DiscordRemoteAuthLogin)(nil) + +func (dl *DiscordRemoteAuthLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + log := zerolog.Ctx(ctx) + + log.Debug().Msg("Creating new remoteauth client") + client, err := remoteauth.New() + if err != nil { + return nil, fmt.Errorf("couldn't create Discord remoteauth client: %w", err) + } + + dl.remoteAuthClient = client + + dl.qrChan = make(chan string) + dl.doneChan = make(chan struct{}) + + log.Info().Msg("Starting the QR code login process") + err = client.Dial(ctx, dl.qrChan, dl.doneChan) + if err != nil { + log.Err(err).Msg("Couldn't connect to Discord remoteauth websocket") + close(dl.qrChan) + close(dl.doneChan) + return nil, fmt.Errorf("couldn't connect to Discord remoteauth websocket: %w", err) + } + + log.Info().Msg("Waiting for QR code to be ready") + + select { + case qrCode := <-dl.qrChan: + log.Info().Int("qr_code_data_len", len(qrCode)).Msg("Received QR code, creating login step") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeDisplayAndWait, + StepID: "fi.mau.discord.qr", + Instructions: "On your phone, find “Scan QR Code” in the official Discord mobile app’s settings.", + DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{ + Type: bridgev2.LoginDisplayTypeQR, + Data: qrCode, + }, + }, nil + case <-ctx.Done(): + log.Debug().Msg("Cancelled while waiting for QR code") + return nil, nil + } +} + +// Wait implements bridgev2.LoginProcessDisplayAndWait. +func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { + if dl.doneChan == nil { + panic("can't wait for discord remoteauth without a doneChan") + } + + log := zerolog.Ctx(ctx) + + log.Debug().Msg("Waiting for remoteauth") + select { + case <-dl.doneChan: + user, err := dl.remoteAuthClient.Result() + if err != nil { + log.Err(err).Msg("Discord remoteauth failed") + return nil, fmt.Errorf("Discord remoteauth failed: %w", err) + } + log.Debug().Msg("Discord remoteauth succeeded") + + return dl.finalizeSuccessfulLogin(ctx, user) + case <-ctx.Done(): + log.Debug().Msg("Cancelled while waiting for remoteauth to complete") + return nil, nil + } +} + +func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) { + log := zerolog.Ctx(ctx) + + session, err := discordgo.New(user.Token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session from successful remoteauth: %w", err) + } + + client := &DiscordClient{ + connector: dl.connector, + Session: session, + } + client.SetUp(ctx, nil) + err = client.connect(ctx) + + softlyClose := func() { + log.Debug().Msg("Softly closing session due to error after successful remoteauth") + err := dl.Session.Close() + if err != nil { + log.Err(err).Msg("Couldn't softly close session due to error after successful remoteauth") + } + } + if err != nil { + softlyClose() + return nil, fmt.Errorf("couldn't connect to Discord: %w", err) + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + discordUser := session.State.User + dl.Session = session + + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.UserID), + Metadata: &UserLoginMetadata{ + Token: user.Token, + HeartbeatSession: discordgo.NewHeartbeatSession(), + }, + }, &bridgev2.NewLoginParams{ + DeleteOnConflict: true, + LoadUserLogin: func(ctx context.Context, ul *bridgev2.UserLogin) error { + ul.Client = client + client.UserLogin = ul + + // Only now that we have a UserLogin can we begin syncing. + client.BeginSyncingIfUserLoginPresent(ctx) + return nil + }, + }) + if err != nil { + softlyClose() + return nil, fmt.Errorf("couldn't create login after successful remoteauth: %w", err) + } + zerolog.Ctx(ctx).Info(). + Str("user_id", discordUser.ID). + Str("user_username", discordUser.Username). + Msg("Connected to Discord during login") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: LoginStepIDComplete, + Instructions: fmt.Sprintf("Logged in as %s", user.Username), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (dl *DiscordRemoteAuthLogin) Cancel() { + dl.User.Log.Debug().Msg("Discord remoteauth cancelled") + + // remoteauth.Client doesn't seem to expose a cancellation method right now. + close(dl.doneChan) + close(dl.qrChan) +} From 1fb161f3795b189de74df4acc7afcfc5d6bb8b30 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 19:37:40 -0800 Subject: [PATCH 53/99] lint --- pkg/connector/login_remoteauth.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index c2ae2fc..b8d0273 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -22,10 +22,11 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/mautrix-discord/pkg/remoteauth" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/remoteauth" ) const LoginFlowIDRemoteAuth = "fi.mau.discord.login.remote_auth" From c015148b63ef727215d13933fb193fade8983e59 Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 19:40:43 -0800 Subject: [PATCH 54/99] login/remoteauth: simplify copy --- pkg/connector/login_remoteauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index b8d0273..2af96f5 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -76,7 +76,7 @@ func (dl *DiscordRemoteAuthLogin) Start(ctx context.Context) (*bridgev2.LoginSte return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeDisplayAndWait, StepID: "fi.mau.discord.qr", - Instructions: "On your phone, find “Scan QR Code” in the official Discord mobile app’s settings.", + Instructions: "On your phone, find “Scan QR Code” in Discord’s settings.", DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{ Type: bridgev2.LoginDisplayTypeQR, Data: qrCode, From cbfbe656198dfd6d005798c13e7b692f91fffccf Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 18 Dec 2025 19:43:22 -0800 Subject: [PATCH 55/99] login/remoteauth: lowercase `Errorf` --- pkg/connector/login_remoteauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index 2af96f5..c93a08e 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -102,7 +102,7 @@ func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep user, err := dl.remoteAuthClient.Result() if err != nil { log.Err(err).Msg("Discord remoteauth failed") - return nil, fmt.Errorf("Discord remoteauth failed: %w", err) + return nil, fmt.Errorf("discord remoteauth failed: %w", err) } log.Debug().Msg("Discord remoteauth succeeded") From e71075cd0d50ea84aa3880f919e947ef4bed274c Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 5 Jan 2026 22:12:39 -0800 Subject: [PATCH 56/99] handlematrix: bridge outgoing message attachments --- pkg/connector/capabilities.go | 59 +++++++++++++++++++- pkg/connector/client.go | 6 -- pkg/connector/connector.go | 5 +- pkg/connector/handlematrix.go | 5 +- pkg/msgconv/from-matrix.go | 102 +++++++++++++++++++++++++++++++--- pkg/msgconv/msgconv.go | 24 +++++++- 6 files changed, 179 insertions(+), 22 deletions(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index f55a701..9cbf20d 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -54,6 +54,13 @@ func capID() string { return base } +// TODO: This limit is increased depending on user subscription status (Discord Nitro). +const MaxTextLength = 2000 + +// TODO: This limit is increased depending on user subscription status (Discord Nitro). +// TODO: Verify this figure (10 MiB). +const MaxFileSize = 10485760 + var discordCaps = &event.RoomFeatures{ ID: capID(), Reply: event.CapLevelFullySupported, @@ -78,9 +85,57 @@ var discordCaps = &event.RoomFeatures{ event.FmtListJumpValue: event.CapLevelUnsupported, event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support. }, + File: event.FileFeatureMap{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelFullySupported, + "image/gif": event.CapLevelFullySupported, + "image/webp": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/mpeg": event.CapLevelFullySupported, + "audio/webm": event.CapLevelFullySupported, + "audio/wav": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.CapMsgGIF: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + // TODO: Support voice messages. + }, LocationMessage: event.CapLevelUnsupported, - // TODO: This limit is increased depending on Discord subscription (Nitro). - MaxTextLength: 2000, + MaxTextLength: MaxTextLength, // TODO: Support reactions. // TODO: Support threads. // TODO: Support editing. diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 66d3a91..6ab8d45 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -33,7 +33,6 @@ import ( "maunium.net/go/mautrix/bridgev2/status" "go.mau.fi/mautrix-discord/pkg/discordid" - "go.mau.fi/mautrix-discord/pkg/msgconv" ) type DiscordClient struct { @@ -42,7 +41,6 @@ type DiscordClient struct { UserLogin *bridgev2.UserLogin Session *discordgo.Session hasBegunSyncing bool - MsgConv msgconv.MessageConverter } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -59,10 +57,6 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us connector: d, UserLogin: login, Session: session, - MsgConv: msgconv.MessageConverter{ - Bridge: d.Bridge, - ReuploadMedia: d.ReuploadMedia, - }, } cl.SetUp(ctx, meta) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ba1fd09..5353e1e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -33,10 +33,7 @@ var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge - d.MsgConv = &msgconv.MessageConverter{ - Bridge: bridge, - ReuploadMedia: d.ReuploadMedia, - } + d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia) } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 7e13fd7..3cdebe3 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -20,6 +20,7 @@ import ( "context" "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -41,7 +42,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M portal := msg.Portal channelID := string(portal.ID) - sendReq, err := d.connector.MsgConv.ToDiscord(ctx, msg) + sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg) if err != nil { return nil, err } @@ -51,7 +52,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M // TODO: Pass the guild ID when send messages in guild channels. options = append(options, discordgo.WithChannelReferer("", channelID)) - sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), &sendReq, options...) + sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), sendReq, options...) if err != nil { return nil, err } diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 3729c31..a6ec736 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -17,12 +17,16 @@ package msgconv import ( + "bytes" "context" "fmt" + "io" + "net/http" "strconv" "time" "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" "go.mau.fi/util/variationselector" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" @@ -59,14 +63,43 @@ func parseAllowedLinkPreviews(raw map[string]any) []string { return allowedLinkPreviews } +func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error { + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) + if err != nil { + return err + } + + for key, value := range discordgo.DroidBaseHeaders { + req.Header.Set(key, value) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Referer", "https://discord.com/") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + resp, err := cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode > 300 { + respData, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData) + } + return nil +} + // ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate // for bridging the message to Discord. func (mc *MessageConverter) ToDiscord( ctx context.Context, + session *discordgo.Session, msg *bridgev2.MatrixMessage, -) (discordgo.MessageSend, error) { +) (*discordgo.MessageSend, error) { var req discordgo.MessageSend req.Nonce = generateMessageNonce() + log := zerolog.Ctx(ctx) if msg.ReplyTo != nil { req.Reference = &discordgo.MessageReference{ @@ -75,18 +108,73 @@ func (mc *MessageConverter) ToDiscord( } } - switch msg.Content.MsgType { - case event.MsgText, event.MsgEmote, event.MsgNotice: - req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, msg.Content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) - if msg.Content.MsgType == event.MsgEmote { + portal := msg.Portal + channelID := string(portal.ID) + content := msg.Content + + convertMatrix := func() { + req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) + if content.MsgType == event.MsgEmote { req.Content = fmt.Sprintf("_%s_", req.Content) } - // TODO: Handle attachments. + } + + switch content.MsgType { + case event.MsgText, event.MsgEmote, event.MsgNotice: + convertMatrix() + case event.MsgAudio, event.MsgFile, event.MsgVideo: + mediaData, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) + if err != nil { + log.Err(err).Msg("Failed to download Matrix attachment for bridging") + return nil, bridgev2.ErrMediaDownloadFailed + } + + filename := content.Body + if content.FileName != "" && content.FileName != content.Body { + filename = content.FileName + convertMatrix() + } + if msg.Event.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true { + filename = "SPOILER_" + filename + } + + // TODO: Support attachments for relay/webhook. (A branch was removed here.) + att := &discordgo.MessageAttachment{ + ID: "0", + Filename: filename, + } + + upload_id := mc.NextDiscordUploadID() + log.Debug().Str("upload_id", upload_id).Msg("Preparing attachment") + prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ + Files: []*discordgo.FilePrepare{{ + Size: len(mediaData), + Name: att.Filename, + ID: mc.NextDiscordUploadID(), + }}, + // TODO: Populate with guild ID. Support threads. + }, discordgo.WithChannelReferer("", channelID)) + + if err != nil { + log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") + return nil, bridgev2.ErrMediaReuploadFailed + } + + prepared := prep.Attachments[0] + att.UploadedFilename = prepared.UploadFilename + + err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) + if err != nil { + log.Err(err).Msg("Failed to reupload Discord attachment after preparing") + return nil, bridgev2.ErrMediaReuploadFailed + } + + req.Attachments = append(req.Attachments, att) } // TODO: Handle (silent) replies and allowed mentions. - return req, nil + return &req, nil } func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) { diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 7d51f84..f27a12b 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -18,19 +18,41 @@ package msgconv import ( "context" + "math/rand" + "strconv" + "sync/atomic" "maunium.net/go/mautrix/bridgev2" "go.mau.fi/mautrix-discord/pkg/attachment" ) +type MediaReuploader func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) + type MessageConverter struct { Bridge *bridgev2.Bridge + nextDiscordUploadID atomic.Int32 + // ReuploadMedia is called when the message converter wants to upload some // media it is attempting to bridge. // // This can be directly forwarded to the ReuploadMedia method on DiscordConnector. // The indirection is only necessary to prevent an import cycle. - ReuploadMedia func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) + ReuploadMedia MediaReuploader +} + +func NewMessageConverter(bridge *bridgev2.Bridge, reuploader MediaReuploader) *MessageConverter { + mc := &MessageConverter{ + Bridge: bridge, + ReuploadMedia: reuploader, + } + + mc.nextDiscordUploadID.Store(rand.Int31n(100)) + return mc +} + +func (mc *MessageConverter) NextDiscordUploadID() string { + val := mc.nextDiscordUploadID.Add(2) + return strconv.Itoa(int(val)) } From ca1168bfc27ba2b0f94e672055ad8aec39659076 Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 5 Jan 2026 22:12:39 -0800 Subject: [PATCH 57/99] clean up stray `zerolog` import --- pkg/connector/handlematrix.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 3cdebe3..6299c10 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -20,7 +20,6 @@ import ( "context" "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" From 761a850a501f87792e792dec974603d778406c89 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 6 Jan 2026 17:40:14 -0800 Subject: [PATCH 58/99] handlematrix: bridge outgoing read receipts --- pkg/connector/handlematrix.go | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 6299c10..0fa936a 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -109,8 +109,44 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * } func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { - //TODO implement me - panic("implement me") + // TODO: Support threads. + log := msg.Portal.Log.With(). + Str("event_id", string(msg.EventID)). + Str("action", "matrix read receipt").Logger() + + sendReadReceipt := func(messageID string) error { + // TODO: Support guilds. + channelID := string(msg.Portal.ID) + resp, err := d.Session.ChannelMessageAckNoToken(channelID, messageID, discordgo.WithChannelReferer("", channelID)) + if err != nil { + log.Err(err).Msg("Failed to send read receipt to Discord") + return err + } else if resp.Token != nil { + log.Debug(). + Str("unexpected_resp_token", *resp.Token). + Msg("Marked message as read on Discord (and got unexpected non-nil token)") + } else { + log.Debug().Msg("Marked message as read on Discord") + } + return nil + } + + if msg.ExactMessage != nil { + messageID := string(msg.ExactMessage.ID) + return sendReadReceipt(messageID) + } + + lastMessage, err := d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo) + if err != nil { + log.Err(err).Msg("Failed to send read receipt, couldn't find last part before ReadUpTo") + return err + } else if lastMessage != nil { + messageID := string(lastMessage.ID) + log.Debug().Str("message_id", messageID).Msg("Bridging read receipt via last message part") + return sendReadReceipt(string(messageID)) + } + + return nil } func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { From 4314aa920673cb67f936ef40f16ddc460e64d7f7 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 6 Jan 2026 18:09:52 -0800 Subject: [PATCH 59/99] handlematrix: simplify read receipt bridging --- pkg/connector/handlematrix.go | 63 ++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 0fa936a..5df997b 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -114,36 +114,51 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge Str("event_id", string(msg.EventID)). Str("action", "matrix read receipt").Logger() - sendReadReceipt := func(messageID string) error { - // TODO: Support guilds. - channelID := string(msg.Portal.ID) - resp, err := d.Session.ChannelMessageAckNoToken(channelID, messageID, discordgo.WithChannelReferer("", channelID)) - if err != nil { - log.Err(err).Msg("Failed to send read receipt to Discord") - return err - } else if resp.Token != nil { - log.Debug(). - Str("unexpected_resp_token", *resp.Token). - Msg("Marked message as read on Discord (and got unexpected non-nil token)") - } else { - log.Debug().Msg("Marked message as read on Discord") - } - return nil - } + var targetMessageID string + // Figure out the ID of the Discord message that we'll mark as read. If the + // receipt didn't exactly correspond with a message, try finding one close + // by to use as the target. if msg.ExactMessage != nil { - messageID := string(msg.ExactMessage.ID) - return sendReadReceipt(messageID) + targetMessageID = string(msg.ExactMessage.ID) + log = log.With(). + Str("message_id", targetMessageID). + Logger() + } else { + closestMessage, err := d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo) + + if err != nil { + log.Err(err).Msg("Failed to find closest message part") + return err + } else if closestMessage != nil { + // The read receipt didn't specify an exact message but we were able to + // find one close by. + + targetMessageID = string(closestMessage.ID) + log = log.With(). + Str("closest_message_id", targetMessageID). + Str("closest_event_id", closestMessage.MXID.String()). + Logger() + log.Debug(). + Msg("Read receipt target event not found, using closest message") + } else { + log.Debug().Msg("Dropping read receipt: no messages found") + return nil + } } - lastMessage, err := d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo) + // TODO: Support guilds. + channelID := string(msg.Portal.ID) + resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer("", channelID)) if err != nil { - log.Err(err).Msg("Failed to send read receipt, couldn't find last part before ReadUpTo") + log.Err(err).Msg("Failed to send read receipt to Discord") return err - } else if lastMessage != nil { - messageID := string(lastMessage.ID) - log.Debug().Str("message_id", messageID).Msg("Bridging read receipt via last message part") - return sendReadReceipt(string(messageID)) + } else if resp.Token != nil { + log.Debug(). + Str("unexpected_resp_token", *resp.Token). + Msg("Marked message as read on Discord (and got unexpected non-nil token)") + } else { + log.Debug().Msg("Marked message as read on Discord") } return nil From 8a28fa0f95b2e02c2b40285ed5d42872e7e46562 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 7 Jan 2026 18:50:02 -0800 Subject: [PATCH 60/99] connector: share session construction logic So we can't forget to set up the logging handler. --- pkg/connector/client.go | 2 +- pkg/connector/login_remoteauth.go | 2 +- pkg/connector/login_token.go | 10 +------ pkg/connector/session.go | 44 +++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 pkg/connector/session.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 6ab8d45..13eae65 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -46,7 +46,7 @@ type DiscordClient struct { func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { meta := login.Metadata.(*UserLoginMetadata) - session, err := discordgo.New(meta.Token) + session, err := NewDiscordSession(ctx, meta.Token) login.Save(ctx) if err != nil { diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index c93a08e..e1ae04a 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -116,7 +116,7 @@ func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) { log := zerolog.Ctx(ctx) - session, err := discordgo.New(user.Token) + session, err := NewDiscordSession(ctx, user.Token) if err != nil { return nil, fmt.Errorf("couldn't create discord session from successful remoteauth: %w", err) } diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index d499343..60678b8 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -19,7 +19,6 @@ package connector import ( "context" "fmt" - "strings" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" @@ -68,18 +67,11 @@ func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[stri log.Info().Msg("Creating session from provided token") dl.Token = token - session, err := discordgo.New(token) + session, err := NewDiscordSession(ctx, token) if err != nil { return nil, fmt.Errorf("couldn't create discord session: %w", err) } - // Set up logging. - session.LogLevel = discordgo.LogInformational - session.Logger = func(msgL, caller int, format string, a ...any) { - // FIXME(skip): Hook up zerolog properly. - log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf - } - client := DiscordClient{ connector: dl.connector, Session: session, diff --git a/pkg/connector/session.go b/pkg/connector/session.go new file mode 100644 index 0000000..f56a094 --- /dev/null +++ b/pkg/connector/session.go @@ -0,0 +1,44 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" +) + +func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, error) { + log := zerolog.Ctx(ctx) + + session, err := discordgo.New(token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + // Set up logging. + session.LogLevel = discordgo.LogInformational + session.Logger = func(msgL, caller int, format string, a ...any) { + // FIXME(skip): Hook up zerolog properly. + log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf + } + + return session, nil +} From b764f489deb26aedbb70ecf1c07f50aac7012406 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 7 Jan 2026 18:50:02 -0800 Subject: [PATCH 61/99] login: implement logging in via browser --- pkg/connector/login.go | 7 ++ pkg/connector/login_browser.go | 156 +++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 pkg/connector/login_browser.go diff --git a/pkg/connector/login.go b/pkg/connector/login.go index d555e3e..037851c 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -27,6 +27,11 @@ const LoginStepIDComplete = "fi.mau.discord.login.complete" func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ + { + ID: LoginFlowIDBrowser, + Name: "Browser", + Description: "Log in to your Discord account in a web browser.", + }, { ID: LoginFlowIDRemoteAuth, Name: "QR Code", @@ -46,6 +51,8 @@ func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, return &DiscordTokenLogin{connector: d, User: user}, nil case LoginFlowIDRemoteAuth: return &DiscordRemoteAuthLogin{connector: d, User: user}, nil + case LoginFlowIDBrowser: + return &DiscordBrowserLogin{connector: d, User: user}, nil default: return nil, fmt.Errorf("unknown discord login flow id") } diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go new file mode 100644 index 0000000..70e9f08 --- /dev/null +++ b/pkg/connector/login_browser.go @@ -0,0 +1,156 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const LoginFlowIDBrowser = "fi.mau.discord.login.browser" + +type DiscordBrowserLogin struct { + connector *DiscordConnector + User *bridgev2.User + + Session *discordgo.Session +} + +var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil) + +func (dl *DiscordBrowserLogin) softlyCloseSession() { + dl.User.Log.Debug().Msg("Closing session") + err := dl.Session.Close() + if err != nil { + dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + } +} + +func (dl *DiscordBrowserLogin) Cancel() { +} + +const ExtractDiscordTokenJS = ` +new Promise((resolve) => { + let mautrixDiscordTokenCheckInterval + + const iframe = document.createElement('iframe') + document.head.append(iframe) + + mautrixDiscordTokenCheckInterval = setInterval(() => { + const token = iframe.contentWindow.localStorage.token + if (token) { + resolve({ token: token.slice(1, -1) }) + clearInterval(mautrixDiscordTokenCheckInterval) + } + }, 200) +}) +` + +func (dl *DiscordBrowserLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeCookies, + StepID: "fi.mau.discord.cookies", + Instructions: "Log in with Discord.", + CookiesParams: &bridgev2.LoginCookiesParams{ + URL: "https://discord.com/login", + UserAgent: "", + Fields: []bridgev2.LoginCookieField{{ + ID: "token", + Required: true, + Sources: []bridgev2.LoginCookieFieldSource{{ + Type: bridgev2.LoginCookieTypeSpecial, + Name: "fi.mau.discord.token", + }}, + }}, + ExtractJS: ExtractDiscordTokenJS, + }, + }, nil +} + +func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { + log := zerolog.Ctx(ctx) + + token := cookies["token"] + if token == "" { + log.Error().Msg("Received empty token") + return nil, fmt.Errorf("received empty token") + } + log.Debug().Msg("Logging in with submitted cookie") + + // FIXME FIXME: The rest of this method is basically copy and pasted from + // DiscordTokenLogin, so find a way to tidy this up. + + session, err := NewDiscordSession(ctx, token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + client := DiscordClient{ + connector: dl.connector, + Session: session, + } + client.SetUp(ctx, nil) + err = client.connect(ctx) + if err != nil { + dl.softlyCloseSession() + return nil, err + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + user := session.State.User + + dl.Session = session + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.ID), + Metadata: &UserLoginMetadata{ + Token: token, + HeartbeatSession: session.HeartbeatSession, + }, + }, &bridgev2.NewLoginParams{ + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &client + client.UserLogin = login + + // Only now that we have a UserLogin can we begin syncing. + client.BeginSyncingIfUserLoginPresent(ctx) + return nil + }, + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + dl.softlyCloseSession() + return nil, fmt.Errorf("couldn't create login: %w", err) + } + zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: LoginStepIDComplete, + Instructions: fmt.Sprintf("Logged in as %s", user), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} From 4fb0cdb84784d460fca905075a12e4255329c319 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 7 Jan 2026 20:10:18 -0800 Subject: [PATCH 62/99] login: relocate shared login finalization logic into embedded struct All of the login methods need to do (effectively) the same thing once we have a token, so refactor this out into something we can reuse. --- pkg/connector/login.go | 21 +++--- pkg/connector/login_browser.go | 67 ++---------------- pkg/connector/login_generic.go | 114 ++++++++++++++++++++++++++++++ pkg/connector/login_remoteauth.go | 68 ++---------------- pkg/connector/login_token.go | 71 ++----------------- 5 files changed, 141 insertions(+), 200 deletions(-) create mode 100644 pkg/connector/login_generic.go diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 037851c..5f056f6 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -27,16 +27,16 @@ const LoginStepIDComplete = "fi.mau.discord.login.complete" func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ - { - ID: LoginFlowIDBrowser, - Name: "Browser", - Description: "Log in to your Discord account in a web browser.", - }, { ID: LoginFlowIDRemoteAuth, Name: "QR Code", Description: "Scan a QR code with the Discord mobile app to log in.", }, + { + ID: LoginFlowIDBrowser, + Name: "Browser", + Description: "Log in to your Discord account in a web browser.", + }, { ID: LoginFlowIDToken, Name: "Token", @@ -46,13 +46,18 @@ func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { } func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + login := DiscordGenericLogin{ + connector: d, + User: user, + } + switch flowID { case LoginFlowIDToken: - return &DiscordTokenLogin{connector: d, User: user}, nil + return &DiscordTokenLogin{DiscordGenericLogin: &login}, nil case LoginFlowIDRemoteAuth: - return &DiscordRemoteAuthLogin{connector: d, User: user}, nil + return &DiscordRemoteAuthLogin{DiscordGenericLogin: &login}, nil case LoginFlowIDBrowser: - return &DiscordBrowserLogin{connector: d, User: user}, nil + return &DiscordBrowserLogin{DiscordGenericLogin: &login}, nil default: return nil, fmt.Errorf("unknown discord login flow id") } diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go index 70e9f08..d6bee8e 100644 --- a/pkg/connector/login_browser.go +++ b/pkg/connector/login_browser.go @@ -20,35 +20,18 @@ import ( "context" "fmt" - "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" ) const LoginFlowIDBrowser = "fi.mau.discord.login.browser" type DiscordBrowserLogin struct { - connector *DiscordConnector - User *bridgev2.User - - Session *discordgo.Session + *DiscordGenericLogin } var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil) -func (dl *DiscordBrowserLogin) softlyCloseSession() { - dl.User.Log.Debug().Msg("Closing session") - err := dl.Session.Close() - if err != nil { - dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") - } -} - -func (dl *DiscordBrowserLogin) Cancel() { -} - const ExtractDiscordTokenJS = ` new Promise((resolve) => { let mautrixDiscordTokenCheckInterval @@ -97,57 +80,15 @@ func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[st } log.Debug().Msg("Logging in with submitted cookie") - // FIXME FIXME: The rest of this method is basically copy and pasted from - // DiscordTokenLogin, so find a way to tidy this up. - - session, err := NewDiscordSession(ctx, token) + ul, err := dl.FinalizeCreatingLogin(ctx, token) if err != nil { - return nil, fmt.Errorf("couldn't create discord session: %w", err) + return nil, fmt.Errorf("couldn't log in via browser: %w", err) } - client := DiscordClient{ - connector: dl.connector, - Session: session, - } - client.SetUp(ctx, nil) - err = client.connect(ctx) - if err != nil { - dl.softlyCloseSession() - return nil, err - } - // At this point we've opened a WebSocket connection to the gateway, received - // a READY packet, and know who we are. - user := session.State.User - - dl.Session = session - ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ - ID: networkid.UserLoginID(user.ID), - Metadata: &UserLoginMetadata{ - Token: token, - HeartbeatSession: session.HeartbeatSession, - }, - }, &bridgev2.NewLoginParams{ - LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { - login.Client = &client - client.UserLogin = login - - // Only now that we have a UserLogin can we begin syncing. - client.BeginSyncingIfUserLoginPresent(ctx) - return nil - }, - DeleteOnConflict: true, - DontReuseExisting: false, - }) - if err != nil { - dl.softlyCloseSession() - return nil, fmt.Errorf("couldn't create login: %w", err) - } - zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") - return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, StepID: LoginStepIDComplete, - Instructions: fmt.Sprintf("Logged in as %s", user), + Instructions: dl.CompleteInstructions(), CompleteParams: &bridgev2.LoginCompleteParams{ UserLoginID: ul.ID, UserLogin: ul, diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go new file mode 100644 index 0000000..ff094e2 --- /dev/null +++ b/pkg/connector/login_generic.go @@ -0,0 +1,114 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// DiscordGenericLogin is embedded within each struct that implements +// bridgev2.LoginProcess in order to encapsulate the common behavior that needs +// to occur after procuring a valid user token. Namely, creating a gateway +// connection to Discord and an associated UserLogin to wrap things up. +// +// It also implements a baseline Cancel method that closes the gateway +// connection. +type DiscordGenericLogin struct { + User *bridgev2.User + connector *DiscordConnector + + Session *discordgo.Session + + // The Discord user we've authenticated as. This is only non-nil if + // a call to FinalizeCreatingLogin has succeeded. + DiscordUser *discordgo.User +} + +func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) { + session, err := NewDiscordSession(ctx, token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + client := DiscordClient{ + connector: dl.connector, + Session: session, + } + client.SetUp(ctx, nil) + + err = client.connect(ctx) + if err != nil { + dl.Cancel() + return nil, err + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + user := session.State.User + dl.DiscordUser = user + + dl.Session = session + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.ID), + Metadata: &UserLoginMetadata{ + Token: token, + HeartbeatSession: session.HeartbeatSession, + }, + }, &bridgev2.NewLoginParams{ + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &client + client.UserLogin = login + + // Only now that we have a UserLogin can we begin syncing. + client.BeginSyncingIfUserLoginPresent(ctx) + return nil + }, + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + dl.Cancel() + return nil, fmt.Errorf("couldn't create login: %w", err) + } + + zerolog.Ctx(ctx).Info(). + Str("user_id", user.ID). + Str("user_username", user.Username). + Msg("Logged in to Discord") + + return ul, nil +} + +func (dl *DiscordGenericLogin) CompleteInstructions() string { + return fmt.Sprintf("Logged in as %s", dl.DiscordUser.Username) +} + +func (dl *DiscordGenericLogin) Cancel() { + if dl.Session != nil { + dl.User.Log.Debug().Msg("Login cancelled, closing session") + err := dl.Session.Close() + if err != nil { + dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + } + } +} diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index e1ae04a..d828736 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -20,11 +20,8 @@ import ( "context" "fmt" - "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-discord/pkg/remoteauth" ) @@ -32,10 +29,7 @@ import ( const LoginFlowIDRemoteAuth = "fi.mau.discord.login.remote_auth" type DiscordRemoteAuthLogin struct { - connector *DiscordConnector - User *bridgev2.User - - Session *discordgo.Session + *DiscordGenericLogin remoteAuthClient *remoteauth.Client qrChan chan string @@ -114,66 +108,15 @@ func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep } func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) { - log := zerolog.Ctx(ctx) - - session, err := NewDiscordSession(ctx, user.Token) + ul, err := dl.FinalizeCreatingLogin(ctx, user.Token) if err != nil { - return nil, fmt.Errorf("couldn't create discord session from successful remoteauth: %w", err) + return nil, fmt.Errorf("couldn't log in via remoteauth: %w", err) } - client := &DiscordClient{ - connector: dl.connector, - Session: session, - } - client.SetUp(ctx, nil) - err = client.connect(ctx) - - softlyClose := func() { - log.Debug().Msg("Softly closing session due to error after successful remoteauth") - err := dl.Session.Close() - if err != nil { - log.Err(err).Msg("Couldn't softly close session due to error after successful remoteauth") - } - } - if err != nil { - softlyClose() - return nil, fmt.Errorf("couldn't connect to Discord: %w", err) - } - // At this point we've opened a WebSocket connection to the gateway, received - // a READY packet, and know who we are. - discordUser := session.State.User - dl.Session = session - - ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ - ID: networkid.UserLoginID(user.UserID), - Metadata: &UserLoginMetadata{ - Token: user.Token, - HeartbeatSession: discordgo.NewHeartbeatSession(), - }, - }, &bridgev2.NewLoginParams{ - DeleteOnConflict: true, - LoadUserLogin: func(ctx context.Context, ul *bridgev2.UserLogin) error { - ul.Client = client - client.UserLogin = ul - - // Only now that we have a UserLogin can we begin syncing. - client.BeginSyncingIfUserLoginPresent(ctx) - return nil - }, - }) - if err != nil { - softlyClose() - return nil, fmt.Errorf("couldn't create login after successful remoteauth: %w", err) - } - zerolog.Ctx(ctx).Info(). - Str("user_id", discordUser.ID). - Str("user_username", discordUser.Username). - Msg("Connected to Discord during login") - return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, StepID: LoginStepIDComplete, - Instructions: fmt.Sprintf("Logged in as %s", user.Username), + Instructions: dl.CompleteInstructions(), CompleteParams: &bridgev2.LoginCompleteParams{ UserLoginID: ul.ID, UserLogin: ul, @@ -183,8 +126,9 @@ func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, u func (dl *DiscordRemoteAuthLogin) Cancel() { dl.User.Log.Debug().Msg("Discord remoteauth cancelled") + dl.DiscordGenericLogin.Cancel() - // remoteauth.Client doesn't seem to expose a cancellation method right now. + // remoteauth.Client doesn't seem to expose a cancellation method. close(dl.doneChan) close(dl.qrChan) } diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index 60678b8..5b0d233 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -20,20 +20,13 @@ import ( "context" "fmt" - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" ) const LoginFlowIDToken = "fi.mau.discord.login.token" type DiscordTokenLogin struct { - connector *DiscordConnector - User *bridgev2.User - Token string - Session *discordgo.Session + *DiscordGenericLogin } var _ bridgev2.LoginProcessUserInput = (*DiscordTokenLogin)(nil) @@ -62,74 +55,18 @@ func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[stri return nil, fmt.Errorf("no token provided") } - log := zerolog.Ctx(ctx) - - log.Info().Msg("Creating session from provided token") - dl.Token = token - - session, err := NewDiscordSession(ctx, token) + ul, err := dl.FinalizeCreatingLogin(ctx, token) if err != nil { - return nil, fmt.Errorf("couldn't create discord session: %w", err) + return nil, fmt.Errorf("couldn't login from token: %w", err) } - client := DiscordClient{ - connector: dl.connector, - Session: session, - } - client.SetUp(ctx, nil) - err = client.connect(ctx) - if err != nil { - dl.softlyCloseSession() - return nil, err - } - // At this point we've opened a WebSocket connection to the gateway, received - // a READY packet, and know who we are. - user := session.State.User - - dl.Session = session - ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ - ID: networkid.UserLoginID(user.ID), - Metadata: &UserLoginMetadata{ - Token: token, - HeartbeatSession: session.HeartbeatSession, - }, - }, &bridgev2.NewLoginParams{ - LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { - login.Client = &client - client.UserLogin = login - - // Only now that we have a UserLogin can we begin syncing. - client.BeginSyncingIfUserLoginPresent(ctx) - return nil - }, - DeleteOnConflict: true, - DontReuseExisting: false, - }) - if err != nil { - dl.softlyCloseSession() - return nil, fmt.Errorf("couldn't create login: %w", err) - } - zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") - return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, StepID: LoginStepIDComplete, - Instructions: fmt.Sprintf("Logged in as %s", user), + Instructions: dl.CompleteInstructions(), CompleteParams: &bridgev2.LoginCompleteParams{ UserLoginID: ul.ID, UserLogin: ul, }, }, nil } - -func (dl *DiscordTokenLogin) softlyCloseSession() { - dl.User.Log.Debug().Msg("Closing session") - err := dl.Session.Close() - if err != nil { - dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") - } -} - -func (dl *DiscordTokenLogin) Cancel() { - dl.softlyCloseSession() -} From bfebeeb7e5d1f22eb1bcb5d4beb031a0650ccc0a Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 8 Jan 2026 16:11:26 -0800 Subject: [PATCH 63/99] handlematrix: bridge outgoing typing events --- pkg/connector/client.go | 16 ++++++++--- pkg/connector/handlematrix.go | 53 ++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 13eae65..92c0dab 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -23,6 +23,7 @@ import ( "io" "net/http" "slices" + "sync" "time" "github.com/bwmarrin/discordgo" @@ -36,11 +37,15 @@ import ( ) type DiscordClient struct { - connector *DiscordConnector - usersFromReady map[string]*discordgo.User - UserLogin *bridgev2.UserLogin - Session *discordgo.Session + connector *DiscordConnector + usersFromReady map[string]*discordgo.User + UserLogin *bridgev2.UserLogin + Session *discordgo.Session + hasBegunSyncing bool + + markedOpened map[string]time.Time + markedOpenedLock sync.Mutex } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -73,6 +78,7 @@ var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) // nil may be passed for meta, especially during provisioning where we need to // connect to the Discord gateway, but don't have a UserLogin yet. func (d *DiscordClient) SetUp(ctx context.Context, meta *UserLoginMetadata) { + // TODO: Turn this into a factory function like `NewDiscordClient`. log := zerolog.Ctx(ctx) // We'll have UserLogin metadata if this UserLogin is being loaded from the @@ -85,6 +91,8 @@ func (d *DiscordClient) SetUp(ctx context.Context, meta *UserLoginMetadata) { meta.HeartbeatSession.BumpLastUsed() d.Session.HeartbeatSession = meta.HeartbeatSession } + + d.markedOpened = make(map[string]time.Time) } func (d *DiscordClient) Connect(ctx context.Context) { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 5df997b..5335a87 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -18,8 +18,10 @@ package connector import ( "context" + "time" "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -147,7 +149,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge } } - // TODO: Support guilds. + // TODO: Support guilds and threads. channelID := string(msg.Portal.ID) resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer("", channelID)) if err != nil { @@ -164,7 +166,50 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge return nil } -func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { - //TODO implement me - panic("implement me") +func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error { + // TODO: When guilds are supported, explicitly bail out if this method is called with a guild channel. + d.markedOpenedLock.Lock() + defer d.markedOpenedLock.Unlock() + + channelID := string(portal.ID) + log := zerolog.Ctx(ctx).With(). + Str("channel_id", channelID).Logger() + + lastMarkedOpenedTs := d.markedOpened[channelID] + if lastMarkedOpenedTs.IsZero() { + d.markedOpened[channelID] = time.Now() + + err := d.Session.MarkViewing(channelID) + + if err != nil { + log.Error().Err(err).Msg("Failed to mark user as viewing channel") + return err + } + + log.Trace().Msg("Marked channel as being viewed") + } else { + log.Trace().Str("channel_id", channelID). + Msg("Already marked channel as viewed, not doing so") + } + + return nil +} + +func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + log := zerolog.Ctx(ctx) + + // Don't mind if this fails. + _ = d.viewingChannel(ctx, msg.Portal) + + channelID := string(msg.Portal.ID) + // TODO: Support guilds and threads properly when sending the referer. + err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer("", channelID)) + + if err != nil { + log.Warn().Err(err).Msg("Failed to mark user as typing") + return err + } + + log.Debug().Msg("Marked user as typing") + return nil } From fdcfb2b0830e86f16a6fd5eae22ed95ada3556ea Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 8 Jan 2026 16:55:34 -0800 Subject: [PATCH 64/99] chore: bump copyright year --- cmd/mautrix-discord/main.go | 2 +- pkg/attachment/attachment.go | 2 +- pkg/connector/attachments.go | 2 +- pkg/connector/backfill.go | 2 +- pkg/connector/capabilities.go | 2 +- pkg/connector/chatinfo.go | 2 +- pkg/connector/client.go | 2 +- pkg/connector/config.go | 2 +- pkg/connector/connector.go | 2 +- pkg/connector/dbmeta.go | 2 +- pkg/connector/events.go | 2 +- pkg/connector/handlediscord.go | 2 +- pkg/connector/handlematrix.go | 2 +- pkg/connector/login.go | 2 +- pkg/connector/login_browser.go | 2 +- pkg/connector/login_generic.go | 2 +- pkg/connector/login_remoteauth.go | 2 +- pkg/connector/login_token.go | 2 +- pkg/connector/session.go | 2 +- pkg/connector/userinfo.go | 2 +- pkg/discordid/id.go | 2 +- pkg/msgconv/embed.go | 2 +- pkg/msgconv/formatter.go | 2 +- pkg/msgconv/formatter_everyone.go | 2 +- pkg/msgconv/formatter_tag.go | 2 +- pkg/msgconv/from-discord.go | 2 +- pkg/msgconv/from-matrix.go | 2 +- pkg/msgconv/msgconv.go | 2 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/cmd/mautrix-discord/main.go b/cmd/mautrix-discord/main.go index 641aa24..e119827 100644 --- a/cmd/mautrix-discord/main.go +++ b/cmd/mautrix-discord/main.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go index 3e573cb..c1d333c 100644 --- a/pkg/attachment/attachment.go +++ b/pkg/attachment/attachment.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go index cec764e..c552deb 100644 --- a/pkg/connector/attachments.go +++ b/pkg/connector/attachments.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 05d4961..f14e151 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 9cbf20d..d0168e2 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index ef628dd..449460c 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 92c0dab..9b02caf 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/config.go b/pkg/connector/config.go index d40198c..a1f839f 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 5353e1e..858c22d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 903e99a..53537f1 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 6e55404..3395d5b 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index bbef78f..031fdc2 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 5335a87..1c5802b 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 5f056f6..ef0ec62 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go index d6bee8e..d484aab 100644 --- a/pkg/connector/login_browser.go +++ b/pkg/connector/login_browser.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go index ff094e2..edda578 100644 --- a/pkg/connector/login_generic.go +++ b/pkg/connector/login_generic.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index d828736..98b1657 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index 5b0d233..428eb32 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/session.go b/pkg/connector/session.go index f56a094..fef9592 100644 --- a/pkg/connector/session.go +++ b/pkg/connector/session.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 9be88ed..aad230d 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go index e329aa2..377bc23 100644 --- a/pkg/discordid/id.go +++ b/pkg/discordid/id.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go index 79c1f96..0bb6921 100644 --- a/pkg/msgconv/embed.go +++ b/pkg/msgconv/embed.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go index 8a9fa3f..465cd63 100644 --- a/pkg/msgconv/formatter.go +++ b/pkg/msgconv/formatter.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/formatter_everyone.go b/pkg/msgconv/formatter_everyone.go index 8e87013..6a2195f 100644 --- a/pkg/msgconv/formatter_everyone.go +++ b/pkg/msgconv/formatter_everyone.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go index cc107a9..0a4975c 100644 --- a/pkg/msgconv/formatter_tag.go +++ b/pkg/msgconv/formatter_tag.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 95519a3..a7119fd 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index a6ec736..2dddac4 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index f27a12b..7f40710 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -1,5 +1,5 @@ // mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by From 86544bc7afaf2bfbe3e0481a7edc3be50d95c054 Mon Sep 17 00:00:00 2001 From: Skip R Date: Fri, 9 Jan 2026 17:08:33 -0800 Subject: [PATCH 65/99] relocate `dbmeta` models to `discordid` While we're about to introduce more metadata structs, it seems like mautrix-slack keeps these in slackid, so let's keep ours in discordid. --- pkg/connector/client.go | 4 ++-- pkg/connector/dbmeta.go | 10 +++------- pkg/connector/login_generic.go | 4 +++- pkg/discordid/dbmeta.go | 24 ++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 pkg/discordid/dbmeta.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 9b02caf..8bf9925 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -49,7 +49,7 @@ type DiscordClient struct { } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { - meta := login.Metadata.(*UserLoginMetadata) + meta := login.Metadata.(*discordid.UserLoginMetadata) session, err := NewDiscordSession(ctx, meta.Token) login.Save(ctx) @@ -77,7 +77,7 @@ var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) // // nil may be passed for meta, especially during provisioning where we need to // connect to the Discord gateway, but don't have a UserLogin yet. -func (d *DiscordClient) SetUp(ctx context.Context, meta *UserLoginMetadata) { +func (d *DiscordClient) SetUp(ctx context.Context, meta *discordid.UserLoginMetadata) { // TODO: Turn this into a factory function like `NewDiscordClient`. log := zerolog.Ctx(ctx) diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 53537f1..d4ecb85 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -17,19 +17,15 @@ package connector import ( - "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2/database" -) -type UserLoginMetadata struct { - Token string `json:"token"` - HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` -} + "go.mau.fi/mautrix-discord/pkg/discordid" +) func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { return database.MetaTypes{ UserLogin: func() any { - return &UserLoginMetadata{} + return &discordid.UserLoginMetadata{} }, } } diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go index edda578..ba206ee 100644 --- a/pkg/connector/login_generic.go +++ b/pkg/connector/login_generic.go @@ -25,6 +25,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) // DiscordGenericLogin is embedded within each struct that implements @@ -70,7 +72,7 @@ func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token dl.Session = session ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ ID: networkid.UserLoginID(user.ID), - Metadata: &UserLoginMetadata{ + Metadata: &discordid.UserLoginMetadata{ Token: token, HeartbeatSession: session.HeartbeatSession, }, diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go new file mode 100644 index 0000000..2e8131b --- /dev/null +++ b/pkg/discordid/dbmeta.go @@ -0,0 +1,24 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package discordid + +import "github.com/bwmarrin/discordgo" + +type UserLoginMetadata struct { + Token string `json:"token"` + HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` +} From f5292e6a7d2a7d73dfc3af96a76a00788e35dadc Mon Sep 17 00:00:00 2001 From: Skip R Date: Fri, 9 Jan 2026 17:08:33 -0800 Subject: [PATCH 66/99] connector: first pass at bridging guilds For each guild specified in the config, create a space and bridge all contained text channels that the user has permissions to view. * Finally add a custom config struct where we accept a list of guild IDs to bridge. This is intended to be temporary as we flesh out the proper interfaces for managing which guilds to bridge. * Defined a custom meta type for portals that holds the containing guild ID of the channel (if any). * Transferred the responsibility of building a channel's ChatInfo and ChatMemberList to the DiscordChatResync event itself. --- pkg/connector/client.go | 245 ++++++++++++++++++++++++------ pkg/connector/config.go | 21 ++- pkg/connector/connector.go | 1 + pkg/connector/dbmeta.go | 3 + pkg/connector/events.go | 114 +++++++++++++- pkg/connector/example-config.yaml | 6 + pkg/connector/handlematrix.go | 32 ++-- pkg/discordid/dbmeta.go | 9 ++ pkg/msgconv/from-matrix.go | 7 +- 9 files changed, 366 insertions(+), 72 deletions(-) create mode 100644 pkg/connector/example-config.yaml diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8bf9925..b324377 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -33,6 +33,8 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" + "go.mau.fi/util/ptr" + "go.mau.fi/mautrix-discord/pkg/discordid" ) @@ -202,6 +204,7 @@ func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { } go cl.syncPrivateChannels(ctx) + go cl.syncGuilds(ctx) } func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { @@ -212,13 +215,199 @@ func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID) return bts.Compare(ats) }) + // TODO(skip): This is startup_private_channel_create_limit. Support this in the config. for _, dm := range dms[:10] { zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity") - d.syncChannel(ctx, dm, true) + d.syncChannel(ctx, dm) } } +func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Channel) bool { + log := zerolog.Ctx(ctx).With(). + Str("channel_id", ch.ID). + Int("channel_type", int(ch.Type)). + Str("action", "determine guild channel visbility").Logger() + + sess := d.Session + myDiscordUserID := d.Session.State.User.ID + + // To calculate guild channel visibility we need to know our effective permission + // bitmask, which can only be truly determined when we know which roles we have + // in the guild. + // + // To this end, make sure we have detailed information about ourselves in the + // cache ("state"). + + _, err := sess.State.Member(ch.GuildID, myDiscordUserID) + if errors.Is(err, discordgo.ErrStateNotFound) { + log.Debug().Msg("Fetching own membership in guild to check roles") + + member, err := sess.GuildMember(ch.GuildID, myDiscordUserID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get own membership in guild from server") + } else { + err = sess.State.MemberAdd(member) + if err != nil { + log.Warn().Err(err).Msg("Failed to add own membership in guild to cache") + } + } + } else if err != nil { + log.Warn().Err(err).Msg("Failed to get own membership in guild from cache") + } + + err = sess.State.ChannelAdd(ch) + if err != nil { + log.Warn().Err(err).Msg("Failed to add channel to cache") + } + + perms, err := sess.State.UserChannelPermissions(myDiscordUserID, ch.ID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable") + return true + } + + canView := perms&discordgo.PermissionViewChannel > 0 + log.Debug(). + Int64("permissions", perms). + Bool("channel_visible", canView). + Msg("Computed visibility of guild channel") + return canView +} + +// The string prepended to [networkid.PortalKey]s identifying spaces that +// bridge Discord guilds. +// +// Every Discord guild created before August 2017 contained an channel +// having _the same ID as the guild itself_. This channel also functioned as +// the "default channel" in that incoming members would view this channel by +// default. It was also impossible to delete. +// +// After this date, these "default channels" became deletable, and fresh guilds +// were no longer created with a channel that exactly corresponded to the guild +// ID. +// +// To accommodate Discord guilds created before this API change that have also +// never deleted the default channel, we need a way to distinguish between the +// guild and the default channel, as we wouldn't be able to bridge the guild +// as a space otherwise. +// +// "*" was chosen as the asterisk character is used to filter by guilds in +// the quick switcher (in Discord's first-party clients). +// +// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer. +const guildPortalKeySigil = "*" + +func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey { + // TODO: Support configuring `split_portals`. + return networkid.PortalKey{ + ID: networkid.PortalID(guildPortalKeySigil + guildID), + Receiver: d.UserLogin.ID, + } +} + +func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar { + return &bridgev2.Avatar{ + ID: networkid.AvatarID(guild.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) + return simpleDownload(ctx, url, "group dm icon") + }, + Remove: guild.Icon == "", + } +} + +func (d *DiscordClient) syncGuildSpace(ctx context.Context, guild *discordgo.Guild) error { + prt, err := d.connector.Bridge.GetPortalByKey(ctx, d.guildPortalKeyFromID(guild.ID)) + if err != nil { + return fmt.Errorf("couldn't get/create portal corresponding to guild: %w", err) + } + + selfEvtSender := d.selfEventSender() + info := &bridgev2.ChatInfo{ + Name: &guild.Name, + Topic: nil, + Members: &bridgev2.ChatMemberList{ + MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}}, + + // As recommended by the spec, prohibit normal events by setting + // `events_default` to a suitably high number. + PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)}, + }, + Avatar: d.makeAvatarForGuild(guild), + Type: ptr.Ptr(database.RoomTypeSpace), + } + + if prt.MXID == "" { + err := prt.CreateMatrixRoom(ctx, d.UserLogin, info) + + if err != nil { + return fmt.Errorf("couldn't create room in order to materialize guild portal: %w", err) + } + } else { + prt.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{}) + } + + return nil +} + +func (d *DiscordClient) syncGuilds(ctx context.Context) { + guildIDs := d.connector.Config.Guilds.BridgingGuildIDs + + for _, guildID := range guildIDs { + log := zerolog.Ctx(ctx).With(). + Str("guild_id", guildID). + Str("action", "sync guild"). + Logger() + + guild, err := d.Session.State.Guild(guildID) + if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil { + log.Err(err).Msg("Couldn't find guild, user isn't a member?") + continue + } + + err = d.syncGuildSpace(ctx, guild) + if err != nil { + log.Err(err).Msg("Couldn't sync guild space portal") + continue + } + + for _, guildCh := range guild.Channels { + if guildCh.Type != discordgo.ChannelTypeGuildText { + // TODO implement categories (spaces) and news channels + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel due to type") + continue + } + + if !d.canSeeGuildChannel(ctx, guildCh) { + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel that the user doesn't have permission to view") + + continue + } + + d.syncChannel(ctx, guildCh) + } + + log.Debug().Msg("Subscribing to guild after bridging") + err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{ + GuildID: guild.ID, + Typing: true, + Activities: true, + Threads: true, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to subscribe to guild") + } + } + +} + func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -238,17 +427,6 @@ func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { return data, nil } -func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { - return &bridgev2.Avatar{ - ID: networkid.AvatarID(ch.Icon), - Get: func(ctx context.Context) ([]byte, error) { - url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) - return simpleDownload(ctx, url, "group dm icon") - }, - Remove: ch.Icon == "", - } -} - func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: userID == d.Session.State.User.ID, @@ -257,49 +435,18 @@ func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSende } } +func (d *DiscordClient) selfEventSender() bridgev2.EventSender { + return d.makeEventSenderWithID(d.Session.State.User.ID) +} + func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { return d.makeEventSenderWithID(user.ID) } -func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { - isGroup := len(ch.RecipientIDs) > 1 - - var roomType database.RoomType - if isGroup { - roomType = database.RoomTypeGroupDM - } else { - roomType = database.RoomTypeDM - } - - selfEventSender := d.makeEventSender(d.Session.State.User) - - var members bridgev2.ChatMemberList - members.IsFull = true - members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) - if len(ch.Recipients) > 0 { - // Private channels' array of participants doesn't include ourselves, - // so this boolean can be used to inject ourselves as a member. - if selfIsInChannel { - members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} - } - - for _, recipient := range ch.Recipients { - sender := d.makeEventSender(recipient) - members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} - } - - members.TotalMemberCount = len(ch.Recipients) - } - +func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) { d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ + Client: d, channel: ch, portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true), - info: &bridgev2.ChatInfo{ - Name: &ch.Name, - Members: &members, - Avatar: makeChannelAvatar(ch), - Type: &roomType, - CanBackfill: true, - }, }) } diff --git a/pkg/connector/config.go b/pkg/connector/config.go index a1f839f..22da26d 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -17,9 +17,24 @@ package connector import ( - "go.mau.fi/util/configupgrade" + _ "embed" + + up "go.mau.fi/util/configupgrade" ) -func (d *DiscordConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { - return "", nil, configupgrade.NoopUpgrader +//go:embed example-config.yaml +var ExampleConfig string + +type Config struct { + Guilds struct { + BridgingGuildIDs []string `yaml:"bridging_guild_ids"` + } `yaml:"guilds"` +} + +func upgradeConfig(helper up.Helper) { + helper.Copy(up.List, "guilds", "bridging_guild_ids") +} + +func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { + return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig) } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 858c22d..7f66d22 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -26,6 +26,7 @@ import ( type DiscordConnector struct { Bridge *bridgev2.Bridge + Config *Config MsgConv *msgconv.MessageConverter } diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index d4ecb85..6d8fbd5 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -24,6 +24,9 @@ import ( func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { return database.MetaTypes{ + Portal: func() any { + return &discordid.PortalMetadata{} + }, UserLogin: func() any { return &discordid.UserLoginMetadata{} }, diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 3395d5b..aa263db 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -21,15 +21,18 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) type DiscordChatResync struct { + Client *DiscordClient channel *discordgo.Channel portalKey networkid.PortalKey - info *bridgev2.ChatInfo } var ( @@ -55,11 +58,112 @@ func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType { return bridgev2.RemoteEventChatResync } -func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - if d.info == nil { - return nil, nil +func (d *DiscordChatResync) avatar(ctx context.Context) *bridgev2.Avatar { + ch := d.channel + + // TODO make this configurable (ala workspace_avatar_in_rooms) + if !d.isPrivate() { + guild, err := d.Client.Session.State.Guild(ch.GuildID) + + if err != nil || guild == nil { + zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar") + return nil + } + + return d.Client.makeAvatarForGuild(guild) } - return d.info, nil + + return &bridgev2.Avatar{ + ID: networkid.AvatarID(ch.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) + return simpleDownload(ctx, url, "group dm icon") + }, + Remove: ch.Icon == "", + } +} + +func (d *DiscordChatResync) privateChannelMemberList() bridgev2.ChatMemberList { + ch := d.channel + + var members bridgev2.ChatMemberList + members.IsFull = true + members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) + if len(ch.Recipients) > 0 { + selfEventSender := d.Client.selfEventSender() + + // Private channels' array of participants doesn't include ourselves, + // so inject ourselves as a member. + members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} + + for _, recipient := range ch.Recipients { + sender := d.Client.makeEventSender(recipient) + members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} + } + + members.TotalMemberCount = len(ch.Recipients) + } + + return members +} + +func (d *DiscordChatResync) memberList() bridgev2.ChatMemberList { + if d.isPrivate() { + return d.privateChannelMemberList() + } + + // TODO we're _always_ sending partial member lists for guilds; we can probably + // do better + selfEventSender := d.Client.selfEventSender() + + return bridgev2.ChatMemberList{ + IsFull: false, + MemberMap: map[networkid.UserID]bridgev2.ChatMember{ + selfEventSender.Sender: {EventSender: selfEventSender}, + }, + } +} + +func (d *DiscordChatResync) isPrivate() bool { + ch := d.channel + return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM +} + +func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + ch := d.channel + + var roomType database.RoomType + + switch ch.Type { + case discordgo.ChannelTypeDM: + roomType = database.RoomTypeDM + case discordgo.ChannelTypeGroupDM: + roomType = database.RoomTypeGroupDM + } + + info := &bridgev2.ChatInfo{ + Name: &ch.Name, + Members: ptr.Ptr(d.memberList()), + Avatar: d.avatar(ctx), + Type: &roomType, + CanBackfill: true, + ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) { + meta := portal.Metadata.(*discordid.PortalMetadata) + if meta.GuildID != ch.GuildID { + meta.GuildID = ch.GuildID + changed = true + } + + return + }, + } + + if !d.isPrivate() { + // Channel belongs to a guild; associate it with the respective space. + info.ParentID = ptr.Ptr(d.Client.guildPortalKeyFromID(ch.GuildID).ID) + } + + return info, nil } func (d *DiscordChatResync) ShouldCreatePortal() bool { diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml new file mode 100644 index 0000000..bfd5c3c --- /dev/null +++ b/pkg/connector/example-config.yaml @@ -0,0 +1,6 @@ +# Configuration options related to Discord guilds (also known as "servers"). +guilds: + # UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure + # during bridge development. If no guild IDs are specified, then no guilds + # are bridged at all. + bridging_guild_ids: [] diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 1c5802b..1c74002 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -25,6 +25,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) var ( @@ -41,6 +43,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M } portal := msg.Portal + guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(portal.ID) sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg) @@ -50,8 +53,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M var options []discordgo.RequestOption // TODO: When supporting threads (and not a bot user), send a thread referer. - // TODO: Pass the guild ID when send messages in guild channels. - options = append(options, discordgo.WithChannelReferer("", channelID)) + options = append(options, discordgo.WithChannelReferer(guildID, channelID)) sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), sendReq, options...) if err != nil { @@ -84,12 +86,11 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) { - key := reaction.Content.RelatesTo.Key + relatesToKey := reaction.Content.RelatesTo.Key portal := reaction.Portal - // TODO: Support guilds. - guildID := "" + meta := portal.Metadata.(*discordid.PortalMetadata) - err := d.Session.MessageReactionAddUser(guildID, string(portal.ID), string(reaction.TargetMessage.ID), key) + err := d.Session.MessageReactionAddUser(meta.GuildID, string(portal.ID), string(reaction.TargetMessage.ID), relatesToKey) return nil, err } @@ -97,8 +98,7 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal removing := removal.TargetReaction emojiID := removing.EmojiID channelID := string(removing.Room.ID) - // TODO: Support guilds. - guildID := "" + guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID err := d.Session.MessageReactionRemoveUser(guildID, channelID, string(removing.MessageID), string(emojiID), string(d.UserLogin.ID)) return err @@ -149,9 +149,10 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge } } - // TODO: Support guilds and threads. + // TODO: Support threads. + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(msg.Portal.ID) - resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer("", channelID)) + resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Err(err).Msg("Failed to send read receipt to Discord") return err @@ -167,7 +168,11 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge } func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error { - // TODO: When guilds are supported, explicitly bail out if this method is called with a guild channel. + if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" { + // Only private channels need this logic. + return nil + } + d.markedOpenedLock.Lock() defer d.markedOpenedLock.Unlock() @@ -201,9 +206,10 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma // Don't mind if this fails. _ = d.viewingChannel(ctx, msg.Portal) + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(msg.Portal.ID) - // TODO: Support guilds and threads properly when sending the referer. - err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer("", channelID)) + // TODO: Support threads properly when sending the referer. + err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Warn().Err(err).Msg("Failed to mark user as typing") diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index 2e8131b..1f2b939 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -18,6 +18,15 @@ package discordid import "github.com/bwmarrin/discordgo" +type PortalMetadata struct { + // The ID of the Discord guild that the channel corresponding to this portal + // belongs to. + // + // For private channels (DMs and group DMs), this will be the zero value + // (an empty string). + GuildID string `json:"guild_id"` +} + type UserLoginMetadata struct { Token string `json:"token"` HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 2dddac4..317a48b 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -31,6 +31,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) const discordEpochMillis = 1420070400000 @@ -109,6 +111,7 @@ func (mc *MessageConverter) ToDiscord( } portal := msg.Portal + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(portal.ID) content := msg.Content @@ -152,8 +155,8 @@ func (mc *MessageConverter) ToDiscord( Name: att.Filename, ID: mc.NextDiscordUploadID(), }}, - // TODO: Populate with guild ID. Support threads. - }, discordgo.WithChannelReferer("", channelID)) + // TODO: Support threads. + }, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") From e38998e68b6ad3032f9e7a45a2339ff3830b8780 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 13 Jan 2026 14:49:01 -0800 Subject: [PATCH 67/99] connector: don't store config as pointer --- pkg/connector/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 7f66d22..4ff64b7 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -26,7 +26,7 @@ import ( type DiscordConnector struct { Bridge *bridgev2.Bridge - Config *Config + Config Config MsgConv *msgconv.MessageConverter } From 3d59a0eb3fccd30d3c917dd9b5d06c8e2f9efb5b Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 13 Jan 2026 14:49:01 -0800 Subject: [PATCH 68/99] login/remoteauth: tolerate multiple attempts to cancel This will otherwise panic. --- pkg/connector/login_remoteauth.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index 98b1657..b9f4a03 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -31,6 +31,7 @@ const LoginFlowIDRemoteAuth = "fi.mau.discord.login.remote_auth" type DiscordRemoteAuthLogin struct { *DiscordGenericLogin + hasClosed bool remoteAuthClient *remoteauth.Client qrChan chan string doneChan chan struct{} @@ -125,6 +126,12 @@ func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, u } func (dl *DiscordRemoteAuthLogin) Cancel() { + // Tolerate multiple attempts to cancel. + if dl.hasClosed { + return + } + dl.hasClosed = true + dl.User.Log.Debug().Msg("Discord remoteauth cancelled") dl.DiscordGenericLogin.Cancel() From bdbfd661a2e7591911d05bcfaac90e56af94ff4e Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 13 Jan 2026 14:49:01 -0800 Subject: [PATCH 69/99] msgconv: don't set `EncryptedFile` if there isn't one FIXME: This probably isn't proper handling, but this is enough to prevent panics. --- pkg/attachment/attachment.go | 3 ++- pkg/msgconv/from-discord.go | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go index c1d333c..5890029 100644 --- a/pkg/attachment/attachment.go +++ b/pkg/attachment/attachment.go @@ -34,5 +34,6 @@ type ReuploadedAttachment struct { AttachmentReupload DownloadedSize int MXC id.ContentURIString - EncryptedFile *event.EncryptedFileInfo + // This can be nil if the room isn't encrypted. + EncryptedFile *event.EncryptedFileInfo } diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index a7119fd..a0cdcc5 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -488,9 +488,11 @@ func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, int } preview.ImageSize = event.IntOrString(reupload.DownloadedSize) preview.ImageType = reupload.MimeType - preview.ImageEncryption = &event.EncryptedFileInfo{ - EncryptedFile: reupload.EncryptedFile.EncryptedFile, - URL: reupload.MXC, + if reupload.EncryptedFile != nil { + preview.ImageEncryption = &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + } } } From ac338ee7228bcf89efeb0996148386f4f8c227a8 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 14 Jan 2026 17:52:06 -0800 Subject: [PATCH 70/99] msgconv: correctly bridge attachments and embeds `URL` needs to be set if the room/attachment is unencrypted; otherwise, `File` needs to be set. --- pkg/msgconv/from-discord.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index a0cdcc5..58511fc 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -289,7 +289,11 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent MimeType: reupload.MimeType, Size: reupload.DownloadedSize, }, - File: reupload.EncryptedFile, + } + if reupload.EncryptedFile != nil { + content.File = reupload.EncryptedFile + } else { + content.URL = reupload.MXC } if embed.Video != nil { @@ -570,8 +574,11 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent content.Info.Width = att.Width content.Info.Height = att.Height } - content.URL = reupload.MXC - content.File = reupload.EncryptedFile + if reupload.EncryptedFile != nil { + content.File = reupload.EncryptedFile + } else { + content.URL = reupload.MXC + } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, From 5b7a7a430ce5033b852ed3497a7915a18ff3b65e Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 15 Jan 2026 15:53:15 -0800 Subject: [PATCH 71/99] connector: fix log messages when downloading guild avatars --- pkg/connector/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index b324377..0b0250b 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -311,7 +311,7 @@ func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Ava ID: networkid.AvatarID(guild.Icon), Get: func(ctx context.Context) ([]byte, error) { url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) - return simpleDownload(ctx, url, "group dm icon") + return simpleDownload(ctx, url, "guild icon") }, Remove: guild.Icon == "", } From 7849c09443d58d3fcbaa2aefae9cc92109ddf8cf Mon Sep 17 00:00:00 2001 From: Skip R Date: Thu, 15 Jan 2026 15:53:15 -0800 Subject: [PATCH 72/99] connector: send bridge state updates on gateway events This also makes the account properly appear in client settings after provisioning. --- pkg/connector/client.go | 4 ---- pkg/connector/handlediscord.go | 11 +++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 0b0250b..fcd1806 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -115,10 +115,6 @@ func (d *DiscordClient) Connect(ctx context.Context) { if err := d.connect(ctx); err != nil { log.Err(err).Msg("Couldn't connect to Discord") } - // TODO(skip): Use event handler and send this in response to READY/RESUMED instead? - d.UserLogin.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateConnected, - }) } func (cl *DiscordClient) handleDiscordEventSync(event any) { diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 031fdc2..67227e4 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -25,6 +25,7 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" ) type DiscordEventMeta struct { @@ -193,6 +194,16 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { Logger() switch evt := rawEvt.(type) { + case *discordgo.Ready: + log.Info().Msg("Received READY dispatch from discordgo") + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnected, + }) + case *discordgo.Resumed: + log.Info().Msg("Received RESUMED dispatch from discordgo") + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnected, + }) case *discordgo.MessageCreate: if evt.Author == nil { log.Trace().Int("message_type", int(evt.Message.Type)). From 5c4527f1b2cbd8ea63bd3f58f325450e2231b522 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 21 Jan 2026 18:47:43 +0200 Subject: [PATCH 73/99] Disable restricted rooms by default --- example-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example-config.yaml b/example-config.yaml index ea392bb..bfe30d4 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -130,7 +130,7 @@ bridge: message_error_notices: true # Should the bridge use space-restricted join rules instead of invite-only for guild rooms? # This can avoid unnecessary invite events in guild rooms when members are synced in. - restricted_rooms: true + restricted_rooms: false # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). autojoin_thread_on_open: true From 689f8b99981cba86dd92d652cc0cc2e78ebd253a Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 20 Jan 2026 16:10:14 -0800 Subject: [PATCH 74/99] mautrix-go@v0.26.2 --- go.mod | 26 +++++++++++++------------- go.sum | 47 ++++++++++++++++++++++++----------------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 7d80acc..423ce7b 100644 --- a/go.mod +++ b/go.mod @@ -7,35 +7,35 @@ toolchain go1.25.4 require ( github.com/bwmarrin/discordgo v0.27.0 github.com/gorilla/websocket v1.5.0 - go.mau.fi/util v0.9.3 - maunium.net/go/mautrix v0.26.1-0.20251119212156-1fac8ceb6653 + github.com/rs/zerolog v1.34.0 + github.com/yuin/goldmark v1.7.16 + go.mau.fi/util v0.9.5 + maunium.net/go/mautrix v0.26.2 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/coder/websocket v1.8.14 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect - github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/rs/xid v1.6.0 // indirect - github.com/rs/zerolog v1.34.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/yuin/goldmark v1.7.13 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.44.0 // indirect - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect diff --git a/go.sum b/go.sum index 3f4e757..c52c58a 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,9 @@ github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4Ghar github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -24,10 +25,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4= -github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -49,27 +50,27 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU= -go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4= +go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -78,5 +79,5 @@ 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/mautrix v0.26.1-0.20251119212156-1fac8ceb6653 h1:z62cPre4V+NGIonN5qlvt1ZtZH5/93ix9DR7KR8yImU= -maunium.net/go/mautrix v0.26.1-0.20251119212156-1fac8ceb6653/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q= +maunium.net/go/mautrix v0.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g= +maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo= From b4fdd8b9ed58d0ad5dba32b90ee8aecc1a127b1f Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 21 Jan 2026 06:54:58 -0800 Subject: [PATCH 75/99] connector: don't crash upon less than 10 private channels --- pkg/connector/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index fcd1806..0473dc3 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -213,7 +213,11 @@ func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { }) // TODO(skip): This is startup_private_channel_create_limit. Support this in the config. - for _, dm := range dms[:10] { + maxDms := 10 + if maxDms > len(dms) { + maxDms = len(dms) + } + for _, dm := range dms[:maxDms] { zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity") d.syncChannel(ctx, dm) } From 5e0f9b909a82fb80372ae1020ebc8354c66986dd Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 21 Jan 2026 06:54:58 -0800 Subject: [PATCH 76/99] connector: break out guild bridging logic into method --- pkg/connector/client.go | 94 +++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 0473dc3..2c38584 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -360,52 +360,62 @@ func (d *DiscordClient) syncGuilds(ctx context.Context) { Str("action", "sync guild"). Logger() - guild, err := d.Session.State.Guild(guildID) - if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil { - log.Err(err).Msg("Couldn't find guild, user isn't a member?") - continue - } - - err = d.syncGuildSpace(ctx, guild) + err := d.bridgeGuild(log.WithContext(ctx), guildID) if err != nil { - log.Err(err).Msg("Couldn't sync guild space portal") - continue - } - - for _, guildCh := range guild.Channels { - if guildCh.Type != discordgo.ChannelTypeGuildText { - // TODO implement categories (spaces) and news channels - log.Trace(). - Str("channel_id", guildCh.ID). - Int("channel_type", int(guildCh.Type)). - Msg("Not bridging guild channel due to type") - continue - } - - if !d.canSeeGuildChannel(ctx, guildCh) { - log.Trace(). - Str("channel_id", guildCh.ID). - Int("channel_type", int(guildCh.Type)). - Msg("Not bridging guild channel that the user doesn't have permission to view") - - continue - } - - d.syncChannel(ctx, guildCh) - } - - log.Debug().Msg("Subscribing to guild after bridging") - err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{ - GuildID: guild.ID, - Typing: true, - Activities: true, - Threads: true, - }) - if err != nil { - log.Warn().Err(err).Msg("Failed to subscribe to guild") + log.Err(err).Msg("Couldn't bridge guild during sync") } } +} +func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error { + log := zerolog.Ctx(ctx) + + guild, err := d.Session.State.Guild(guildID) + if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil { + log.Err(err).Msg("Couldn't find guild, user isn't a member?") + return errors.New("couldn't find guild in state") + } + + err = d.syncGuildSpace(ctx, guild) + if err != nil { + log.Err(err).Msg("Couldn't sync guild space portal") + return fmt.Errorf("couldn't sync guild space portal: %w", err) + } + + for _, guildCh := range guild.Channels { + if guildCh.Type != discordgo.ChannelTypeGuildText { + // TODO implement categories (spaces) and news channels + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel due to type") + continue + } + + if !d.canSeeGuildChannel(ctx, guildCh) { + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel that the user doesn't have permission to view") + + continue + } + + d.syncChannel(ctx, guildCh) + } + + log.Debug().Msg("Subscribing to guild after bridging") + err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{ + GuildID: guild.ID, + Typing: true, + Activities: true, + Threads: true, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding") + } + + return nil } func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { From 138c77c34e875ce09de5020eb54178ae2604a179 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 21 Jan 2026 06:54:58 -0800 Subject: [PATCH 77/99] provisioning: sketch out implementation for now, this is completely unauthenticated --- pkg/connector/connector.go | 1 + pkg/connector/provisioning.go | 204 ++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 pkg/connector/provisioning.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 4ff64b7..10a625f 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -35,6 +35,7 @@ var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia) + d.setUpProvisioningAPIs() } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go new file mode 100644 index 0000000..1860171 --- /dev/null +++ b/pkg/connector/provisioning.go @@ -0,0 +1,204 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "errors" + "net/http" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "go.mau.fi/util/exhttp" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" +) + +const ( + ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED" + ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN" + ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED" + ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED" + ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED" + ErrCodeGuildBridgeFailed = "M_UNKNOWN" + ErrCodeGuildUnbridgeFailed = "M_UNKNOWN" + ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED" + ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED" + ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED" + ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED" + ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED" +) + +type ProvisioningAPI struct { + log zerolog.Logger + connector *DiscordConnector + prov bridgev2.IProvisioningAPI +} + +func (d *DiscordConnector) setUpProvisioningAPIs() error { + c, ok := d.Bridge.Matrix.(bridgev2.MatrixConnectorWithProvisioning) + if !ok { + return errors.New("matrix connector doesn't support provisioning; not setting up") + } + + prov := c.GetProvisioning() + r := prov.GetRouter() + if r == nil { + return errors.New("matrix connector's provisioning api didn't return a router") + } + + log := d.Bridge.Log.With().Str("component", "provisioning").Logger() + p := &ProvisioningAPI{ + connector: d, + log: log, + prov: prov, + } + + // NOTE: aim to provide backwards compatibility with v1 provisioning APIs + r.HandleFunc("GET /v1/guilds", p.makeHandler(p.guildsList)) + r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild)) + r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild)) + + return nil +} + +type guildEntry struct { + ID string `json:"id"` + Name string `json:"name"` + + // TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here + AvatarURL string `json:"avatar_url"` + + // v1-compatible fields: + MXID string `json:"mxid"` + AutoBridge bool `json:"auto_bridge_channels"` + BridgingMode string `json:"bridging_mode"` + + Available bool `json:"available"` +} +type respGuildsList struct { + Guilds []guildEntry `json:"guilds"` +} + +func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := p.prov.GetUser(r) + logins := user.GetUserLogins() + + if len(logins) < 1 { + mautrix.RespError{ + ErrCode: ErrCodeNotConnected, + Err: "user has no logins", + }.Write(w) + return + } + + login := logins[0] + client := login.Client.(*DiscordClient) + + handler(w, r, login, client) + } +} + +func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + p.log.Info().Str("login_id", string(login.ID)).Msg("guilds list requested via provisioning api") + + var resp respGuildsList + resp.Guilds = []guildEntry{} + for _, guild := range client.Session.State.Guilds { + resp.Guilds = append(resp.Guilds, guildEntry{ + ID: guild.ID, + Name: guild.Name, + AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon), + + BridgingMode: "everything", + + Available: !guild.Unavailable, + }) + } + + exhttp.WriteJSONResponse(w, 200, resp) +} + +func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + guildID := r.PathValue("guildID") + if guildID == "" { + mautrix.MInvalidParam.WithMessage("no guild id").Write(w) + return + } + + p.log.Info(). + Str("login_id", string(login.ID)). + Str("guild_id", guildID). + Msg("requested to bridge guild via provisioning api") + + // TODO detect guild already bridged + go client.bridgeGuild(context.TODO(), guildID) + + exhttp.WriteJSONResponse(w, 201, nil) +} + +func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + guildID := r.PathValue("guildID") + if guildID == "" { + mautrix.MInvalidParam.WithMessage("no guild id").Write(w) + return + } + + p.log.Info(). + Str("login_id", string(login.ID)). + Str("guild_id", guildID). + Msg("requested to unbridge guild via provisioning api") + + ctx := context.TODO() + + portalKey := client.guildPortalKeyFromID(guildID) + portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil { + p.log.Err(err).Msg("Failed to get guild portal") + mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w) + return + } + if portal == nil || portal.MXID == "" { + mautrix.RespError{ + ErrCode: ErrCodeGuildNotBridged, + Err: "guild is not bridged", + }.Write(w) + return + } + + children, err := p.connector.Bridge.GetChildPortals(ctx, portalKey) + if err != nil { + p.log.Err(err).Msg("Failed to get child portals") + mautrix.MUnknown.WithMessage("failed to get children: %v", err).Write(w) + return + } + + portalsToDelete := append(children, portal) + bridgev2.DeleteManyPortals(ctx, portalsToDelete, func(portal *bridgev2.Portal, del bool, err error) { + p.log.Err(err). + Stringer("portal_mxid", portal.MXID). + Bool("delete_room", del). + Msg("Failed during portal cleanup") + }) + + p.log.Info().Int("children", len(children)).Msg("Finished unbridging") + exhttp.WriteJSONResponse(w, 200, map[string]any{ + "success": true, + "deleted_portals": len(children) + 1, + }) +} From 9b3ead71867f2c20b9159402733678fa71c1b8e5 Mon Sep 17 00:00:00 2001 From: Skip R Date: Wed, 21 Jan 2026 06:54:58 -0800 Subject: [PATCH 78/99] doc: add WIP caution to readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 73aad93..b54d262 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,12 @@ # mautrix-discord + +> [!CAUTION] +> This branch houses a work-in-progress rewrite of the bridge to interface with +> [Megabridge/"bridgev2"][bridgev2]. This branch is **NOT** ready for general +> consumption, especially for self-hosting. + +[bridgev2]: https://github.com/mautrix/go/tree/38278ef37d199d3a9deba04b825a094eea6c1d10/bridgev2/unorganized-docs + A Matrix-Discord puppeting bridge based on [discordgo](https://github.com/bwmarrin/discordgo). ## Documentation From 2f8de6635a250bc9a2adbfef06f48c54afe4f269 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 27 Jan 2026 19:08:32 -0800 Subject: [PATCH 79/99] msgconv/from-discord: refactor forwarded message conversion --- pkg/msgconv/from-discord.go | 73 +++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 58511fc..2c25771 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -51,6 +51,10 @@ func (mc *MessageConverter) ToMatrix( parts = append(parts, textPart) } + ctx = zerolog.Ctx(ctx).With(). + Str("action", "convert discord message to matrix"). + Str("message_id", msg.ID). + Logger().WithContext(ctx) log := zerolog.Ctx(ctx) handledIDs := make(map[string]struct{}) @@ -167,38 +171,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent len(msg.MessageSnapshots) > 0 && msg.MessageSnapshots[0].Message != nil { // Bridge forwarded messages. - - forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) - msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") - origLink := fmt.Sprintf("unknown channel • %s", msgTSText) - if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { - if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { - // We've bridged the message that was forwarded, so we can link to it directly. - origLink = fmt.Sprintf( - `#%s • %s`, - forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()), - forwardedFromPortal.Name, - msgTSText, - ) - } else if err != nil { - log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message") - } else if forwardedFromPortal.MXID != "" { - // We don't have the message but we have the portal, so link to that. - origLink = fmt.Sprintf( - `#%s • %s`, - forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()), - forwardedFromPortal.Name, - msgTSText, - ) - } else if forwardedFromPortal.Name != "" { - // We only have the name of the portal. - origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText) - } - } else { - log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message") - } - - htmlParts = append(htmlParts, fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink)) + htmlParts = append(htmlParts, mc.forwardedMessageHtmlPart(ctx, portal, source, msg)) } previews := make([]*event.BeeperLinkPreview, 0) @@ -247,6 +220,42 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} } +func (mc *MessageConverter) forwardedMessageHtmlPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string { + log := zerolog.Ctx(ctx) + + forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) + msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") + origLink := fmt.Sprintf("unknown channel • %s", msgTSText) + if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { + if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + // We've bridged the message that was forwarded, so we can link to it directly. + origLink = fmt.Sprintf( + `#%s • %s`, + forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()), + forwardedFromPortal.Name, + msgTSText, + ) + } else if err != nil { + log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message") + } else if forwardedFromPortal.MXID != "" { + // We don't have the message but we have the portal, so link to that. + origLink = fmt.Sprintf( + `#%s • %s`, + forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()), + forwardedFromPortal.Name, + msgTSText, + ) + } else if forwardedFromPortal.Name != "" { + // We only have the name of the portal. + origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText) + } + } else { + log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message") + } + + return fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink) +} + func mediaFailedMessage(err error) *event.MessageEventContent { return &event.MessageEventContent{ Body: fmt.Sprintf("Failed to bridge media: %v", err), From 578030a9dd56297ce3302b529b367628f76fdfb3 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 27 Jan 2026 19:11:21 -0800 Subject: [PATCH 80/99] msgconv/from-discord: only complain about portal when we can't find it --- pkg/msgconv/from-discord.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 2c25771..eecab6b 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -249,7 +249,7 @@ func (mc *MessageConverter) forwardedMessageHtmlPart(ctx context.Context, portal // We only have the name of the portal. origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText) } - } else { + } else if err != nil { log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message") } From b8a01bf9d41b381af52852285b26b4a7d1f6e20e Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 27 Jan 2026 19:12:20 -0800 Subject: [PATCH 81/99] msgconv/from-discord: use a tagged switch instead of `if` --- pkg/msgconv/from-discord.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index eecab6b..d0e35c2 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -139,12 +139,13 @@ const msgComponentTemplateHTML = `

    This message contains interactive elements. func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { log := zerolog.Ctx(ctx) - if msg.Type == discordgo.MessageTypeCall { + switch msg.Type { + case discordgo.MessageTypeCall: return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "started a call", }} - } else if msg.Type == discordgo.MessageTypeGuildMemberJoin { + case discordgo.MessageTypeGuildMemberJoin: return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "joined the server", From 1a3144d2d0e1ea30f3a3b3bfc58ecf14680acc3b Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 27 Jan 2026 22:05:33 -0800 Subject: [PATCH 82/99] msgconv/from-discord: bridge replies --- pkg/msgconv/from-discord.go | 65 ++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index d0e35c2..b99ddfe 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -122,7 +122,18 @@ func (mc *MessageConverter) ToMatrix( part.ID = networkid.PartID(strconv.Itoa(i)) } - return &bridgev2.ConvertedMessage{Parts: parts} + converted := &bridgev2.ConvertedMessage{Parts: parts} + // TODO This is sorta gross; it might be worth bundling these parameters + // into a struct. + mc.tryAddingReplyToConvertedMessage( + ctx, + converted, + portal, + source, + msg, + ) + + return converted } const forwardTemplateHTML = `

    @@ -137,6 +148,58 @@ const msgInteractionTemplateHTML = `
    const msgComponentTemplateHTML = `

    This message contains interactive elements. Use the Discord app to interact with the message.

    ` +func (mc *MessageConverter) tryAddingReplyToConvertedMessage( + ctx context.Context, + converted *bridgev2.ConvertedMessage, + portal *bridgev2.Portal, + source *bridgev2.UserLogin, + msg *discordgo.Message, +) { + ref := msg.MessageReference + if ref == nil { + return + } + // TODO: Support threads. + + log := zerolog.Ctx(ctx).With(). + Str("referenced_channel_id", ref.ChannelID). + Str("referenced_guild_id", ref.GuildID). + Str("referenced_message_id", ref.MessageID).Logger() + + // The portal containing the message that was replied to. + targetPortal := portal + if ref.ChannelID != string(portal.ID) { + var err error + targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID)) + if err != nil { + log.Err(err).Msg("Failed to get cross-room reply portal; proceeding") + return + } + + if targetPortal == nil { + return + } + } + + messageID := networkid.MessageID(ref.MessageID) + repliedToMatrixMsg, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, messageID) + if err != nil { + log.Err(err).Msg("Failed to query database for first message part; proceeding") + return + } + if repliedToMatrixMsg == nil { + log.Debug().Msg("Couldn't find a first message part for reply target; proceeding") + return + } + + converted.ReplyTo = &networkid.MessageOptionalPartID{ + MessageID: repliedToMatrixMsg.ID, + PartID: &repliedToMatrixMsg.PartID, + } + converted.ReplyToRoom = targetPortal.PortalKey + converted.ReplyToUser = repliedToMatrixMsg.SenderID +} + func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { log := zerolog.Ctx(ctx) switch msg.Type { From d89746d099cfb6f65137cd60a8c50decddbcc77f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 17:05:23 +0200 Subject: [PATCH 83/99] msgconv: clean up reuploading attachments to Matrix --- pkg/attachment/attachment.go | 39 --------- pkg/connector/attachments.go | 104 ----------------------- pkg/connector/backfill.go | 2 +- pkg/connector/connector.go | 11 ++- pkg/connector/handlediscord.go | 2 +- pkg/msgconv/attachments.go | 147 +++++++++++++++++++++++++++++++++ pkg/msgconv/from-discord.go | 141 ++++++++++++++----------------- pkg/msgconv/from-matrix.go | 2 + pkg/msgconv/msgconv.go | 18 +--- 9 files changed, 228 insertions(+), 238 deletions(-) delete mode 100644 pkg/attachment/attachment.go delete mode 100644 pkg/connector/attachments.go create mode 100644 pkg/msgconv/attachments.go diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go deleted file mode 100644 index 5890029..0000000 --- a/pkg/attachment/attachment.go +++ /dev/null @@ -1,39 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2026 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package attachment - -import ( - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// TODO(skip): These types are only in a leaf package to avoid import cycles. -// Perhaps figure out a better way to structure this so that this package is unnecessary. - -type AttachmentReupload struct { - DownloadingURL string - FileName string - MimeType string -} - -type ReuploadedAttachment struct { - AttachmentReupload - DownloadedSize int - MXC id.ContentURIString - // This can be nil if the room isn't encrypted. - EncryptedFile *event.EncryptedFileInfo -} diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go deleted file mode 100644 index c552deb..0000000 --- a/pkg/connector/attachments.go +++ /dev/null @@ -1,104 +0,0 @@ -// mautrix-discord - A Matrix-Discord puppeting bridge. -// Copyright (C) 2026 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package connector - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strconv" - - "github.com/bwmarrin/discordgo" - "maunium.net/go/mautrix/bridgev2" - - "go.mau.fi/mautrix-discord/pkg/attachment" -) - -func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - for key, value := range discordgo.DroidDownloadHeaders { - req.Header.Set(key, value) - } - - resp, err := cli.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode > 300 { - data, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data) - } - if resp.Header.Get("Content-Length") != "" { - length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse content length: %w", err) - } else if length > maxSize { - return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize) - } - return io.ReadAll(resp.Body) - } else { - var mbe *http.MaxBytesError - data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize)) - if err != nil && errors.As(err, &mbe) { - return nil, fmt.Errorf("attachment too large (over %d)", maxSize) - } - return data, err - } -} - -func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) { - // TODO(skip): Do we need to check if we've already downloaded this media before? - // TODO(skip): Read a maximum size from the config. - data, err := downloadDiscordAttachment(http.DefaultClient, upload.DownloadingURL, 1_024*1_024*50) - if err != nil { - return nil, fmt.Errorf("couldn't download attachment for reupload: %w", err) - } - - if upload.FileName == "" { - url, err := url.Parse(upload.DownloadingURL) - if err != nil { - return nil, fmt.Errorf("couldn't parse URL to download for media reupload: %w", err) - } - fileName := path.Base(url.Path) - upload.FileName = fileName - } - - if upload.MimeType == "" { - mime := http.DetectContentType(data) - upload.MimeType = mime - } - - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, upload.FileName, upload.MimeType) - if err != nil { - return nil, err - } - - return &attachment.ReuploadedAttachment{ - AttachmentReupload: upload, - DownloadedSize: len(data), - MXC: mxc, - EncryptedFile: file, - }, nil -} diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index f14e151..b49354d 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -100,7 +100,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 converted = append(converted, &bridgev2.BackfillMessage{ ID: networkid.MessageID(msg.ID), - ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg), + ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, dc.Session, msg), Sender: sender, Timestamp: ts, StreamOrder: streamOrder, diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 10a625f..d866e49 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -30,14 +30,21 @@ type DiscordConnector struct { MsgConv *msgconv.MessageConverter } -var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) +var ( + _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) + _ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil) +) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge - d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia) + d.MsgConv = msgconv.NewMessageConverter(bridge) d.setUpProvisioningAPIs() } +func (d *DiscordConnector) SetMaxFileSize(maxSize int64) { + d.MsgConv.MaxFileSize = maxSize +} + func (d *DiscordConnector) Start(ctx context.Context) error { return nil } diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 67227e4..eb328dd 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -63,7 +63,7 @@ var ( ) func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { - return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Data), nil + return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil } func (m *DiscordMessage) GetID() networkid.MessageID { diff --git a/pkg/msgconv/attachments.go b/pkg/msgconv/attachments.go new file mode 100644 index 0000000..72a7ae9 --- /dev/null +++ b/pkg/msgconv/attachments.go @@ -0,0 +1,147 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type ReuploadedAttachment struct { + MXC id.ContentURIString + File *event.EncryptedFileInfo + Size int + FileName string + MimeType string +} + +func (d *MessageConverter) ReuploadUnknownMedia( + ctx context.Context, + url string, + allowEncryption bool, +) (*ReuploadedAttachment, error) { + return d.ReuploadMedia(ctx, url, "", "", -1, allowEncryption) +} + +func mib(size int64) float64 { + return float64(size) / 1024 / 1024 +} + +func (d *MessageConverter) ReuploadMedia( + ctx context.Context, + downloadURL string, + mimeType string, + fileName string, + estimatedSize int, + allowEncryption bool, +) (*ReuploadedAttachment, error) { + if fileName == "" { + parsedURL, err := url.Parse(downloadURL) + if err != nil { + return nil, fmt.Errorf("couldn't parse URL to detect file name: %w", err) + } + fileName = path.Base(parsedURL.Path) + } + + sess := ctx.Value(contextKeyDiscordClient).(*discordgo.Session) + httpClient := sess.Client + intent := ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI) + var roomID id.RoomID + if allowEncryption { + roomID = ctx.Value(contextKeyPortal).(*bridgev2.Portal).MXID + } + + req, err := http.NewRequest(http.MethodGet, downloadURL, nil) + if err != nil { + return nil, err + } + if sess.IsUser { + for key, value := range discordgo.DroidDownloadHeaders { + req.Header.Set(key, value) + } + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode > 300 { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + logEvt := zerolog.Ctx(ctx).Error(). + Str("media_url", downloadURL). + Int("status_code", resp.StatusCode) + if json.Valid(errBody) { + logEvt.RawJSON("error_json", errBody) + } else { + logEvt.Bytes("error_body", errBody) + } + logEvt.Msg("Media download failed") + return nil, fmt.Errorf("%w: unexpected status code %d", bridgev2.ErrMediaDownloadFailed, resp.StatusCode) + } else if resp.ContentLength > d.MaxFileSize { + return nil, fmt.Errorf("%w (%.2f MiB > %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(resp.ContentLength), mib(d.MaxFileSize)) + } + + requireFile := mimeType == "" + var size int64 + mxc, file, err := intent.UploadMediaStream(ctx, roomID, int64(estimatedSize), requireFile, func(file io.Writer) (*bridgev2.FileStreamResult, error) { + var mbe *http.MaxBytesError + size, err = io.Copy(file, http.MaxBytesReader(nil, resp.Body, d.MaxFileSize)) + if err != nil { + if errors.As(err, &mbe) { + return nil, fmt.Errorf("%w (over %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(d.MaxFileSize)) + } + return nil, err + } + if mimeType == "" { + mimeBuf := make([]byte, 512) + n, err := file.(*os.File).ReadAt(mimeBuf, 0) + if err != nil { + return nil, fmt.Errorf("couldn't read file for mime detection: %w", err) + } + mimeType = http.DetectContentType(mimeBuf[:n]) + } + return &bridgev2.FileStreamResult{ + FileName: fileName, + MimeType: mimeType, + }, nil + }) + if err != nil { + return nil, err + } + + return &ReuploadedAttachment{ + Size: int(size), + MXC: mxc, + File: file, + FileName: fileName, + MimeType: mimeType, + }, nil +} diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index b99ddfe..5dfa9dd 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -26,22 +26,36 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" + "go.mau.fi/util/exmaps" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" - "go.mau.fi/mautrix-discord/pkg/attachment" "go.mau.fi/mautrix-discord/pkg/discordid" ) +type contextKey int + +const ( + contextKeyPortal contextKey = iota + contextKeyIntent + contextKeyUserLogin + contextKeyDiscordClient +) + func (mc *MessageConverter) ToMatrix( ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, source *bridgev2.UserLogin, + session *discordgo.Session, msg *discordgo.Message, ) *bridgev2.ConvertedMessage { + ctx = context.WithValue(ctx, contextKeyUserLogin, source) + ctx = context.WithValue(ctx, contextKeyIntent, intent) + ctx = context.WithValue(ctx, contextKeyPortal, portal) + ctx = context.WithValue(ctx, contextKeyDiscordClient, session) predictedLength := len(msg.Attachments) + len(msg.StickerItems) if msg.Content != "" { predictedLength++ @@ -56,28 +70,26 @@ func (mc *MessageConverter) ToMatrix( Str("message_id", msg.ID). Logger().WithContext(ctx) log := zerolog.Ctx(ctx) - handledIDs := make(map[string]struct{}) + handledIDs := make(exmaps.Set[string]) for _, att := range msg.Attachments { - if _, handled := handledIDs[att.ID]; handled { + if !handledIDs.Add(att.ID) { continue } - handledIDs[att.ID] = struct{}{} log := log.With().Str("attachment_id", att.ID).Logger() - if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, portal, att); part != nil { + if part := mc.renderDiscordAttachment(log.WithContext(ctx), att); part != nil { parts = append(parts, part) } } for _, sticker := range msg.StickerItems { - if _, handled := handledIDs[sticker.ID]; handled { + if !handledIDs.Add(sticker.ID) { continue } - handledIDs[sticker.ID] = struct{}{} log := log.With().Str("sticker_id", sticker.ID).Logger() - if part := mc.renderDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil { + if part := mc.renderDiscordSticker(log.WithContext(ctx), sticker); part != nil { parts = append(parts, part) } } @@ -88,17 +100,16 @@ func (mc *MessageConverter) ToMatrix( continue } // Discord deduplicates embeds by URL. It makes things easier for us too. - if _, handled := handledIDs[embed.URL]; handled { + if !handledIDs.Add(embed.URL) { continue } - handledIDs[embed.URL] = struct{}{} log := log.With(). Str("computed_embed_type", "video"). Str("embed_type", string(embed.Type)). Int("embed_index", i). Logger() - part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, portal, embed) + part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), embed) if part != nil { parts = append(parts, part) } @@ -235,7 +246,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent len(msg.MessageSnapshots) > 0 && msg.MessageSnapshots[0].Message != nil { // Bridge forwarded messages. - htmlParts = append(htmlParts, mc.forwardedMessageHtmlPart(ctx, portal, source, msg)) + htmlParts = append(htmlParts, mc.forwardedMessageHTMLPart(ctx, portal, source, msg)) } previews := make([]*event.BeeperLinkPreview, 0) @@ -251,10 +262,10 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent switch getEmbedType(msg, embed) { case EmbedRich: log := with.Str("computed_embed_type", "rich").Logger() - htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, portal, embed)) + htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), embed)) case EmbedLinkPreview: log := with.Str("computed_embed_type", "link preview").Logger() - previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, portal, embed)) + previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), embed)) case EmbedVideo: // Video embeds are handled as separate messages via renderDiscordVideoEmbed. default: @@ -284,7 +295,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} } -func (mc *MessageConverter) forwardedMessageHtmlPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string { +func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string { log := zerolog.Ctx(ctx) forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) @@ -327,7 +338,7 @@ func mediaFailedMessage(err error) *event.MessageEventContent { } } -func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { +func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { var proxyURL string if embed.Video != nil { proxyURL = embed.Video.ProxyURL @@ -344,10 +355,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent } } - upload := attachment.AttachmentReupload{ - DownloadingURL: proxyURL, - } - reupload, err := mc.ReuploadMedia(ctx, intent, portal, upload) + reupload, err := mc.ReuploadUnknownMedia(ctx, proxyURL, true) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") return &bridgev2.ConvertedMessagePart{ @@ -358,16 +366,13 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent content := &event.MessageEventContent{ Body: embed.URL, + URL: reupload.MXC, + File: reupload.File, Info: &event.FileInfo{ MimeType: reupload.MimeType, - Size: reupload.DownloadedSize, + Size: reupload.Size, }, } - if reupload.EncryptedFile != nil { - content.File = reupload.EncryptedFile - } else { - content.URL = reupload.MXC - } if embed.Video != nil { content.MsgType = event.MsgVideo @@ -398,7 +403,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent } } -func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent bridgev2.MatrixAPI, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { +func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { panic("unimplemented") } @@ -423,7 +428,7 @@ const ( embedFooterDateSeparator = ` • ` ) -func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) string { +func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, embed *discordgo.MessageEmbed) string { log := zerolog.Ctx(ctx) var htmlParts []string if embed.Author != nil { @@ -434,9 +439,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) if embed.Author.ProxyIconURL != "" { - reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ - DownloadingURL: embed.Author.ProxyIconURL, - }) + reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Author.ProxyIconURL, false) if err != nil { log.Warn().Err(err).Msg("Failed to reupload author icon in embed") @@ -447,6 +450,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b htmlParts = append(htmlParts, authorHTML) } + portal := ctx.Value(contextKeyPortal).(*bridgev2.Portal) if embed.Title != "" { var titleHTML string baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false) @@ -492,9 +496,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } if embed.Image != nil { - reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ - DownloadingURL: embed.Image.ProxyURL, - }) + reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Image.ProxyURL, false) if err != nil { log.Warn().Err(err).Msg("Failed to reupload image in embed") } else { @@ -522,9 +524,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b } footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) if embed.Footer.ProxyIconURL != "" { - reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ - DownloadingURL: embed.Footer.ProxyIconURL, - }) + reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Footer.ProxyIconURL, false) if err != nil { log.Warn().Err(err).Msg("Failed to reupload footer icon in embed") @@ -550,10 +550,10 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b return compiledHTML } -func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { - reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ - DownloadingURL: url, - }) +func (mc *MessageConverter) renderDiscordLinkEmbedImage( + ctx context.Context, url string, width, height int, preview *event.BeeperLinkPreview, +) { + reupload, err := mc.ReuploadUnknownMedia(ctx, url, true) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring") return @@ -563,39 +563,42 @@ func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, int preview.ImageWidth = event.IntOrString(width) preview.ImageHeight = event.IntOrString(height) } - preview.ImageSize = event.IntOrString(reupload.DownloadedSize) + preview.ImageSize = event.IntOrString(reupload.Size) preview.ImageType = reupload.MimeType - if reupload.EncryptedFile != nil { - preview.ImageEncryption = &event.EncryptedFileInfo{ - EncryptedFile: reupload.EncryptedFile.EncryptedFile, - URL: reupload.MXC, - } - } + preview.ImageURL, preview.ImageEncryption = reupload.MXC, reupload.File } -func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { +func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { var preview event.BeeperLinkPreview preview.MatchedURL = embed.URL preview.Title = embed.Title preview.Description = embed.Description if embed.Image != nil { - mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) + mc.renderDiscordLinkEmbedImage(ctx, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) } else if embed.Thumbnail != nil { - mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) + mc.renderDiscordLinkEmbedImage(ctx, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) } return &preview } -func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { +func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { + // TODO(skip): Support direct media. + reupload, err := mc.ReuploadMedia(ctx, att.URL, att.ContentType, att.Filename, att.Size, true) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: mediaFailedMessage(err), + } + } + content := &event.MessageEventContent{ - Body: att.Filename, + Body: reupload.FileName, Info: &event.FileInfo{ Width: att.Width, Height: att.Height, - MimeType: att.ContentType, - - // This gets overwritten later after the file is uploaded to the homeserver - Size: att.Size, + MimeType: reupload.MimeType, + Size: reupload.Size, }, } @@ -607,10 +610,10 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent if att.Description != "" { content.Body = att.Description - content.FileName = att.Filename + content.FileName = reupload.FileName } - switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { + switch strings.ToLower(strings.Split(content.Info.MimeType, "/")[0]) { case "audio": content.MsgType = event.MsgAudio if att.Waveform != nil { @@ -630,28 +633,12 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent content.MsgType = event.MsgFile } - // TODO(skip): Support direct media. - reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ - DownloadingURL: att.URL, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") - return &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - Content: mediaFailedMessage(err), - } - } - - content.Info.Size = reupload.DownloadedSize + content.URL, content.File = reupload.MXC, reupload.File + content.Info.Size = reupload.Size if content.Info.Width == 0 && content.Info.Height == 0 { content.Info.Width = att.Width content.Info.Height = att.Height } - if reupload.EncryptedFile != nil { - content.File = reupload.EncryptedFile - } else { - content.URL = reupload.MXC - } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 317a48b..e8cd391 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -99,6 +99,8 @@ func (mc *MessageConverter) ToDiscord( session *discordgo.Session, msg *bridgev2.MatrixMessage, ) (*discordgo.MessageSend, error) { + ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal) + ctx = context.WithValue(ctx, contextKeyDiscordClient, session) var req discordgo.MessageSend req.Nonce = generateMessageNonce() log := zerolog.Ctx(ctx) diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 7f40710..9636ac7 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -17,35 +17,25 @@ package msgconv import ( - "context" "math/rand" "strconv" "sync/atomic" "maunium.net/go/mautrix/bridgev2" - - "go.mau.fi/mautrix-discord/pkg/attachment" ) -type MediaReuploader func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) - type MessageConverter struct { Bridge *bridgev2.Bridge nextDiscordUploadID atomic.Int32 - // ReuploadMedia is called when the message converter wants to upload some - // media it is attempting to bridge. - // - // This can be directly forwarded to the ReuploadMedia method on DiscordConnector. - // The indirection is only necessary to prevent an import cycle. - ReuploadMedia MediaReuploader + MaxFileSize int64 } -func NewMessageConverter(bridge *bridgev2.Bridge, reuploader MediaReuploader) *MessageConverter { +func NewMessageConverter(bridge *bridgev2.Bridge) *MessageConverter { mc := &MessageConverter{ - Bridge: bridge, - ReuploadMedia: reuploader, + Bridge: bridge, + MaxFileSize: 50 * 1024 * 1024, } mc.nextDiscordUploadID.Store(rand.Int31n(100)) From f3a797d5e57199475c7e72fca105c1cef185d142 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 17:06:17 +0200 Subject: [PATCH 84/99] main: update version number --- cmd/mautrix-discord/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/mautrix-discord/main.go b/cmd/mautrix-discord/main.go index e119827..be9aae6 100644 --- a/cmd/mautrix-discord/main.go +++ b/cmd/mautrix-discord/main.go @@ -33,7 +33,8 @@ var m = mxmain.BridgeMain{ Name: "mautrix-discord", Description: "A Matrix-Discord puppeting bridge", URL: "https://github.com/mautrix/discord", - Version: "0.8.0", + Version: "26.03", + SemCalVer: true, Connector: c, } From 7d26eae8e5fe3a5546aa78908e7d99f77c94fe32 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 17:16:44 +0200 Subject: [PATCH 85/99] login: fix flow IDs --- pkg/connector/login_browser.go | 2 +- pkg/connector/login_remoteauth.go | 2 +- pkg/connector/login_token.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go index d484aab..43f2a9e 100644 --- a/pkg/connector/login_browser.go +++ b/pkg/connector/login_browser.go @@ -24,7 +24,7 @@ import ( "maunium.net/go/mautrix/bridgev2" ) -const LoginFlowIDBrowser = "fi.mau.discord.login.browser" +const LoginFlowIDBrowser = "token" type DiscordBrowserLogin struct { *DiscordGenericLogin diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go index b9f4a03..e116090 100644 --- a/pkg/connector/login_remoteauth.go +++ b/pkg/connector/login_remoteauth.go @@ -26,7 +26,7 @@ import ( "go.mau.fi/mautrix-discord/pkg/remoteauth" ) -const LoginFlowIDRemoteAuth = "fi.mau.discord.login.remote_auth" +const LoginFlowIDRemoteAuth = "qr" type DiscordRemoteAuthLogin struct { *DiscordGenericLogin diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go index 428eb32..bee7b98 100644 --- a/pkg/connector/login_token.go +++ b/pkg/connector/login_token.go @@ -23,7 +23,7 @@ import ( "maunium.net/go/mautrix/bridgev2" ) -const LoginFlowIDToken = "fi.mau.discord.login.token" +const LoginFlowIDToken = "DEBUG_USERINPUT_token" type DiscordTokenLogin struct { *DiscordGenericLogin From e7554b212fb9006fbacd0d49838a241fad44a1c5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jan 2026 17:37:34 +0200 Subject: [PATCH 86/99] msgconv/attachments: don't fail if mimeless file has less than 512 bytes --- pkg/msgconv/attachments.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/msgconv/attachments.go b/pkg/msgconv/attachments.go index 72a7ae9..035b5f1 100644 --- a/pkg/msgconv/attachments.go +++ b/pkg/msgconv/attachments.go @@ -123,7 +123,7 @@ func (d *MessageConverter) ReuploadMedia( if mimeType == "" { mimeBuf := make([]byte, 512) n, err := file.(*os.File).ReadAt(mimeBuf, 0) - if err != nil { + if err != nil && !errors.Is(err, io.EOF) { return nil, fmt.Errorf("couldn't read file for mime detection: %w", err) } mimeType = http.DetectContentType(mimeBuf[:n]) From 92352ce603b69e919e977e34700761702214c609 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 1 Feb 2026 21:03:17 -0800 Subject: [PATCH 87/99] discordid: remove all ID-related casts In the same vein as mautrix-whatsapp, -slack and others, do not make assumptions about how the ID is represented in the connector code. Let the discordid package be entirely responsible. --- pkg/connector/backfill.go | 9 ++-- pkg/connector/client.go | 31 ++----------- pkg/connector/events.go | 4 +- pkg/connector/handlediscord.go | 14 +++--- pkg/connector/handlematrix.go | 33 +++++++------- pkg/connector/login_generic.go | 3 +- pkg/connector/provisioning.go | 8 ++-- pkg/connector/userinfo.go | 10 +++-- pkg/discordid/id.go | 81 +++++++++++++++++++++++++++++++++- pkg/msgconv/formatter_tag.go | 3 +- pkg/msgconv/from-discord.go | 8 ++-- pkg/msgconv/from-matrix.go | 6 +-- 12 files changed, 134 insertions(+), 76 deletions(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index b49354d..b23de03 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -24,7 +24,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) var ( @@ -36,7 +37,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 return nil, bridgev2.ErrNotLoggedIn } - channelID := string(fetchParams.Portal.ID) + channelID := discordid.ParsePortalID(fetchParams.Portal.ID) log := zerolog.Ctx(ctx).With(). Str("channel_id", channelID). Int("desired_count", fetchParams.Count). @@ -46,7 +47,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 var afterID string if fetchParams.AnchorMessage != nil { - anchorID := string(fetchParams.AnchorMessage.ID) + anchorID := discordid.ParseMessageID(fetchParams.AnchorMessage.ID) if fetchParams.Forward { afterID = anchorID @@ -99,7 +100,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 } converted = append(converted, &bridgev2.BackfillMessage{ - ID: networkid.MessageID(msg.ID), + ID: discordid.MakeMessageID(msg.ID), ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, dc.Session, msg), Sender: sender, Timestamp: ts, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 2c38584..a6d2cae 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -275,40 +275,17 @@ func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Ch return canView } -// The string prepended to [networkid.PortalKey]s identifying spaces that -// bridge Discord guilds. -// -// Every Discord guild created before August 2017 contained an channel -// having _the same ID as the guild itself_. This channel also functioned as -// the "default channel" in that incoming members would view this channel by -// default. It was also impossible to delete. -// -// After this date, these "default channels" became deletable, and fresh guilds -// were no longer created with a channel that exactly corresponded to the guild -// ID. -// -// To accommodate Discord guilds created before this API change that have also -// never deleted the default channel, we need a way to distinguish between the -// guild and the default channel, as we wouldn't be able to bridge the guild -// as a space otherwise. -// -// "*" was chosen as the asterisk character is used to filter by guilds in -// the quick switcher (in Discord's first-party clients). -// -// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer. -const guildPortalKeySigil = "*" - func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey { // TODO: Support configuring `split_portals`. return networkid.PortalKey{ - ID: networkid.PortalID(guildPortalKeySigil + guildID), + ID: discordid.MakeGuildPortalID(guildID), Receiver: d.UserLogin.ID, } } func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar { return &bridgev2.Avatar{ - ID: networkid.AvatarID(guild.Icon), + ID: discordid.MakeAvatarID(guild.Icon), Get: func(ctx context.Context) ([]byte, error) { url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) return simpleDownload(ctx, url, "guild icon") @@ -440,8 +417,8 @@ func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: userID == d.Session.State.User.ID, - SenderLogin: networkid.UserLoginID(userID), - Sender: networkid.UserID(userID), + SenderLogin: discordid.MakeUserLoginID(userID), + Sender: discordid.MakeUserID(userID), } } diff --git a/pkg/connector/events.go b/pkg/connector/events.go index aa263db..86a5c52 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -74,7 +74,7 @@ func (d *DiscordChatResync) avatar(ctx context.Context) *bridgev2.Avatar { } return &bridgev2.Avatar{ - ID: networkid.AvatarID(ch.Icon), + ID: discordid.MakeAvatarID(ch.Icon), Get: func(ctx context.Context) ([]byte, error) { url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) return simpleDownload(ctx, url, "group dm icon") @@ -175,5 +175,5 @@ func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridge zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling") return false, nil } - return latestBridged.ID < networkid.MessageID(d.channel.LastMessageID), nil + return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil } diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index eb328dd..7525579 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -26,6 +26,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) type DiscordEventMeta struct { @@ -67,7 +69,7 @@ func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Po } func (m *DiscordMessage) GetID() networkid.MessageID { - return networkid.MessageID(m.Data.ID) + return discordid.MakeMessageID(m.Data.ID) } func (m *DiscordMessage) GetSender() bridgev2.EventSender { @@ -79,7 +81,7 @@ func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) Discord DiscordEventMeta: &DiscordEventMeta{ Type: bridgev2.RemoteEventMessage, PortalKey: networkid.PortalKey{ - ID: networkid.PortalID(evt.ChannelID), + ID: discordid.MakePortalID(evt.ChannelID), Receiver: d.UserLogin.ID, }, }, @@ -99,11 +101,11 @@ func (r *DiscordReaction) GetSender() bridgev2.EventSender { } func (r *DiscordReaction) GetTargetMessage() networkid.MessageID { - return networkid.MessageID(r.Reaction.MessageID) + return discordid.MakeMessageID(r.Reaction.MessageID) } func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID { - return networkid.EmojiID(r.Reaction.Emoji.Name) + return discordid.MakeEmojiID(r.Reaction.Emoji.Name) } var ( @@ -116,7 +118,7 @@ func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) { // name is either a grapheme cluster consisting of a Unicode emoji, or the // name of a custom emoji. name := r.Reaction.Emoji.Name - return name, networkid.EmojiID(name) + return name, discordid.MakeEmojiID(name) } func (r *DiscordReaction) GetReactionExtraContent() map[string]any { @@ -152,7 +154,7 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction, DiscordEventMeta: &DiscordEventMeta{ Type: evtType, PortalKey: networkid.PortalKey{ - ID: networkid.PortalID(reaction.ChannelID), + ID: discordid.MakePortalID(reaction.ChannelID), Receiver: d.UserLogin.ID, }, }, diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 1c74002..bc2d50f 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -24,7 +24,6 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-discord/pkg/discordid" ) @@ -44,7 +43,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M portal := msg.Portal guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID - channelID := string(portal.ID) + channelID := discordid.ParsePortalID(portal.ID) sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg) if err != nil { @@ -55,7 +54,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M // TODO: When supporting threads (and not a bot user), send a thread referer. options = append(options, discordgo.WithChannelReferer(guildID, channelID)) - sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), sendReq, options...) + sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParsePortalID(msg.Portal.ID), sendReq, options...) if err != nil { return nil, err } @@ -63,8 +62,8 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M return &bridgev2.MatrixMessageResponse{ DB: &database.Message{ - ID: networkid.MessageID(sentMsg.ID), - SenderID: networkid.UserID(sentMsg.Author.ID), + ID: discordid.MakeMessageID(sentMsg.ID), + SenderID: discordid.MakeUserID(sentMsg.Author.ID), Timestamp: sentMsgTimestamp, }, }, nil @@ -80,8 +79,8 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b // TODO: Handle custom emoji. return bridgev2.MatrixReactionPreResponse{ - SenderID: networkid.UserID(d.UserLogin.ID), - EmojiID: networkid.EmojiID(key), + SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID), + EmojiID: discordid.MakeEmojiID(key), }, nil } @@ -90,23 +89,23 @@ func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *brid portal := reaction.Portal meta := portal.Metadata.(*discordid.PortalMetadata) - err := d.Session.MessageReactionAddUser(meta.GuildID, string(portal.ID), string(reaction.TargetMessage.ID), relatesToKey) + err := d.Session.MessageReactionAddUser(meta.GuildID, discordid.ParsePortalID(portal.ID), discordid.ParseMessageID(reaction.TargetMessage.ID), relatesToKey) return nil, err } func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error { removing := removal.TargetReaction emojiID := removing.EmojiID - channelID := string(removing.Room.ID) + channelID := discordid.ParsePortalID(removing.Room.ID) guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID - err := d.Session.MessageReactionRemoveUser(guildID, channelID, string(removing.MessageID), string(emojiID), string(d.UserLogin.ID)) + err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID)) return err } func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error { - channelID := string(removal.Portal.ID) - messageID := string(removal.TargetMessage.ID) + channelID := discordid.ParsePortalID(removal.Portal.ID) + messageID := discordid.ParseMessageID(removal.TargetMessage.ID) return d.Session.ChannelMessageDelete(channelID, messageID) } @@ -122,7 +121,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge // receipt didn't exactly correspond with a message, try finding one close // by to use as the target. if msg.ExactMessage != nil { - targetMessageID = string(msg.ExactMessage.ID) + targetMessageID = discordid.ParseMessageID(msg.ExactMessage.ID) log = log.With(). Str("message_id", targetMessageID). Logger() @@ -136,7 +135,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge // The read receipt didn't specify an exact message but we were able to // find one close by. - targetMessageID = string(closestMessage.ID) + targetMessageID = discordid.ParseMessageID(closestMessage.ID) log = log.With(). Str("closest_message_id", targetMessageID). Str("closest_event_id", closestMessage.MXID.String()). @@ -151,7 +150,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge // TODO: Support threads. guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID - channelID := string(msg.Portal.ID) + channelID := discordid.ParsePortalID(msg.Portal.ID) resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Err(err).Msg("Failed to send read receipt to Discord") @@ -176,7 +175,7 @@ func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Por d.markedOpenedLock.Lock() defer d.markedOpenedLock.Unlock() - channelID := string(portal.ID) + channelID := discordid.ParsePortalID(portal.ID) log := zerolog.Ctx(ctx).With(). Str("channel_id", channelID).Logger() @@ -207,7 +206,7 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma _ = d.viewingChannel(ctx, msg.Portal) guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID - channelID := string(msg.Portal.ID) + channelID := discordid.ParsePortalID(msg.Portal.ID) // TODO: Support threads properly when sending the referer. err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID)) diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go index ba206ee..95a82ef 100644 --- a/pkg/connector/login_generic.go +++ b/pkg/connector/login_generic.go @@ -24,7 +24,6 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-discord/pkg/discordid" ) @@ -71,7 +70,7 @@ func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token dl.Session = session ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ - ID: networkid.UserLoginID(user.ID), + ID: discordid.MakeUserLoginID(user.ID), Metadata: &discordid.UserLoginMetadata{ Token: token, HeartbeatSession: session.HeartbeatSession, diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go index 1860171..0b2d56e 100644 --- a/pkg/connector/provisioning.go +++ b/pkg/connector/provisioning.go @@ -26,6 +26,8 @@ import ( "go.mau.fi/util/exhttp" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) const ( @@ -115,7 +117,7 @@ func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Re } func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { - p.log.Info().Str("login_id", string(login.ID)).Msg("guilds list requested via provisioning api") + p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api") var resp respGuildsList resp.Guilds = []guildEntry{} @@ -142,7 +144,7 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo } p.log.Info(). - Str("login_id", string(login.ID)). + Str("login_id", discordid.ParseUserLoginID(login.ID)). Str("guild_id", guildID). Msg("requested to bridge guild via provisioning api") @@ -160,7 +162,7 @@ func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, } p.log.Info(). - Str("login_id", string(login.ID)). + Str("login_id", discordid.ParseUserLoginID(login.ID)). Str("guild_id", guildID). Msg("requested to unbridge guild via provisioning api") diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index aad230d..eb3e7b1 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -25,19 +25,21 @@ import ( "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { // We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map // directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison. - return userID == networkid.UserID(d.UserLogin.ID) + return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID) } func makeUserAvatar(u *discordgo.User) *bridgev2.Avatar { url := u.AvatarURL("256") return &bridgev2.Avatar{ - ID: networkid.AvatarID(url), + ID: discordid.MakeAvatarID(url), Get: func(ctx context.Context) ([]byte, error) { return simpleDownload(ctx, url, "user avatar") }, @@ -54,9 +56,9 @@ func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) // FIXME(skip): This won't work for users in guilds. - user, ok := d.usersFromReady[string(ghost.ID)] + user, ok := d.usersFromReady[discordid.ParseUserID(ghost.ID)] if !ok { - log.Error().Str("ghost_id", string(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost") + log.Error().Str("ghost_id", discordid.ParseUserID(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost") return nil, nil } diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go index 377bc23..42d4321 100644 --- a/pkg/discordid/id.go +++ b/pkg/discordid/id.go @@ -21,8 +21,85 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" ) +func MakeUserID(userID string) networkid.UserID { + return networkid.UserID(userID) +} + +func ParseUserID(userID networkid.UserID) string { + return string(userID) +} + +func MakeUserLoginID(userID string) networkid.UserLoginID { + return networkid.UserLoginID(userID) +} + +func ParseUserLoginID(id networkid.UserLoginID) string { + return string(id) +} + +// UserLoginIDToUserID converts a UserLoginID to a UserID. In Discord, both +// are the same underlying snowflake. +func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID { + return networkid.UserID(id) +} + +func MakePortalID(channelID string) networkid.PortalID { + return networkid.PortalID(channelID) +} + +func ParsePortalID(portalID networkid.PortalID) string { + return string(portalID) +} + +func MakeMessageID(messageID string) networkid.MessageID { + return networkid.MessageID(messageID) +} + +func ParseMessageID(messageID networkid.MessageID) string { + return string(messageID) +} + +func MakeEmojiID(emojiName string) networkid.EmojiID { + return networkid.EmojiID(emojiName) +} + +func ParseEmojiID(emojiID networkid.EmojiID) string { + return string(emojiID) +} + +func MakeAvatarID(avatar string) networkid.AvatarID { + return networkid.AvatarID(avatar) +} + +// The string prepended to [networkid.PortalKey]s identifying spaces that +// bridge Discord guilds. +// +// Every Discord guild created before August 2017 contained a channel +// having _the same ID as the guild itself_. This channel also functioned as +// the "default channel" in that incoming members would view this channel by +// default. It was also impossible to delete. +// +// After this date, these "default channels" became deletable, and fresh guilds +// were no longer created with a channel that exactly corresponded to the guild +// ID. +// +// To accommodate Discord guilds created before this API change that have also +// never deleted the default channel, we need a way to distinguish between the +// guild and the default channel, as we wouldn't be able to bridge the guild +// as a space otherwise. +// +// "*" was chosen as the asterisk character is used to filter by guilds in +// the quick switcher (in Discord's first-party clients). +// +// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer. +const GuildPortalKeySigil = "*" + +func MakeGuildPortalID(guildID string) networkid.PortalID { + return networkid.PortalID(GuildPortalKeySigil + guildID) +} + func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { - key.ID = networkid.PortalID(ch.ID) + key.ID = MakePortalID(ch.ID) if wantReceiver { key.Receiver = userLoginID } @@ -30,6 +107,6 @@ func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wan } func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) { - key.ID = networkid.PortalID(channelID) + key.ID = MakePortalID(channelID) return } diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go index 0a4975c..3d93039 100644 --- a/pkg/msgconv/formatter_tag.go +++ b/pkg/msgconv/formatter_tag.go @@ -32,7 +32,6 @@ import ( "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-discord/pkg/discordid" @@ -271,7 +270,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [ case *astDiscordUserMention: var mxid id.UserID var name string - if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, networkid.UserID(strconv.FormatInt(node.id, 10))); ghost != nil { + if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, discordid.MakeUserID(strconv.FormatInt(node.id, 10))); ghost != nil { mxid = ghost.Intent.GetMXID() name = ghost.Name } diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 5dfa9dd..662dee7 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -179,7 +179,7 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage( // The portal containing the message that was replied to. targetPortal := portal - if ref.ChannelID != string(portal.ID) { + if ref.ChannelID != discordid.ParsePortalID(portal.ID) { var err error targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID)) if err != nil { @@ -192,7 +192,7 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage( } } - messageID := networkid.MessageID(ref.MessageID) + messageID := discordid.MakeMessageID(ref.MessageID) repliedToMatrixMsg, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, messageID) if err != nil { log.Err(err).Msg("Failed to query database for first message part; proceeding") @@ -229,7 +229,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent var htmlParts []string if msg.Interaction != nil { - ghost, err := mc.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) + ghost, err := mc.Bridge.GetGhostByID(ctx, discordid.MakeUserID(msg.Interaction.User.ID)) // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. if err == nil { htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) @@ -302,7 +302,7 @@ func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") origLink := fmt.Sprintf("unknown channel • %s", msgTSText) if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { - if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { // We've bridged the message that was forwarded, so we can link to it directly. origLink = fmt.Sprintf( `#%s • %s`, diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index e8cd391..8e328f8 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -107,14 +107,14 @@ func (mc *MessageConverter) ToDiscord( if msg.ReplyTo != nil { req.Reference = &discordgo.MessageReference{ - ChannelID: string(msg.ReplyTo.Room.ID), - MessageID: string(msg.ReplyTo.ID), + ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID), + MessageID: discordid.ParseMessageID(msg.ReplyTo.ID), } } portal := msg.Portal guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID - channelID := string(portal.ID) + channelID := discordid.ParsePortalID(portal.ID) content := msg.Content convertMatrix := func() { From 8c02a80f858e76f9de11547d50dc71483dd48402 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 1 Feb 2026 21:03:34 -0800 Subject: [PATCH 88/99] connector/login: return browser login method as the first one clients will prefer it --- pkg/connector/login.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index ef0ec62..40a92d7 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -27,16 +27,16 @@ const LoginStepIDComplete = "fi.mau.discord.login.complete" func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ - { - ID: LoginFlowIDRemoteAuth, - Name: "QR Code", - Description: "Scan a QR code with the Discord mobile app to log in.", - }, { ID: LoginFlowIDBrowser, Name: "Browser", Description: "Log in to your Discord account in a web browser.", }, + { + ID: LoginFlowIDRemoteAuth, + Name: "QR Code", + Description: "Scan a QR code with the Discord mobile app to log in.", + }, { ID: LoginFlowIDToken, Name: "Token", From 30752fa48b418f8c194408c1f75b6f90b67845fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 2 Feb 2026 19:34:57 +0100 Subject: [PATCH 89/99] Improve Discord bridge state handling --- pkg/connector/bridge_state.go | 127 +++++++++++++++++++++++++++++++++ pkg/connector/client.go | 13 +++- pkg/connector/handlediscord.go | 18 +++-- pkg/connector/login_generic.go | 5 ++ 4 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 pkg/connector/bridge_state.go diff --git a/pkg/connector/bridge_state.go b/pkg/connector/bridge_state.go new file mode 100644 index 0000000..d1ccad1 --- /dev/null +++ b/pkg/connector/bridge_state.go @@ -0,0 +1,127 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "time" + + "maunium.net/go/mautrix/bridgev2/status" +) + +const ( + DiscordNotLoggedIn status.BridgeStateErrorCode = "discord-not-logged-in" + DiscordInvalidAuth status.BridgeStateErrorCode = "discord-invalid-auth" + DiscordDisconnected status.BridgeStateErrorCode = "discord-disconnected" + DiscordConnectFailed status.BridgeStateErrorCode = "discord-connect-failed" +) + +const discordDisconnectDebounce = 7 * time.Second + +func init() { + status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ + DiscordNotLoggedIn: "You're not logged into Discord. Relogin to continue using the bridge.", + DiscordInvalidAuth: "You were logged out of Discord. Relogin to continue using the bridge.", + DiscordDisconnected: "Disconnected from Discord. Trying to reconnect.", + DiscordConnectFailed: "Connecting to Discord failed.", + }) +} + +func (d *DiscordClient) resetBridgeStateTracking() { + d.bridgeStateLock.Lock() + if d.disconnectTimer != nil { + d.disconnectTimer.Stop() + d.disconnectTimer = nil + } + d.invalidAuthDetected = false + d.bridgeStateLock.Unlock() +} + +func (d *DiscordClient) markConnected() { + if d.UserLogin == nil { + return + } + d.bridgeStateLock.Lock() + if d.disconnectTimer != nil { + d.disconnectTimer.Stop() + d.disconnectTimer = nil + } + d.invalidAuthDetected = false + d.bridgeStateLock.Unlock() + d.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) +} + +func (d *DiscordClient) markInvalidAuth(message string) { + if d.UserLogin == nil { + return + } + d.bridgeStateLock.Lock() + d.invalidAuthDetected = true + if d.disconnectTimer != nil { + d.disconnectTimer.Stop() + d.disconnectTimer = nil + } + d.bridgeStateLock.Unlock() + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: DiscordInvalidAuth, + Message: message, + }) +} + +func (d *DiscordClient) scheduleTransientDisconnect(message string) { + if d.UserLogin == nil { + return + } + d.bridgeStateLock.Lock() + if d.invalidAuthDetected { + d.bridgeStateLock.Unlock() + return + } + if d.disconnectTimer != nil { + d.disconnectTimer.Stop() + } + login := d.UserLogin + d.disconnectTimer = time.AfterFunc(discordDisconnectDebounce, func() { + d.bridgeStateLock.Lock() + d.disconnectTimer = nil + invalidAuth := d.invalidAuthDetected + d.bridgeStateLock.Unlock() + if invalidAuth { + return + } + login.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateTransientDisconnect, + Error: DiscordDisconnected, + Message: message, + }) + }) + d.bridgeStateLock.Unlock() +} + +func (d *DiscordClient) sendConnectFailure(err error) { + if d.UserLogin == nil || err == nil { + return + } + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: DiscordConnectFailed, + Message: err.Error(), + Info: map[string]any{ + "go_error": err.Error(), + }, + }) +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index a6d2cae..2dd4b8d 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -48,6 +48,10 @@ type DiscordClient struct { markedOpened map[string]time.Time markedOpenedLock sync.Mutex + + bridgeStateLock sync.Mutex + disconnectTimer *time.Timer + invalidAuthDetected bool } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -95,6 +99,7 @@ func (d *DiscordClient) SetUp(ctx context.Context, meta *discordid.UserLoginMeta } d.markedOpened = make(map[string]time.Time) + d.resetBridgeStateTracking() } func (d *DiscordClient) Connect(ctx context.Context) { @@ -104,7 +109,7 @@ func (d *DiscordClient) Connect(ctx context.Context) { log.Error().Msg("No session present") d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, - Error: "discord-not-logged-in", + Error: DiscordNotLoggedIn, }) return } @@ -135,12 +140,16 @@ func (cl *DiscordClient) connect(ctx context.Context) error { } if err != nil { log.Err(err).Msg("Failed to connect to Discord") + cl.sendConnectFailure(err) return err } // Ensure that we actually have a user. if !cl.IsLoggedIn() { - return fmt.Errorf("unknown identity even after connecting to Discord") + err := fmt.Errorf("unknown identity even after connecting to Discord") + log.Err(err).Msg("No Discord user available after connecting") + cl.sendConnectFailure(err) + return err } user := cl.Session.State.User log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord") diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 7525579..b9d6136 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -25,7 +25,6 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/status" "go.mau.fi/mautrix-discord/pkg/discordid" ) @@ -196,16 +195,21 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { Logger() switch evt := rawEvt.(type) { + case *discordgo.Connect: + log.Info().Msg("Discord gateway connected") + d.markConnected() + case *discordgo.Disconnect: + log.Info().Msg("Discord gateway disconnected") + d.scheduleTransientDisconnect("") + case *discordgo.InvalidAuth: + log.Warn().Msg("Discord gateway reported invalid auth") + d.markInvalidAuth("You have been logged out of Discord, please reconnect") case *discordgo.Ready: log.Info().Msg("Received READY dispatch from discordgo") - d.UserLogin.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateConnected, - }) + d.markConnected() case *discordgo.Resumed: log.Info().Msg("Received RESUMED dispatch from discordgo") - d.UserLogin.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateConnected, - }) + d.markConnected() case *discordgo.MessageCreate: if evt.Author == nil { log.Trace().Int("message_type", int(evt.Message.Type)). diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go index 95a82ef..a04db97 100644 --- a/pkg/connector/login_generic.go +++ b/pkg/connector/login_generic.go @@ -97,6 +97,11 @@ func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token Str("user_username", user.Username). Msg("Logged in to Discord") + // We already opened the gateway session before creating the UserLogin, + // which means the initial READY/CONNECT event was dropped. Send Connected + // here so infra gets login status for new logins. + client.markConnected() + return ul, nil } From 89ac3632ecbe5ff48ddf7668fb19ca17fafdfbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 2 Feb 2026 19:44:21 +0100 Subject: [PATCH 90/99] Preserve Discord bridge state codes and retry logic --- pkg/connector/backfill.go | 1 + pkg/connector/bridge_state.go | 50 ++++++++++++++++++++++++++--------- pkg/connector/client.go | 29 ++++++++++++++++---- pkg/connector/handlematrix.go | 16 ++++++++++- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index b23de03..b589098 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -61,6 +61,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 log.Debug().Msg("Fetching channel history for backfill") msgs, err := dc.Session.ChannelMessages(channelID, count, beforeID, afterID, "") if err != nil { + dc.handlePossible40002(err) return nil, err } diff --git a/pkg/connector/bridge_state.go b/pkg/connector/bridge_state.go index d1ccad1..bd21f86 100644 --- a/pkg/connector/bridge_state.go +++ b/pkg/connector/bridge_state.go @@ -17,26 +17,30 @@ package connector import ( + "errors" "time" + "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2/status" ) const ( - DiscordNotLoggedIn status.BridgeStateErrorCode = "discord-not-logged-in" - DiscordInvalidAuth status.BridgeStateErrorCode = "discord-invalid-auth" - DiscordDisconnected status.BridgeStateErrorCode = "discord-disconnected" - DiscordConnectFailed status.BridgeStateErrorCode = "discord-connect-failed" + DiscordNotLoggedIn status.BridgeStateErrorCode = "dc-not-logged-in" + DiscordTransientDisconnect status.BridgeStateErrorCode = "dc-transient-disconnect" + DiscordInvalidAuth status.BridgeStateErrorCode = "dc-websocket-disconnect-4004" + DiscordHTTP40002 status.BridgeStateErrorCode = "dc-http-40002" + DiscordUnknownWebsocketErr status.BridgeStateErrorCode = "dc-unknown-websocket-error" ) const discordDisconnectDebounce = 7 * time.Second func init() { status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ - DiscordNotLoggedIn: "You're not logged into Discord. Relogin to continue using the bridge.", - DiscordInvalidAuth: "You were logged out of Discord. Relogin to continue using the bridge.", - DiscordDisconnected: "Disconnected from Discord. Trying to reconnect.", - DiscordConnectFailed: "Connecting to Discord failed.", + DiscordNotLoggedIn: "You're not logged into Discord. Relogin to continue using the bridge.", + DiscordTransientDisconnect: "Temporarily disconnected from Discord, trying to reconnect.", + DiscordInvalidAuth: "Discord access token is no longer valid, please log in again.", + DiscordHTTP40002: "Discord requires a verified account, please verify and log in again.", + DiscordUnknownWebsocketErr: "Unknown Discord websocket error.", }) } @@ -79,6 +83,7 @@ func (d *DiscordClient) markInvalidAuth(message string) { StateEvent: status.StateBadCredentials, Error: DiscordInvalidAuth, Message: message, + UserAction: status.UserActionRelogin, }) } @@ -105,23 +110,44 @@ func (d *DiscordClient) scheduleTransientDisconnect(message string) { } login.BridgeState.Send(status.BridgeState{ StateEvent: status.StateTransientDisconnect, - Error: DiscordDisconnected, + Error: DiscordTransientDisconnect, Message: message, }) }) d.bridgeStateLock.Unlock() } -func (d *DiscordClient) sendConnectFailure(err error) { +func (d *DiscordClient) sendConnectFailure(err error, final bool) { if d.UserLogin == nil || err == nil { return } + stateEvent := status.StateTransientDisconnect + if final { + stateEvent = status.StateUnknownError + } d.UserLogin.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: DiscordConnectFailed, + StateEvent: stateEvent, + Error: DiscordUnknownWebsocketErr, Message: err.Error(), Info: map[string]any{ "go_error": err.Error(), }, }) } + +func (d *DiscordClient) handlePossible40002(err error) bool { + var restErr *discordgo.RESTError + if !errors.As(err, &restErr) || restErr.Message == nil || restErr.Message.Code != discordgo.ErrCodeActionRequiredVerifiedAccount { + return false + } + if d.UserLogin == nil { + return true + } + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: DiscordHTTP40002, + Message: restErr.Message.Message, + UserAction: status.UserActionRelogin, + }) + return true +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 2dd4b8d..0c81dd4 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -110,6 +110,7 @@ func (d *DiscordClient) Connect(ctx context.Context) { d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: DiscordNotLoggedIn, + UserAction: status.UserActionRelogin, }) return } @@ -117,9 +118,7 @@ func (d *DiscordClient) Connect(ctx context.Context) { d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateConnecting, }) - if err := d.connect(ctx); err != nil { - log.Err(err).Msg("Couldn't connect to Discord") - } + d.connectWithRetry(ctx, 0) } func (cl *DiscordClient) handleDiscordEventSync(event any) { @@ -140,7 +139,6 @@ func (cl *DiscordClient) connect(ctx context.Context) error { } if err != nil { log.Err(err).Msg("Failed to connect to Discord") - cl.sendConnectFailure(err) return err } @@ -148,7 +146,6 @@ func (cl *DiscordClient) connect(ctx context.Context) error { if !cl.IsLoggedIn() { err := fmt.Errorf("unknown identity even after connecting to Discord") log.Err(err).Msg("No Discord user available after connecting") - cl.sendConnectFailure(err) return err } user := cl.Session.State.User @@ -170,6 +167,27 @@ func (cl *DiscordClient) connect(ctx context.Context) error { return nil } +func (d *DiscordClient) connectWithRetry(ctx context.Context, retryCount int) { + err := d.connect(ctx) + if err == nil || ctx.Err() != nil { + return + } + if retryCount < 6 { + d.sendConnectFailure(err, false) + retryInSeconds := 2 << retryCount + zerolog.Ctx(ctx).Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection") + select { + case <-time.After(time.Duration(retryInSeconds) * time.Second): + case <-ctx.Done(): + zerolog.Ctx(ctx).Info().Msg("Context canceled, exiting connect retry loop") + return + } + d.connectWithRetry(ctx, retryCount+1) + } else { + d.sendConnectFailure(err, true) + } +} + func (d *DiscordClient) Disconnect() { d.UserLogin.Log.Info().Msg("Disconnecting session") d.Session.Close() @@ -398,6 +416,7 @@ func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error { Threads: true, }) if err != nil { + d.handlePossible40002(err) log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding") } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index bc2d50f..2449fa9 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -56,6 +56,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParsePortalID(msg.Portal.ID), sendReq, options...) if err != nil { + d.handlePossible40002(err) return nil, err } sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID) @@ -90,6 +91,9 @@ func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *brid meta := portal.Metadata.(*discordid.PortalMetadata) err := d.Session.MessageReactionAddUser(meta.GuildID, discordid.ParsePortalID(portal.ID), discordid.ParseMessageID(reaction.TargetMessage.ID), relatesToKey) + if err != nil { + d.handlePossible40002(err) + } return nil, err } @@ -100,13 +104,20 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID)) + if err != nil { + d.handlePossible40002(err) + } return err } func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error { channelID := discordid.ParsePortalID(removal.Portal.ID) messageID := discordid.ParseMessageID(removal.TargetMessage.ID) - return d.Session.ChannelMessageDelete(channelID, messageID) + err := d.Session.ChannelMessageDelete(channelID, messageID) + if err != nil { + d.handlePossible40002(err) + } + return err } func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { @@ -153,6 +164,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge channelID := discordid.ParsePortalID(msg.Portal.ID) resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { + d.handlePossible40002(err) log.Err(err).Msg("Failed to send read receipt to Discord") return err } else if resp.Token != nil { @@ -186,6 +198,7 @@ func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Por err := d.Session.MarkViewing(channelID) if err != nil { + d.handlePossible40002(err) log.Error().Err(err).Msg("Failed to mark user as viewing channel") return err } @@ -211,6 +224,7 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { + d.handlePossible40002(err) log.Warn().Err(err).Msg("Failed to mark user as typing") return err } From ca9f0322340f9857f43e6a0511061bead066e186 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 12 Feb 2026 16:29:35 +0200 Subject: [PATCH 91/99] docker: fix working directory and update to Alpine 3.23 --- Dockerfile | 5 +++-- Dockerfile.ci | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4664399..58b1f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1-alpine3.22 AS builder +FROM golang:1-alpine3.23 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev @@ -6,7 +6,7 @@ COPY . /build WORKDIR /build RUN go build -o /usr/bin/mautrix-discord -FROM alpine:3.22 +FROM alpine:3.23 ENV UID=1337 \ GID=1337 @@ -17,5 +17,6 @@ COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/Dockerfile.ci b/Dockerfile.ci index 32b9c35..abb50bd 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,4 +1,4 @@ -FROM alpine:3.22 +FROM alpine:3.23 ENV UID=1337 \ GID=1337 @@ -10,5 +10,6 @@ COPY $EXECUTABLE /usr/bin/mautrix-discord COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] From 17c1938b4caf9d67687e16e12783f6c3c8793db2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 14:46:35 +0200 Subject: [PATCH 92/99] Bump minimum Go version to 1.25 --- .github/workflows/go.yml | 4 ++-- go.mod | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 173e36a..b238c9c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,8 +8,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24", "1.25"] - name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }} + go-version: ["1.25", "1.26"] + name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} steps: - uses: actions/checkout@v4 diff --git a/go.mod b/go.mod index 5dd9c96..69e6091 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module go.mau.fi/mautrix-discord -go 1.24.0 +go 1.25.0 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/bwmarrin/discordgo v0.27.0 From fab784bfd817df7526594259e3201627ffc9869c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 14:48:09 +0200 Subject: [PATCH 93/99] Add new fields to uploads --- go.mod | 2 +- go.sum | 4 ++-- portal.go | 11 ++++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 69e6091..59f16f0 100644 --- a/go.mod +++ b/go.mod @@ -43,4 +43,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f diff --git a/go.sum b/go.sum index 8bd33f8..dc97b3a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99 h1:uLpNLE/Avs+XMOzbjh49MfWuqm2lo+Z8Kv07CjOdRWQ= -github.com/beeper/discordgo v0.0.0-20251125191000-08af68849c99/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= +github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f h1:A+SRmETpSnFixbP1x6u7sQdoi8cOuYfL5bkDJy9F/Pg= +github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/portal.go b/portal.go index 0d860d6..b8132ec 100644 --- a/portal.go +++ b/portal.go @@ -1653,16 +1653,21 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser { att := &discordgo.MessageAttachment{ - ID: "0", - Filename: filename, - Description: description, + ID: "0", + Filename: filename, + Description: description, + OriginalContentType: content.Info.MimeType, } sendReq.Attachments = []*discordgo.MessageAttachment{att} + isClip := false prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ Files: []*discordgo.FilePrepare{{ Size: len(data), Name: att.Filename, ID: sender.NextDiscordUploadID(), + + IsClip: &isClip, + OriginalContentType: att.OriginalContentType, }}, }, portal.RefererOpt(threadID)) if err != nil { From daf6b9420ca07a03b72c67a4fe47935155e16476 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 15 Feb 2026 21:48:10 +0200 Subject: [PATCH 94/99] Add support for federation thumbnail endpoint --- directmedia.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/directmedia.go b/directmedia.go index 4499c1a..6b683aa 100644 --- a/directmedia.go +++ b/directmedia.go @@ -154,6 +154,7 @@ func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI { addRoutes("r0") addRoutes("v1") federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) + federationRouter.HandleFunc("/v1/media/thumbnail/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet) mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) @@ -556,7 +557,7 @@ func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWri func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerolog.Ctx(ctx) - isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/") + isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/") vars := mux.Vars(r) if !isNewFederation && vars["serverName"] != dma.cfg.ServerName { jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ From 19e26674e6624a02bced982aafe845cb20e43827 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 16 Feb 2026 15:49:46 +0200 Subject: [PATCH 95/99] Bump version to v0.7.6 --- CHANGELOG.md | 12 ++++++++++++ main.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f53a95..c88e03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v0.7.6 (2026-02-16) + +* Bumped minimum Go version to 1.25. +* Updated Docker image to Alpine 3.23. +* Added support for following tombstones. +* Added support for disabling link previews in messages sent to Discord using + [MSC4095]. +* Added support for federation thumbnail endpoint when using direct media. +* Disabled using `restricted` join rules by default. + +[MSC4095]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + # v0.7.5 (2025-07-16) * Fixed federation key response when using direct media. diff --git a/main.go b/main.go index 5b6f635..a9beca7 100644 --- a/main.go +++ b/main.go @@ -187,7 +187,7 @@ func main() { Name: "mautrix-discord", URL: "https://github.com/mautrix/discord", Description: "A Matrix-Discord puppeting bridge.", - Version: "0.7.5", + Version: "0.7.6", ProtocolName: "Discord", BeeperServiceName: "discordgo", BeeperNetworkName: "discord", From b69576cdc45b0f19a19878de9b5eb44f675712c0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 11 Mar 2026 22:47:48 +0200 Subject: [PATCH 96/99] Don't panic if editing a message fails [skip cd] --- portal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal.go b/portal.go index b8132ec..6571c1f 100644 --- a/portal.go +++ b/portal.go @@ -1565,7 +1565,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { }) } go portal.sendMessageMetrics(evt, err, "Failed to edit") - if msg.EditedTimestamp != nil { + if msg != nil && msg.EditedTimestamp != nil { edits.UpdateEditTimestamp(*msg.EditedTimestamp) } } else { From ceadbf3093d8670ab9f4b58700bdd3c394a803bf Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Fri, 20 Mar 2026 19:37:50 -0700 Subject: [PATCH 97/99] Add option to block user DMs to strangers using a synchronized relationship cache (#221) Co-authored-by: Tulir Asokan --- config/bridge.go | 1 + config/upgrade.go | 1 + example-config.yaml | 4 ++++ portal.go | 24 ++++++++++++++++++++++++ user.go | 41 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/config/bridge.go b/config/bridge.go index 2f78ed7..c546aa8 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -56,6 +56,7 @@ type BridgeConfig struct { PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"` EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"` UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"` + ForbidDMingStrangers bool `yaml:"forbid_dming_strangers"` Proxy string `yaml:"proxy"` diff --git a/config/upgrade.go b/config/upgrade.go index 1c9fe56..3d7a9fa 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -63,6 +63,7 @@ func DoUpgrade(helper *up.Helper) { 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", "forbid_dming_strangers") helper.Copy(up.Str|up.Null, "bridge", "proxy") helper.Copy(up.Str, "bridge", "cache_media") helper.Copy(up.Bool, "bridge", "direct_media", "enabled") diff --git a/example-config.yaml b/example-config.yaml index bfe30d4..0c1ab13 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -171,6 +171,10 @@ bridge: # 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 the bridge forbid direct messages from users to other users who they aren't friends with? Discord generally + # considers this to be a "risky" action. Note that the bridge will conservatively reject all outgoing DMs from users + # until it has synced that user's relationships from Discord. + forbid_dming_strangers: true # Proxy for Discord connections proxy: # Should mxc uris copied from Discord be cached? diff --git a/portal.go b/portal.go index 6571c1f..baaaa4d 100644 --- a/portal.go +++ b/portal.go @@ -1226,6 +1226,8 @@ var ( errUnknownRelationType = errors.New("unknown relation type") errTargetNotFound = errors.New("target event not found") errUnknownEmoji = errors.New("unknown emoji") + errRelationshipsNotReady = errors.New("can't direct message before receiving relationships") + errDMingStranger = errors.New("can't direct message a stranger") errCantStartThread = errors.New("can't create thread without being logged into Discord") ) @@ -1241,6 +1243,10 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev errors.Is(err, attachment.UnsupportedAlgorithm), errors.Is(err, errCantStartThread): return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "", nil + case errors.Is(err, errDMingStranger): + return event.MessageStatusGenericError, event.MessageStatusFail, true, true, "You can't message users who aren't on your friends list. Use the Discord app to chat or add them as a friend to continue.", nil + case errors.Is(err, errRelationshipsNotReady): + return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Still syncing your Discord friends list, please try again in a moment.", nil case errors.Is(err, attachment.HashMismatch), errors.Is(err, attachment.InvalidKey), errors.Is(err, attachment.InvalidInitVector): @@ -1531,6 +1537,22 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } + + if portal.bridge.Config.Bridge.ForbidDMingStrangers { + sender.relationshipLock.RLock() + if !sender.relationshipsReady { + go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "") + sender.relationshipLock.RUnlock() + return + } + relationship, hasRelationship := sender.relationships[portal.OtherUserID] + sender.relationshipLock.RUnlock() + + if !hasRelationship || relationship.Type != discordgo.RelationshipFriend { + go portal.sendMessageMetrics(evt, errDMingStranger, "") + return + } + } } content, ok := evt.Content.Parsed.(*event.MessageEventContent) @@ -2541,6 +2563,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord if portal.OtherUserID != "" { puppet := portal.bridge.GetPuppetByID(portal.OtherUserID) changed = portal.UpdateAvatarFromPuppet(puppet) || changed + source.relationshipLock.RLock() if rel, ok := source.relationships[portal.OtherUserID]; ok && rel.Nickname != "" { portal.FriendNick = true changed = portal.UpdateNameDirect(rel.Nickname, true) || changed @@ -2548,6 +2571,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord portal.FriendNick = false changed = portal.UpdateNameDirect(puppet.Name, false) || changed } + source.relationshipLock.RUnlock() } if portal.MXID != "" { portal.syncParticipants(source, meta.Recipients) diff --git a/user.go b/user.go index b5ff572..01a7203 100644 --- a/user.go +++ b/user.go @@ -68,6 +68,12 @@ type User struct { nextDiscordUploadID atomic.Int32 relationships map[string]*discordgo.Relationship + // relationshipsReady should be protected by relationshipLock and is merely + // used to cover the brief moment in time where the readyHandler goroutine + // is being scheduled; during that time, the relationships map is unlocked + // and "available" but not logically "ready" just yet. + relationshipsReady bool + relationshipLock sync.RWMutex } func (user *User) GetRemoteID() string { @@ -497,6 +503,7 @@ func (user *User) Logout(isOverwriting bool) { } user.Session = nil + user.reconstructRelationships(nil) user.DiscordToken = "" user.ReadStateVersion = 0 if !isOverwriting { @@ -511,6 +518,26 @@ func (user *User) Logout(isOverwriting bool) { user.log.Info().Msg("User logged out") } +func (user *User) reconstructRelationships(relationships []*discordgo.Relationship) { + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() + + clear(user.relationships) + + if relationships == nil { + // Relationships are just being cleared out; we don't actually have + // them yet. + user.relationshipsReady = false + } else { + // We've received the authoritative list of relationships from the + // gateway. + for _, relationship := range relationships { + user.relationships[relationship.ID] = relationship + } + user.relationshipsReady = true + } +} + func (user *User) Connected() bool { user.Lock() defer user.Unlock() @@ -538,6 +565,9 @@ const BotIntents = discordgo.IntentGuilds | func (user *User) Connect() error { user.Lock() + // Clear our in-memory relationship cache as it might've changed while + // offline; READY will repopulate it. + user.reconstructRelationships(nil) defer user.Unlock() if user.DiscordToken == "" { @@ -700,6 +730,7 @@ func (user *User) Disconnect() error { } user.log.Info().Msg("Disconnecting session manually") + user.reconstructRelationships(nil) if err := user.Session.Close(); err != nil { return err } @@ -758,9 +789,7 @@ func (user *User) readyHandler(r *discordgo.Ready) { user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling}) user.tryAutomaticDoublePuppeting() - for _, relationship := range r.Relationships { - user.relationships[relationship.ID] = relationship - } + user.reconstructRelationships(r.Relationships) updateTS := time.Now() portalsInSpace := make(map[string]bool) @@ -843,17 +872,23 @@ 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.relationshipLock.Lock() + defer user.relationshipLock.Unlock() user.relationships[r.ID] = r.Relationship user.handleRelationshipChange(r.ID, r.Nickname) } func (user *User) relationshipUpdateHandler(r *discordgo.RelationshipUpdate) { + user.relationshipLock.Lock() + defer user.relationshipLock.Unlock() 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.relationshipLock.Lock() + defer user.relationshipLock.Unlock() user.log.Debug().Str("other_user_id", r.ID).Msg("Relationship removed") delete(user.relationships, r.ID) user.handleRelationshipChange(r.ID, "") From bccdea7d24c9060d41c8b5c5f06c89747072ee45 Mon Sep 17 00:00:00 2001 From: "Skip R." Date: Mon, 23 Mar 2026 18:12:10 -0700 Subject: [PATCH 98/99] Permit user DMs to bots even under `ForbidDMingStrangers` (#222) [skip cd] --- portal.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/portal.go b/portal.go index baaaa4d..f73d206 100644 --- a/portal.go +++ b/portal.go @@ -1539,18 +1539,22 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { } if portal.bridge.Config.Bridge.ForbidDMingStrangers { - sender.relationshipLock.RLock() - if !sender.relationshipsReady { - go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "") - sender.relationshipLock.RUnlock() - return - } - relationship, hasRelationship := sender.relationships[portal.OtherUserID] - sender.relationshipLock.RUnlock() + recipient := portal.bridge.GetPuppetByID(portal.OtherUserID) - if !hasRelationship || relationship.Type != discordgo.RelationshipFriend { - go portal.sendMessageMetrics(evt, errDMingStranger, "") - return + if !recipient.IsBot { + sender.relationshipLock.RLock() + if !sender.relationshipsReady { + go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "") + sender.relationshipLock.RUnlock() + return + } + relationship, hasRelationship := sender.relationships[portal.OtherUserID] + sender.relationshipLock.RUnlock() + + if !hasRelationship || relationship.Type != discordgo.RelationshipFriend { + go portal.sendMessageMetrics(evt, errDMingStranger, "") + return + } } } } From d3d1c338f44dd3977fa9bea9dc708b904cd41a68 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 12 May 2026 00:16:06 +0300 Subject: [PATCH 99/99] Don't check friend status when sending as bot [skip cd] --- portal.go | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/portal.go b/portal.go index f73d206..db26a0e 100644 --- a/portal.go +++ b/portal.go @@ -1528,17 +1528,27 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok { + go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") + return + } + + channelID := portal.Key.ChannelID + sess := sender.Session + if sess == nil && portal.RelayWebhookID == "" { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + isWebhookSend := sess == nil + if portal.IsPrivateChat() { - if !sender.IsLoggedIn() { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") - return - } if sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return } - if portal.bridge.Config.Bridge.ForbidDMingStrangers { + if portal.bridge.Config.Bridge.ForbidDMingStrangers && sess.IsUser { recipient := portal.bridge.GetPuppetByID(portal.OtherUserID) if !recipient.IsBot { @@ -1558,20 +1568,6 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { } } } - - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") - return - } - - channelID := portal.Key.ChannelID - sess := sender.Session - if sess == nil && portal.RelayWebhookID == "" { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") - return - } - isWebhookSend := sess == nil var threadID string if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil {