├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── enhancement.md └── workflows │ ├── go.yml │ └── stale.yml ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.ci ├── Dockerfile.dev ├── LICENSE ├── README.md ├── ROADMAP.md ├── attachments.go ├── backfill.go ├── build.sh ├── commands.go ├── commands_botinteraction.go ├── config ├── bridge.go ├── config.go └── upgrade.go ├── custompuppet.go ├── database ├── database.go ├── file.go ├── guild.go ├── message.go ├── portal.go ├── puppet.go ├── reaction.go ├── role.go ├── thread.go ├── upgrades │ ├── 00-latest-revision.sql │ ├── 02-column-renames.sql │ ├── 03-spaces.sql │ ├── 04-attachment-fix.postgres.sql │ ├── 04-attachment-fix.sqlite.sql │ ├── 05-reaction-fkey-fix.sql │ ├── 06-user-read-state-version.sql │ ├── 07-store-role-info.sql │ ├── 08-channel-plain-name.sql │ ├── 09-more-thread-data.sql │ ├── 10-remove-broken-double-puppets.sql │ ├── 11-cache-reuploaded-files.sql │ ├── 12-file-cache-mime-type.sql │ ├── 13-merge-emoji-and-file.postgres.sql │ ├── 13-merge-emoji-and-file.sqlite.sql │ ├── 14-guild-bridging-mode.sql │ ├── 15-portal-relay-webhook.sql │ ├── 16-add-contact-info.sql │ ├── 17-dm-portal-friend-nick.sql │ ├── 18-extra-ghost-metadata.sql │ ├── 19-message-edit-ts.postgres.sql │ ├── 19-message-edit-ts.sqlite.sql │ ├── 20-message-sender-mxid.sql │ ├── 21-more-puppet-info.sql │ ├── 22-file-cache-duplicate-mxc.sql │ ├── 23-puppet-is-application.sql │ └── upgrades.go ├── user.go └── userportal.go ├── directmedia.go ├── directmedia_id.go ├── discord.go ├── docker-run.sh ├── example-config.yaml ├── formatter.go ├── formatter_everyone.go ├── formatter_tag.go ├── formatter_test.go ├── go.mod ├── go.sum ├── guildportal.go ├── main.go ├── portal.go ├── portal_convert.go ├── provisioning.go ├── puppet.go ├── remoteauth ├── README.md ├── client.go ├── clientpackets.go ├── serverpackets.go └── user.go ├── thread.go └── user.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yaml,yml,sql}] 12 | indent_style = space 13 | 14 | [.gitlab-ci.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: If something is definitely wrong in the bridge (rather than just a setup issue), 4 | file a bug report. Remember to include relevant logs. 5 | labels: bug 6 | 7 | --- 8 | 9 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Troubleshooting docs & FAQ 3 | url: https://docs.mau.fi/bridges/general/troubleshooting.html 4 | about: Check this first if you're having problems setting up the bridge. 5 | - name: Support room 6 | url: https://matrix.to/#/#discord:maunium.net 7 | about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Submit a feature request or other suggestion 4 | labels: enhancement 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | go-version: ["1.23", "1.24"] 12 | name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | cache: true 22 | 23 | - name: Install libolm 24 | run: sudo apt-get install libolm-dev libolm3 25 | 26 | - name: Install goimports 27 | run: | 28 | go install golang.org/x/tools/cmd/goimports@latest 29 | export PATH="$HOME/go/bin:$PATH" 30 | 31 | - name: Install pre-commit 32 | run: pip install pre-commit 33 | 34 | - name: Lint 35 | run: pre-commit run -a 36 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock old issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 21 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | # pull-requests: write 11 | # discussions: write 12 | 13 | concurrency: 14 | group: lock-threads 15 | 16 | jobs: 17 | lock-stale: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | id: lock 22 | with: 23 | issue-inactive-days: 90 24 | process-only: issues 25 | - name: Log processed threads 26 | run: | 27 | if [ '${{ steps.lock.outputs.issues }}' ]; then 28 | echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' 29 | fi 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.yaml 2 | !example-config.yaml 3 | !.pre-commit-config.yaml 4 | 5 | *.db* 6 | *.log* 7 | 8 | /mautrix-discord 9 | /start 10 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/go.yml' 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | - id: go-vet-repo-mod 16 | 17 | - repo: https://github.com/beeper/pre-commit-go 18 | rev: v0.3.1 19 | hooks: 20 | - id: zerolog-ban-msgf 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.7.3 (2025-04-16) 2 | 3 | * Added support for sending no-mention replies from Matrix 4 | (uses intentional mentions and requires client support). 5 | * Added file name to QR image message when logging in to fix rendering in dumb 6 | clients that validate the file extension. 7 | * Added `id` field to per-message profiles to match [MSC4144]. 8 | * Fixed guild avatars in per-message profiles (thanks to [@mat-1] in [#172]). 9 | * Fixed typo in MSC1767 field name in voice messages (thanks to [@ginnyTheCat] in [#177]). 10 | 11 | [@mat-1]: https://github.com/mat-1 12 | [@ginnyTheCat]: https://github.com/ginnyTheCat 13 | [#172]: https://github.com/mautrix/discord/pull/172 14 | [#177]: https://github.com/mautrix/discord/pull/177 15 | [MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144 16 | 17 | # v0.7.2 (2024-12-16) 18 | 19 | * Fixed some headers being set incorrectly. 20 | 21 | # v0.7.1 (2024-11-16) 22 | 23 | * Bumped minimum Go version to 1.22. 24 | * Updated Discord version numbers. 25 | 26 | # v0.7.0 (2024-07-16) 27 | 28 | * Bumped minimum Go version to 1.21. 29 | * Added support for Matrix v1.11 authenticated media. 30 | * This also changes how avatars are sent to Discord when using relay webhooks. 31 | To keep avatars working, you must configure `public_address` in the *bridge* 32 | section of the config and proxy `/mautrix-discord/avatar/*` from that 33 | address to the bridge. 34 | * Added `create-portal` command to create individual portals bypassing the 35 | bridging mode. When used in combination with the `if-portal-exists` bridging 36 | mode, this can be used to bridge individual channels from a guild. 37 | * Changed how direct media access works to make it compatible with Discord's 38 | signed URL requirement. The new system must be enabled manually, see 39 | [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for info. 40 | 41 | # v0.6.5 (2024-01-16) 42 | 43 | * Fixed adding reply embed to webhook sends if the Matrix room is encrypted. 44 | 45 | # v0.6.4 (2023-11-16) 46 | 47 | * Changed error messages to be sent in a thread if the errored message was in 48 | a thread. 49 | 50 | # v0.6.3 (2023-10-16) 51 | 52 | * Fixed op7 reconnects during connection causing the bridge to get stuck 53 | disconnected. 54 | * Fixed double puppet of recipient joining DM portals when both ends of a DM 55 | are using the same bridge. 56 | 57 | # v0.6.2 (2023-09-16) 58 | 59 | * Added support for double puppeting with arbitrary `as_token`s. 60 | See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info. 61 | * Adjusted markdown parsing rules to allow inline links in normal messages. 62 | * Fixed panic if redacting an attachment fails. 63 | * Fixed panic when handling video embeds with no URLs 64 | (thanks to [@odrling] in [#110]). 65 | 66 | [@odrling]: https://github.com/odrling 67 | [#110]: https://github.com/mautrix/discord/pull/110 68 | 69 | # v0.6.1 (2023-08-16) 70 | 71 | * Bumped minimum Go version to 1.20. 72 | * Fixed all logged-in users being invited to existing portal rooms even if they 73 | don't have permission to view the channel on Discord. 74 | * Fixed gif links not being treated as embeds if the canonical URL is different 75 | than the URL in the message body. 76 | 77 | # v0.6.0 (2023-07-16) 78 | 79 | * Added initial support for backfilling threads. 80 | * Exposed `Application` flag to displayname template. 81 | * Changed `m.emote` bridging to use italics on Discord. 82 | * Updated Docker image to Alpine 3.18. 83 | * Added limit to parallel media transfers to avoid high memory usage if lots 84 | of messages are received at the same time. 85 | * Fixed guilds being unbridged if Discord has server issues and temporarily 86 | marks a guild as unavailable. 87 | * Fixed using `guilds bridge` command without `--entire` flag. 88 | * Fixed panic if lottieconverter isn't installed. 89 | * Fixed relay webhook secret being leaked in network error messages. 90 | 91 | # v0.5.0 (2023-06-16) 92 | 93 | * Added support for intentional mentions in Matrix (MSC3952). 94 | * Added `GlobalName` variable to displayname templates and updated the default 95 | template to prefer it over usernames. 96 | * Added `Webhook` variable to displayname templates to allow determining if a 97 | ghost user is a webhook. 98 | * Added guild profiles and webhook profiles as a custom field in Matrix 99 | message events. 100 | * Added support for bulk message delete from Discord. 101 | * Added support for appservice websockets. 102 | * Enabled parsing headers (`#`) in Discord markdown. 103 | * Messages that consist of a single image link are now bridged as images to 104 | closer match Discord. 105 | * Stopped bridging incoming typing notifications from users who are logged into 106 | the bridge to prevent echoes. 107 | 108 | # v0.4.0 (2023-05-16) 109 | 110 | * Added bridging of friend nicks into DM room names. 111 | * Added option to bypass homeserver for Discord media. 112 | See [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for more info. 113 | * Added conversion of replies to embeds when sending messages via webhook. 114 | * Added option to disable caching reuploaded media. This may be necessary when 115 | using a media repo that doesn't create a unique mxc URI for each upload. 116 | * Added option to disable uploading files directly to the Discord CDN 117 | (and send as form parts in the message send request instead). 118 | * Improved formatting of error messages returned by Discord. 119 | * Enabled discordgo info logs by default. 120 | * Fixed limited backfill always stopping after 50 messages 121 | (thanks to [@odrling] in [#81]). 122 | * Fixed startup sync to sync most recent private channels first. 123 | * Fixed syncing group DM participants when they change. 124 | * Fixed bridging animated emojis in messages. 125 | * Stopped handling all message edits from relay webhook to prevent incorrect 126 | edits. 127 | * Possibly fixed inviting to portal rooms when multiple Matrix users use the 128 | bridge. 129 | 130 | [@odrling]: https://github.com/odrling 131 | [#81]: https://github.com/mautrix/discord/pull/81 132 | 133 | # v0.3.0 (2023-04-16) 134 | 135 | * Added support for backfilling on room creation and missed messages on startup. 136 | * Added options to automatically ratchet/delete megolm sessions to minimize 137 | access to old messages. 138 | * Added basic support for incoming voice messages. 139 | 140 | # v0.2.0 (2023-03-16) 141 | 142 | * Switched to zerolog for logging. 143 | * The basic log config will be migrated automatically, but you may want to 144 | tweak it as the options are different. 145 | * Added support for logging in with a bot account. 146 | The [Authentication docs](https://docs.mau.fi/bridges/go/discord/authentication.html) 147 | have been updated with instructions for creating a bot. 148 | * Added support for relaying messages for unauthenticated users using a webhook. 149 | See [docs](https://docs.mau.fi/bridges/go/discord/relay.html) for instructions. 150 | * Added commands to bridge and unbridge channels manually. 151 | * Added `ping` command. 152 | * Added support for gif stickers from Discord. 153 | * Changed mention bridging so mentions for users logged into the bridge use the 154 | Matrix user's MXID even if double puppeting is not enabled. 155 | * Actually fixed ghost user info not being synced when receiving reactions. 156 | * Fixed uncommon bug with sending messages that only occurred after login 157 | before restarting the bridge. 158 | * Fixed guild name not being synced immediately after joining a new guild. 159 | * Fixed variation selectors when bridging emojis to Discord. 160 | 161 | # v0.1.1 (2023-02-16) 162 | 163 | * Started automatically subscribing to bridged guilds. This fixes two problems: 164 | * Typing notifications should now work automatically in guilds. 165 | * Huge guilds now actually get messages bridged. 166 | * Added support for converting animated lottie stickers to raster formats using 167 | [lottieconverter](https://github.com/sot-tech/LottieConverter). 168 | * Added basic bridging for call start and guild join messages. 169 | * Improved markdown parsing to disable more features that don't exist on Discord. 170 | * Removed width from inline images (e.g. in the `guilds status` output) to 171 | handle non-square images properly. 172 | * Fixed ghost user info not being synced when receiving reactions. 173 | 174 | # v0.1.0 (2023-01-29) 175 | 176 | Initial release. 177 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie 2 | 3 | FROM golang:1-alpine3.18 AS builder 4 | 5 | RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev 6 | 7 | COPY . /build 8 | WORKDIR /build 9 | RUN go build -o /usr/bin/mautrix-discord 10 | 11 | FROM alpine:3.18 12 | 13 | ENV UID=1337 \ 14 | GID=1337 15 | 16 | RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \ 17 | zlib libpng giflib libstdc++ libgcc 18 | 19 | COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ 20 | COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter 21 | COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord 22 | COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml 23 | COPY --from=builder /build/docker-run.sh /docker-run.sh 24 | VOLUME /data 25 | 26 | CMD ["/docker-run.sh"] 27 | -------------------------------------------------------------------------------- /Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie 2 | 3 | FROM alpine:3.18 4 | 5 | ENV UID=1337 \ 6 | GID=1337 7 | 8 | RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \ 9 | zlib libpng giflib libstdc++ libgcc 10 | 11 | COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ 12 | COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter 13 | ARG EXECUTABLE=./mautrix-discord 14 | COPY $EXECUTABLE /usr/bin/mautrix-discord 15 | COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml 16 | COPY ./docker-run.sh /docker-run.sh 17 | VOLUME /data 18 | 19 | CMD ["/docker-run.sh"] 20 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie 2 | 3 | FROM golang:1-alpine3.18 AS builder 4 | 5 | RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \ 6 | zlib libpng giflib libstdc++ libgcc 7 | 8 | COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ 9 | COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter 10 | COPY . /build 11 | WORKDIR /build 12 | RUN go build -o /usr/bin/mautrix-discord 13 | 14 | # Setup development stack using gow 15 | RUN go install github.com/mitranim/gow@latest 16 | RUN echo 'gow run /build $@' > /usr/bin/mautrix-discord \ 17 | && chmod +x /usr/bin/mautrix-discord 18 | VOLUME /data 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-discord 2 | A Matrix-Discord puppeting bridge based on [discordgo](https://github.com/bwmarrin/discordgo). 3 | 4 | ## Documentation 5 | All setup and usage instructions are located on [docs.mau.fi]. Some quick links: 6 | 7 | [docs.mau.fi]: https://docs.mau.fi/bridges/go/discord/index.html 8 | 9 | * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord) 10 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord)) 11 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html), 12 | [Relaying with webhooks](https://docs.mau.fi/bridges/go/discord/relay.html) 13 | 14 | ### Features & Roadmap 15 | [ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md) 16 | contains a general overview of what is supported by the bridge. 17 | 18 | ## Discussion 19 | Matrix room: [#discord:maunium.net](https://matrix.to/#/#discord:maunium.net) 20 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | * Matrix → Discord 3 | * [ ] Message content 4 | * [x] Plain text 5 | * [x] Formatted messages 6 | * [x] Media/files 7 | * [x] Replies 8 | * [x] Threads 9 | * [ ] Custom emojis 10 | * [x] Message redactions 11 | * [x] Reactions 12 | * [x] Unicode emojis 13 | * [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works) 14 | * [ ] Executing Discord bot commands 15 | * [x] Basic arguments and subcommands 16 | * [ ] Subcommand groups 17 | * [ ] Mention arguments 18 | * [ ] Attachment arguments 19 | * [ ] Presence 20 | * [x] Typing notifications 21 | * [x] Own read status 22 | * [ ] Power level 23 | * [ ] Membership actions 24 | * [ ] Invite 25 | * [ ] Leave 26 | * [ ] Kick 27 | * [ ] Room metadata changes 28 | * [ ] Name 29 | * [ ] Avatar 30 | * [ ] Topic 31 | * [ ] Initial room metadata 32 | * Discord → Matrix 33 | * [ ] Message content 34 | * [x] Plain text 35 | * [x] Formatted messages 36 | * [x] Media/files 37 | * [x] Replies 38 | * [x] Threads 39 | * [x] Auto-joining threads when opening 40 | * [ ] Backfilling threads after joining 41 | * [x] Custom emojis 42 | * [x] Embeds 43 | * [ ] Interactive components 44 | * [x] Interactions (commands) 45 | * [x] @everyone/@here mentions into @room 46 | * [x] Message deletions 47 | * [x] Reactions 48 | * [x] Unicode emojis 49 | * [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027)) 50 | * [x] Avatars 51 | * [ ] Presence 52 | * [ ] Typing notifications (currently partial support: DMs work after you type in them) 53 | * [x] Own read status 54 | * [ ] Role permissions 55 | * [ ] Membership actions 56 | * [ ] Invite 57 | * [ ] Join 58 | * [ ] Leave 59 | * [ ] Kick 60 | * [x] Channel/group DM metadata changes 61 | * [x] Title 62 | * [x] Avatar 63 | * [x] Description 64 | * [x] Initial channel/group DM metadata 65 | * [ ] User metadata changes 66 | * [ ] Display name 67 | * [ ] Avatar 68 | * [ ] Initial user metadata 69 | * [ ] Display name 70 | * [ ] Avatar 71 | * Misc 72 | * [x] Login methods 73 | * [x] QR scan from mobile 74 | * [x] Manually providing access token 75 | * [x] Automatic portal creation 76 | * [x] After login 77 | * [x] When receiving DM 78 | * [ ] Private chat creation by inviting Matrix puppet of Discord user to new room 79 | * [x] Option to use own Matrix account for messages sent from other Discord clients 80 | -------------------------------------------------------------------------------- /attachments.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "image" 9 | "io" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/bwmarrin/discordgo" 20 | "github.com/gabriel-vasile/mimetype" 21 | "go.mau.fi/util/exsync" 22 | "go.mau.fi/util/ffmpeg" 23 | "maunium.net/go/mautrix" 24 | "maunium.net/go/mautrix/appservice" 25 | "maunium.net/go/mautrix/crypto/attachment" 26 | "maunium.net/go/mautrix/event" 27 | "maunium.net/go/mautrix/id" 28 | 29 | "go.mau.fi/mautrix-discord/database" 30 | ) 31 | 32 | func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { 33 | req, err := http.NewRequest(http.MethodGet, url, nil) 34 | if err != nil { 35 | return nil, err 36 | } 37 | for key, value := range discordgo.DroidDownloadHeaders { 38 | req.Header.Set(key, value) 39 | } 40 | 41 | resp, err := cli.Do(req) 42 | if err != nil { 43 | return nil, err 44 | } 45 | defer resp.Body.Close() 46 | if resp.StatusCode > 300 { 47 | data, _ := io.ReadAll(resp.Body) 48 | return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data) 49 | } 50 | if resp.Header.Get("Content-Length") != "" { 51 | length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to parse content length: %w", err) 54 | } else if length > maxSize { 55 | return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize) 56 | } 57 | return io.ReadAll(resp.Body) 58 | } else { 59 | var mbe *http.MaxBytesError 60 | data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize)) 61 | if err != nil && errors.As(err, &mbe) { 62 | return nil, fmt.Errorf("attachment too large (over %d)", maxSize) 63 | } 64 | return data, err 65 | } 66 | } 67 | 68 | func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error { 69 | req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) 70 | if err != nil { 71 | return err 72 | } 73 | for key, value := range discordgo.DroidBaseHeaders { 74 | req.Header.Set(key, value) 75 | } 76 | req.Header.Set("Content-Type", "application/octet-stream") 77 | req.Header.Set("Referer", "https://discord.com/") 78 | req.Header.Set("Sec-Fetch-Dest", "empty") 79 | req.Header.Set("Sec-Fetch-Mode", "cors") 80 | req.Header.Set("Sec-Fetch-Site", "cross-site") 81 | 82 | resp, err := cli.Do(req) 83 | if err != nil { 84 | return err 85 | } 86 | defer resp.Body.Close() 87 | if resp.StatusCode > 300 { 88 | respData, _ := io.ReadAll(resp.Body) 89 | return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData) 90 | } 91 | return nil 92 | } 93 | 94 | func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) { 95 | var file *event.EncryptedFileInfo 96 | rawMXC := content.URL 97 | 98 | if content.File != nil { 99 | file = content.File 100 | rawMXC = file.URL 101 | } 102 | 103 | mxc, err := rawMXC.Parse() 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | data, err := intent.DownloadBytes(mxc) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if file != nil { 114 | err = file.DecryptInPlace(data) 115 | if err != nil { 116 | return nil, err 117 | } 118 | } 119 | 120 | return data, nil 121 | } 122 | 123 | func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) { 124 | dbFile := br.DB.File.New() 125 | dbFile.Timestamp = time.Now() 126 | dbFile.URL = url 127 | dbFile.ID = meta.AttachmentID 128 | dbFile.EmojiName = meta.EmojiName 129 | dbFile.Size = len(data) 130 | dbFile.MimeType = mimetype.Detect(data).String() 131 | if meta.MimeType == "" { 132 | meta.MimeType = dbFile.MimeType 133 | } 134 | if strings.HasPrefix(meta.MimeType, "image/") { 135 | cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) 136 | dbFile.Width = cfg.Width 137 | dbFile.Height = cfg.Height 138 | } 139 | 140 | uploadMime := meta.MimeType 141 | if encrypt { 142 | dbFile.Encrypted = true 143 | dbFile.DecryptionInfo = attachment.NewEncryptedFile() 144 | dbFile.DecryptionInfo.EncryptInPlace(data) 145 | uploadMime = "application/octet-stream" 146 | } 147 | req := mautrix.ReqUploadMedia{ 148 | ContentBytes: data, 149 | ContentType: uploadMime, 150 | } 151 | if br.Config.Homeserver.AsyncMedia { 152 | resp, err := intent.CreateMXC() 153 | if err != nil { 154 | return nil, err 155 | } 156 | dbFile.MXC = resp.ContentURI 157 | req.MXC = resp.ContentURI 158 | req.UnstableUploadURL = resp.UnstableUploadURL 159 | semaWg.Add(1) 160 | go func() { 161 | defer semaWg.Done() 162 | _, err = intent.UploadMedia(req) 163 | if err != nil { 164 | br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err) 165 | dbFile.Delete() 166 | } 167 | }() 168 | } else { 169 | uploaded, err := intent.UploadMedia(req) 170 | if err != nil { 171 | return nil, err 172 | } 173 | dbFile.MXC = uploaded.ContentURI 174 | } 175 | return dbFile, nil 176 | } 177 | 178 | type AttachmentMeta struct { 179 | AttachmentID string 180 | MimeType string 181 | EmojiName string 182 | CopyIfMissing bool 183 | Converter func([]byte) ([]byte, string, error) 184 | } 185 | 186 | var NoMeta = AttachmentMeta{} 187 | 188 | type attachmentKey struct { 189 | URL string 190 | Encrypt bool 191 | } 192 | 193 | func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) { 194 | fps := br.Config.Bridge.AnimatedSticker.Args.FPS 195 | width := br.Config.Bridge.AnimatedSticker.Args.Width 196 | height := br.Config.Bridge.AnimatedSticker.Args.Height 197 | target := br.Config.Bridge.AnimatedSticker.Target 198 | var lottieTarget, outputMime string 199 | switch target { 200 | case "png": 201 | lottieTarget = "png" 202 | outputMime = "image/png" 203 | fps = 1 204 | case "gif": 205 | lottieTarget = "gif" 206 | outputMime = "image/gif" 207 | case "webm": 208 | lottieTarget = "pngs" 209 | outputMime = "video/webm" 210 | case "webp": 211 | lottieTarget = "pngs" 212 | outputMime = "image/webp" 213 | case "disable": 214 | return data, "application/json", nil 215 | default: 216 | return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target) 217 | } 218 | 219 | ctx := context.Background() 220 | tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_") 221 | if err != nil { 222 | return nil, "", fmt.Errorf("failed to create temp dir: %w", err) 223 | } 224 | defer func() { 225 | removErr := os.RemoveAll(tempdir) 226 | if removErr != nil { 227 | br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr) 228 | } 229 | }() 230 | 231 | lottieOutput := filepath.Join(tempdir, "out_") 232 | if lottieTarget != "pngs" { 233 | lottieOutput = filepath.Join(tempdir, "output."+lottieTarget) 234 | } 235 | cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps)) 236 | cmd.Stdin = bytes.NewReader(data) 237 | err = cmd.Run() 238 | if err != nil { 239 | return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err) 240 | } 241 | var path string 242 | if lottieTarget == "pngs" { 243 | var videoCodec string 244 | outputExtension := "." + target 245 | if target == "webm" { 246 | videoCodec = "libvpx-vp9" 247 | } else if target == "webp" { 248 | videoCodec = "libwebp_anim" 249 | } else { 250 | panic(fmt.Errorf("impossible case: unknown target %q", target)) 251 | } 252 | path, err = ffmpeg.ConvertPath( 253 | ctx, lottieOutput+"*.png", outputExtension, 254 | []string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"}, 255 | []string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target}, 256 | false, 257 | ) 258 | if err != nil { 259 | return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err) 260 | } 261 | } else { 262 | path = lottieOutput 263 | } 264 | data, err = os.ReadFile(path) 265 | if err != nil { 266 | return nil, "", fmt.Errorf("failed to read converted file: %w", err) 267 | } 268 | return data, outputMime, nil 269 | } 270 | 271 | func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) { 272 | isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt) 273 | returnDBFile = br.DB.File.Get(url, encrypt) 274 | if returnDBFile == nil { 275 | transferKey := attachmentKey{url, encrypt} 276 | once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{}) 277 | returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) { 278 | if isCacheable { 279 | onceDBFile = br.DB.File.Get(url, encrypt) 280 | if onceDBFile != nil { 281 | return 282 | } 283 | } 284 | 285 | const attachmentSizeVal = 1 286 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 287 | onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal) 288 | cancel() 289 | if onceErr != nil { 290 | br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore") 291 | onceErr = fmt.Errorf("reuploading timed out") 292 | return 293 | } 294 | var semaWg sync.WaitGroup 295 | semaWg.Add(1) 296 | defer semaWg.Done() 297 | go func() { 298 | semaWg.Wait() 299 | br.parallelAttachmentSemaphore.Release(attachmentSizeVal) 300 | }() 301 | 302 | var data []byte 303 | data, onceErr = downloadDiscordAttachment(http.DefaultClient, url, br.MediaConfig.UploadSize) 304 | if onceErr != nil { 305 | return 306 | } 307 | 308 | if meta.Converter != nil { 309 | data, meta.MimeType, onceErr = meta.Converter(data) 310 | if onceErr != nil { 311 | onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr) 312 | return 313 | } 314 | } 315 | 316 | onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg) 317 | if onceErr != nil { 318 | return 319 | } 320 | if isCacheable { 321 | onceDBFile.Insert(nil) 322 | } 323 | br.attachmentTransfers.Delete(transferKey) 324 | return 325 | }) 326 | } 327 | return 328 | } 329 | 330 | func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI { 331 | mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated) 332 | if !mxc.IsEmpty() { 333 | return mxc 334 | } 335 | var url, mimeType string 336 | if animated { 337 | url = discordgo.EndpointEmojiAnimated(emojiID) 338 | mimeType = "image/gif" 339 | } else { 340 | url = discordgo.EndpointEmoji(emojiID) 341 | mimeType = "image/png" 342 | } 343 | dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{ 344 | AttachmentID: emojiID, 345 | MimeType: mimeType, 346 | EmojiName: name, 347 | }) 348 | if err != nil { 349 | portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix") 350 | return id.ContentURI{} 351 | } 352 | return dbFile.MXC 353 | } 354 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 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'`'" "$@" 3 | -------------------------------------------------------------------------------- /config/bridge.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package config 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "strings" 23 | "text/template" 24 | 25 | "github.com/bwmarrin/discordgo" 26 | 27 | "maunium.net/go/mautrix/bridge/bridgeconfig" 28 | ) 29 | 30 | type BridgeConfig struct { 31 | UsernameTemplate string `yaml:"username_template"` 32 | DisplaynameTemplate string `yaml:"displayname_template"` 33 | ChannelNameTemplate string `yaml:"channel_name_template"` 34 | GuildNameTemplate string `yaml:"guild_name_template"` 35 | PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` 36 | PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"` 37 | 38 | PortalMessageBuffer int `yaml:"portal_message_buffer"` 39 | 40 | PublicAddress string `yaml:"public_address"` 41 | AvatarProxyKey string `yaml:"avatar_proxy_key"` 42 | 43 | DeliveryReceipts bool `yaml:"delivery_receipts"` 44 | MessageStatusEvents bool `yaml:"message_status_events"` 45 | MessageErrorNotices bool `yaml:"message_error_notices"` 46 | RestrictedRooms bool `yaml:"restricted_rooms"` 47 | AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"` 48 | EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"` 49 | MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"` 50 | SyncDirectChatList bool `yaml:"sync_direct_chat_list"` 51 | ResendBridgeInfo bool `yaml:"resend_bridge_info"` 52 | CustomEmojiReactions bool `yaml:"custom_emoji_reactions"` 53 | DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"` 54 | DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"` 55 | FederateRooms bool `yaml:"federate_rooms"` 56 | PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"` 57 | EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"` 58 | UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"` 59 | 60 | Proxy string `yaml:"proxy"` 61 | 62 | CacheMedia string `yaml:"cache_media"` 63 | DirectMedia DirectMedia `yaml:"direct_media"` 64 | 65 | AnimatedSticker struct { 66 | Target string `yaml:"target"` 67 | Args struct { 68 | Width int `yaml:"width"` 69 | Height int `yaml:"height"` 70 | FPS int `yaml:"fps"` 71 | } `yaml:"args"` 72 | } `yaml:"animated_sticker"` 73 | 74 | DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` 75 | 76 | CommandPrefix string `yaml:"command_prefix"` 77 | ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"` 78 | 79 | Backfill struct { 80 | Limits struct { 81 | Initial BackfillLimitPart `yaml:"initial"` 82 | Missed BackfillLimitPart `yaml:"missed"` 83 | } `yaml:"forward_limits"` 84 | MaxGuildMembers int `yaml:"max_guild_members"` 85 | } `yaml:"backfill"` 86 | 87 | Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"` 88 | 89 | Provisioning struct { 90 | Prefix string `yaml:"prefix"` 91 | SharedSecret string `yaml:"shared_secret"` 92 | DebugEndpoints bool `yaml:"debug_endpoints"` 93 | } `yaml:"provisioning"` 94 | 95 | Permissions bridgeconfig.PermissionConfig `yaml:"permissions"` 96 | 97 | usernameTemplate *template.Template `yaml:"-"` 98 | displaynameTemplate *template.Template `yaml:"-"` 99 | channelNameTemplate *template.Template `yaml:"-"` 100 | guildNameTemplate *template.Template `yaml:"-"` 101 | } 102 | 103 | type DirectMedia struct { 104 | Enabled bool `yaml:"enabled"` 105 | ServerName string `yaml:"server_name"` 106 | WellKnownResponse string `yaml:"well_known_response"` 107 | AllowProxy bool `yaml:"allow_proxy"` 108 | ServerKey string `yaml:"server_key"` 109 | } 110 | 111 | type BackfillLimitPart struct { 112 | DM int `yaml:"dm"` 113 | Channel int `yaml:"channel"` 114 | Thread int `yaml:"thread"` 115 | } 116 | 117 | func (bc *BridgeConfig) GetResendBridgeInfo() bool { 118 | return bc.ResendBridgeInfo 119 | } 120 | 121 | func (bc *BridgeConfig) EnableMessageStatusEvents() bool { 122 | return bc.MessageStatusEvents 123 | } 124 | 125 | func (bc *BridgeConfig) EnableMessageErrorNotices() bool { 126 | return bc.MessageErrorNotices 127 | } 128 | 129 | func boolToInt(val bool) int { 130 | if val { 131 | return 1 132 | } 133 | return 0 134 | } 135 | 136 | func (bc *BridgeConfig) Validate() error { 137 | _, hasWildcard := bc.Permissions["*"] 138 | _, hasExampleDomain := bc.Permissions["example.com"] 139 | _, hasExampleUser := bc.Permissions["@admin:example.com"] 140 | exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) 141 | if len(bc.Permissions) <= exampleLen { 142 | return errors.New("bridge.permissions not configured") 143 | } 144 | return nil 145 | } 146 | 147 | type umBridgeConfig BridgeConfig 148 | 149 | func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { 150 | err := unmarshal((*umBridgeConfig)(bc)) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate) 156 | if err != nil { 157 | return err 158 | } else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") { 159 | return fmt.Errorf("username template is missing user ID placeholder") 160 | } 161 | bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate) 162 | if err != nil { 163 | return err 164 | } 165 | bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate) 166 | if err != nil { 167 | return err 168 | } 169 | bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | return nil 175 | } 176 | 177 | var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil) 178 | 179 | func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig { 180 | return bc.DoublePuppetConfig 181 | } 182 | 183 | func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { 184 | return bc.Encryption 185 | } 186 | 187 | func (bc BridgeConfig) GetCommandPrefix() string { 188 | return bc.CommandPrefix 189 | } 190 | 191 | func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { 192 | return bc.ManagementRoomText 193 | } 194 | 195 | func (bc BridgeConfig) FormatUsername(userID string) string { 196 | var buffer strings.Builder 197 | _ = bc.usernameTemplate.Execute(&buffer, userID) 198 | return buffer.String() 199 | } 200 | 201 | type DisplaynameParams struct { 202 | *discordgo.User 203 | Webhook bool 204 | Application bool 205 | } 206 | 207 | func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string { 208 | var buffer strings.Builder 209 | _ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{ 210 | User: user, 211 | Webhook: webhook, 212 | Application: application, 213 | }) 214 | return buffer.String() 215 | } 216 | 217 | type ChannelNameParams struct { 218 | Name string 219 | ParentName string 220 | GuildName string 221 | NSFW bool 222 | Type discordgo.ChannelType 223 | } 224 | 225 | func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string { 226 | var buffer strings.Builder 227 | _ = bc.channelNameTemplate.Execute(&buffer, params) 228 | return buffer.String() 229 | } 230 | 231 | type GuildNameParams struct { 232 | Name string 233 | } 234 | 235 | func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string { 236 | var buffer strings.Builder 237 | _ = bc.guildNameTemplate.Execute(&buffer, params) 238 | return buffer.String() 239 | } 240 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package config 18 | 19 | import ( 20 | "maunium.net/go/mautrix/bridge/bridgeconfig" 21 | "maunium.net/go/mautrix/id" 22 | ) 23 | 24 | type Config struct { 25 | *bridgeconfig.BaseConfig `yaml:",inline"` 26 | 27 | Bridge BridgeConfig `yaml:"bridge"` 28 | } 29 | 30 | func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool { 31 | _, homeserver, _ := userID.Parse() 32 | _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver] 33 | 34 | return hasSecret 35 | } 36 | -------------------------------------------------------------------------------- /config/upgrade.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2023 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package config 18 | 19 | import ( 20 | up "go.mau.fi/util/configupgrade" 21 | "go.mau.fi/util/random" 22 | "maunium.net/go/mautrix/bridge/bridgeconfig" 23 | "maunium.net/go/mautrix/federation" 24 | ) 25 | 26 | func DoUpgrade(helper *up.Helper) { 27 | bridgeconfig.Upgrader.DoUpgrade(helper) 28 | 29 | helper.Copy(up.Str, "bridge", "username_template") 30 | helper.Copy(up.Str, "bridge", "displayname_template") 31 | helper.Copy(up.Str, "bridge", "channel_name_template") 32 | helper.Copy(up.Str, "bridge", "guild_name_template") 33 | if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok { 34 | updatedPrivateChatPortalMeta := "default" 35 | if legacyPrivateChatPortalMeta == "true" { 36 | updatedPrivateChatPortalMeta = "always" 37 | } 38 | helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta") 39 | } else { 40 | helper.Copy(up.Str, "bridge", "private_chat_portal_meta") 41 | } 42 | helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit") 43 | helper.Copy(up.Str|up.Null, "bridge", "public_address") 44 | if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" { 45 | helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key") 46 | } else { 47 | helper.Copy(up.Str, "bridge", "avatar_proxy_key") 48 | } 49 | helper.Copy(up.Int, "bridge", "portal_message_buffer") 50 | helper.Copy(up.Bool, "bridge", "delivery_receipts") 51 | helper.Copy(up.Bool, "bridge", "message_status_events") 52 | helper.Copy(up.Bool, "bridge", "message_error_notices") 53 | helper.Copy(up.Bool, "bridge", "restricted_rooms") 54 | helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open") 55 | helper.Copy(up.Bool, "bridge", "embed_fields_as_tables") 56 | helper.Copy(up.Bool, "bridge", "mute_channels_on_create") 57 | helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") 58 | helper.Copy(up.Bool, "bridge", "resend_bridge_info") 59 | helper.Copy(up.Bool, "bridge", "custom_emoji_reactions") 60 | helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete") 61 | helper.Copy(up.Bool, "bridge", "delete_guild_on_leave") 62 | helper.Copy(up.Bool, "bridge", "federate_rooms") 63 | helper.Copy(up.Bool, "bridge", "prefix_webhook_messages") 64 | helper.Copy(up.Bool, "bridge", "enable_webhook_avatars") 65 | helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload") 66 | helper.Copy(up.Str|up.Null, "bridge", "proxy") 67 | helper.Copy(up.Str, "bridge", "cache_media") 68 | helper.Copy(up.Bool, "bridge", "direct_media", "enabled") 69 | helper.Copy(up.Str, "bridge", "direct_media", "server_name") 70 | helper.Copy(up.Str|up.Null, "bridge", "direct_media", "well_known_response") 71 | helper.Copy(up.Bool, "bridge", "direct_media", "allow_proxy") 72 | if serverKey, ok := helper.Get(up.Str, "bridge", "direct_media", "server_key"); !ok || serverKey == "generate" { 73 | serverKey = federation.GenerateSigningKey().SynapseString() 74 | helper.Set(up.Str, serverKey, "bridge", "direct_media", "server_key") 75 | } else { 76 | helper.Copy(up.Str, "bridge", "direct_media", "server_key") 77 | } 78 | helper.Copy(up.Str, "bridge", "animated_sticker", "target") 79 | helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width") 80 | helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height") 81 | helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps") 82 | helper.Copy(up.Map, "bridge", "double_puppet_server_map") 83 | helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery") 84 | helper.Copy(up.Map, "bridge", "login_shared_secret_map") 85 | helper.Copy(up.Str, "bridge", "command_prefix") 86 | helper.Copy(up.Str, "bridge", "management_room_text", "welcome") 87 | helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected") 88 | helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected") 89 | helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help") 90 | helper.Copy(up.Bool, "bridge", "backfill", "enabled") 91 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm") 92 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel") 93 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread") 94 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm") 95 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel") 96 | helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread") 97 | helper.Copy(up.Int, "bridge", "backfill", "max_guild_members") 98 | helper.Copy(up.Bool, "bridge", "encryption", "allow") 99 | helper.Copy(up.Bool, "bridge", "encryption", "default") 100 | helper.Copy(up.Bool, "bridge", "encryption", "require") 101 | helper.Copy(up.Bool, "bridge", "encryption", "appservice") 102 | helper.Copy(up.Bool, "bridge", "encryption", "msc4190") 103 | helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing") 104 | helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions") 105 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack") 106 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound") 107 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt") 108 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt") 109 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session") 110 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete") 111 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired") 112 | helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound") 113 | helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive") 114 | helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send") 115 | helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share") 116 | helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom") 117 | helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") 118 | helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") 119 | helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation") 120 | 121 | helper.Copy(up.Str, "bridge", "provisioning", "prefix") 122 | if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { 123 | sharedSecret := random.String(64) 124 | helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret") 125 | } else { 126 | helper.Copy(up.Str, "bridge", "provisioning", "shared_secret") 127 | } 128 | helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints") 129 | 130 | helper.Copy(up.Map, "bridge", "permissions") 131 | //helper.Copy(up.Bool, "bridge", "relay", "enabled") 132 | //helper.Copy(up.Bool, "bridge", "relay", "admin_only") 133 | //helper.Copy(up.Map, "bridge", "relay", "message_formats") 134 | } 135 | 136 | var SpacedBlocks = [][]string{ 137 | {"homeserver", "software"}, 138 | {"appservice"}, 139 | {"appservice", "hostname"}, 140 | {"appservice", "database"}, 141 | {"appservice", "id"}, 142 | {"appservice", "as_token"}, 143 | {"bridge"}, 144 | {"bridge", "command_prefix"}, 145 | {"bridge", "management_room_text"}, 146 | {"bridge", "encryption"}, 147 | {"bridge", "provisioning"}, 148 | {"bridge", "permissions"}, 149 | //{"bridge", "relay"}, 150 | {"logging"}, 151 | } 152 | -------------------------------------------------------------------------------- /custompuppet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "maunium.net/go/mautrix/id" 5 | ) 6 | 7 | func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error { 8 | puppet.CustomMXID = mxid 9 | puppet.AccessToken = accessToken 10 | puppet.Update() 11 | err := puppet.StartCustomMXID(false) 12 | if err != nil { 13 | return err 14 | } 15 | // TODO leave rooms with default puppet 16 | return nil 17 | } 18 | 19 | func (puppet *Puppet) ClearCustomMXID() { 20 | save := puppet.CustomMXID != "" || puppet.AccessToken != "" 21 | puppet.bridge.puppetsLock.Lock() 22 | if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet { 23 | delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID) 24 | } 25 | puppet.bridge.puppetsLock.Unlock() 26 | puppet.CustomMXID = "" 27 | puppet.AccessToken = "" 28 | puppet.customIntent = nil 29 | puppet.customUser = nil 30 | if save { 31 | puppet.Update() 32 | } 33 | } 34 | 35 | func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error { 36 | newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail) 37 | if err != nil { 38 | puppet.ClearCustomMXID() 39 | return err 40 | } 41 | puppet.bridge.puppetsLock.Lock() 42 | puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet 43 | puppet.bridge.puppetsLock.Unlock() 44 | if puppet.AccessToken != newAccessToken { 45 | puppet.AccessToken = newAccessToken 46 | puppet.Update() 47 | } 48 | puppet.customIntent = newIntent 49 | puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID) 50 | return nil 51 | } 52 | 53 | func (user *User) tryAutomaticDoublePuppeting() { 54 | if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) { 55 | return 56 | } 57 | user.log.Debug().Msg("Checking if double puppeting needs to be enabled") 58 | puppet := user.bridge.GetPuppetByID(user.DiscordID) 59 | if len(puppet.CustomMXID) > 0 { 60 | user.log.Debug().Msg("User already has double-puppeting enabled") 61 | // Custom puppet already enabled 62 | return 63 | } 64 | puppet.CustomMXID = user.MXID 65 | err := puppet.StartCustomMXID(true) 66 | if err != nil { 67 | user.log.Warn().Err(err).Msg("Failed to login with shared secret") 68 | } else { 69 | // TODO leave rooms with default puppet 70 | user.log.Debug().Msg("Successfully automatically enabled custom puppet") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | _ "embed" 5 | 6 | _ "github.com/lib/pq" 7 | _ "github.com/mattn/go-sqlite3" 8 | "go.mau.fi/util/dbutil" 9 | "maunium.net/go/maulogger/v2" 10 | 11 | "go.mau.fi/mautrix-discord/database/upgrades" 12 | ) 13 | 14 | type Database struct { 15 | *dbutil.Database 16 | 17 | User *UserQuery 18 | Portal *PortalQuery 19 | Puppet *PuppetQuery 20 | Message *MessageQuery 21 | Thread *ThreadQuery 22 | Reaction *ReactionQuery 23 | Guild *GuildQuery 24 | Role *RoleQuery 25 | File *FileQuery 26 | } 27 | 28 | func New(baseDB *dbutil.Database, log maulogger.Logger) *Database { 29 | db := &Database{Database: baseDB} 30 | db.UpgradeTable = upgrades.Table 31 | db.User = &UserQuery{ 32 | db: db, 33 | log: log.Sub("User"), 34 | } 35 | db.Portal = &PortalQuery{ 36 | db: db, 37 | log: log.Sub("Portal"), 38 | } 39 | db.Puppet = &PuppetQuery{ 40 | db: db, 41 | log: log.Sub("Puppet"), 42 | } 43 | db.Message = &MessageQuery{ 44 | db: db, 45 | log: log.Sub("Message"), 46 | } 47 | db.Thread = &ThreadQuery{ 48 | db: db, 49 | log: log.Sub("Thread"), 50 | } 51 | db.Reaction = &ReactionQuery{ 52 | db: db, 53 | log: log.Sub("Reaction"), 54 | } 55 | db.Guild = &GuildQuery{ 56 | db: db, 57 | log: log.Sub("Guild"), 58 | } 59 | db.Role = &RoleQuery{ 60 | db: db, 61 | log: log.Sub("Role"), 62 | } 63 | db.File = &FileQuery{ 64 | db: db, 65 | log: log.Sub("File"), 66 | } 67 | return db 68 | } 69 | 70 | func strPtr[T ~string](val T) *string { 71 | if val == "" { 72 | return nil 73 | } 74 | valStr := string(val) 75 | return &valStr 76 | } 77 | -------------------------------------------------------------------------------- /database/file.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "time" 8 | 9 | "go.mau.fi/util/dbutil" 10 | log "maunium.net/go/maulogger/v2" 11 | "maunium.net/go/mautrix/crypto/attachment" 12 | "maunium.net/go/mautrix/id" 13 | ) 14 | 15 | type FileQuery struct { 16 | db *Database 17 | log log.Logger 18 | } 19 | 20 | // language=postgresql 21 | const ( 22 | fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file" 23 | fileInsert = ` 24 | INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp) 25 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 26 | ` 27 | ) 28 | 29 | func (fq *FileQuery) New() *File { 30 | return &File{ 31 | db: fq.db, 32 | log: fq.log, 33 | } 34 | } 35 | 36 | func (fq *FileQuery) Get(url string, encrypted bool) *File { 37 | query := fileSelect + " WHERE url=$1 AND encrypted=$2" 38 | return fq.New().Scan(fq.db.QueryRow(query, url, encrypted)) 39 | } 40 | 41 | func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File { 42 | query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1" 43 | return fq.New().Scan(fq.db.QueryRow(query, mxc.String())) 44 | } 45 | 46 | type File struct { 47 | db *Database 48 | log log.Logger 49 | 50 | URL string 51 | Encrypted bool 52 | MXC id.ContentURI 53 | 54 | ID string 55 | EmojiName string 56 | 57 | Size int 58 | Width int 59 | Height int 60 | MimeType string 61 | 62 | DecryptionInfo *attachment.EncryptedFile 63 | Timestamp time.Time 64 | } 65 | 66 | func (f *File) Scan(row dbutil.Scannable) *File { 67 | var fileID, emojiName, decryptionInfo sql.NullString 68 | var width, height sql.NullInt32 69 | var timestamp int64 70 | var mxc string 71 | err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp) 72 | if err != nil { 73 | if !errors.Is(err, sql.ErrNoRows) { 74 | f.log.Errorln("Database scan failed:", err) 75 | panic(err) 76 | } 77 | return nil 78 | } 79 | f.ID = fileID.String 80 | f.EmojiName = emojiName.String 81 | f.Timestamp = time.UnixMilli(timestamp).UTC() 82 | f.Width = int(width.Int32) 83 | f.Height = int(height.Int32) 84 | f.MXC, err = id.ParseContentURI(mxc) 85 | if err != nil { 86 | f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err) 87 | panic(err) 88 | } 89 | if decryptionInfo.Valid { 90 | err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo) 91 | if err != nil { 92 | f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err) 93 | panic(err) 94 | } 95 | } 96 | return f 97 | } 98 | 99 | func positiveIntToNullInt32(val int) (ptr sql.NullInt32) { 100 | if val > 0 { 101 | ptr.Valid = true 102 | ptr.Int32 = int32(val) 103 | } 104 | return 105 | } 106 | 107 | func (f *File) Insert(txn dbutil.Execable) { 108 | if txn == nil { 109 | txn = f.db 110 | } 111 | var decryptionInfoStr sql.NullString 112 | if f.DecryptionInfo != nil { 113 | decryptionInfo, err := json.Marshal(f.DecryptionInfo) 114 | if err != nil { 115 | f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err) 116 | panic(err) 117 | } 118 | decryptionInfoStr.Valid = true 119 | decryptionInfoStr.String = string(decryptionInfo) 120 | } 121 | _, err := txn.Exec(fileInsert, 122 | f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size, 123 | positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType, 124 | decryptionInfoStr, f.Timestamp.UnixMilli(), 125 | ) 126 | if err != nil { 127 | f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err) 128 | panic(err) 129 | } 130 | } 131 | 132 | func (f *File) Delete() { 133 | _, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted) 134 | if err != nil { 135 | f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err) 136 | panic(err) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /database/guild.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "go.mau.fi/util/dbutil" 10 | log "maunium.net/go/maulogger/v2" 11 | "maunium.net/go/mautrix/id" 12 | ) 13 | 14 | type GuildBridgingMode int 15 | 16 | const ( 17 | // GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists. 18 | GuildBridgeNothing GuildBridgingMode = iota 19 | // GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals. 20 | GuildBridgeIfPortalExists 21 | // GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received. 22 | GuildBridgeCreateOnMessage 23 | // GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications. 24 | GuildBridgeEverything 25 | 26 | GuildBridgeInvalid GuildBridgingMode = -1 27 | ) 28 | 29 | func ParseGuildBridgingMode(str string) GuildBridgingMode { 30 | str = strings.ToLower(str) 31 | str = strings.ReplaceAll(str, "-", "") 32 | str = strings.ReplaceAll(str, "_", "") 33 | switch str { 34 | case "nothing", "0": 35 | return GuildBridgeNothing 36 | case "ifportalexists", "1": 37 | return GuildBridgeIfPortalExists 38 | case "createonmessage", "2": 39 | return GuildBridgeCreateOnMessage 40 | case "everything", "3": 41 | return GuildBridgeEverything 42 | default: 43 | return GuildBridgeInvalid 44 | } 45 | } 46 | 47 | func (gbm GuildBridgingMode) String() string { 48 | switch gbm { 49 | case GuildBridgeNothing: 50 | return "nothing" 51 | case GuildBridgeIfPortalExists: 52 | return "if-portal-exists" 53 | case GuildBridgeCreateOnMessage: 54 | return "create-on-message" 55 | case GuildBridgeEverything: 56 | return "everything" 57 | default: 58 | return "" 59 | } 60 | } 61 | 62 | func (gbm GuildBridgingMode) Description() string { 63 | switch gbm { 64 | case GuildBridgeNothing: 65 | return "never bridge messages" 66 | case GuildBridgeIfPortalExists: 67 | return "bridge messages in existing portals" 68 | case GuildBridgeCreateOnMessage: 69 | return "bridge all messages and create portals on first message" 70 | case GuildBridgeEverything: 71 | return "bridge all messages and create portals proactively" 72 | default: 73 | return "" 74 | } 75 | } 76 | 77 | type GuildQuery struct { 78 | db *Database 79 | log log.Logger 80 | } 81 | 82 | const ( 83 | guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild" 84 | ) 85 | 86 | func (gq *GuildQuery) New() *Guild { 87 | return &Guild{ 88 | db: gq.db, 89 | log: gq.log, 90 | } 91 | } 92 | 93 | func (gq *GuildQuery) GetByID(dcid string) *Guild { 94 | query := guildSelect + " WHERE dcid=$1" 95 | return gq.New().Scan(gq.db.QueryRow(query, dcid)) 96 | } 97 | 98 | func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild { 99 | query := guildSelect + " WHERE mxid=$1" 100 | return gq.New().Scan(gq.db.QueryRow(query, mxid)) 101 | } 102 | 103 | func (gq *GuildQuery) GetAll() []*Guild { 104 | rows, err := gq.db.Query(guildSelect) 105 | if err != nil { 106 | gq.log.Errorln("Failed to query guilds:", err) 107 | return nil 108 | } 109 | 110 | var guilds []*Guild 111 | for rows.Next() { 112 | guild := gq.New().Scan(rows) 113 | if guild != nil { 114 | guilds = append(guilds, guild) 115 | } 116 | } 117 | 118 | return guilds 119 | } 120 | 121 | type Guild struct { 122 | db *Database 123 | log log.Logger 124 | 125 | ID string 126 | MXID id.RoomID 127 | PlainName string 128 | Name string 129 | NameSet bool 130 | Avatar string 131 | AvatarURL id.ContentURI 132 | AvatarSet bool 133 | 134 | BridgingMode GuildBridgingMode 135 | } 136 | 137 | func (g *Guild) Scan(row dbutil.Scannable) *Guild { 138 | var mxid sql.NullString 139 | var avatarURL string 140 | err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode) 141 | if err != nil { 142 | if !errors.Is(err, sql.ErrNoRows) { 143 | g.log.Errorln("Database scan failed:", err) 144 | panic(err) 145 | } 146 | 147 | return nil 148 | } 149 | if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything { 150 | panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID)) 151 | } 152 | g.MXID = id.RoomID(mxid.String) 153 | g.AvatarURL, _ = id.ParseContentURI(avatarURL) 154 | return g 155 | } 156 | 157 | func (g *Guild) mxidPtr() *id.RoomID { 158 | if g.MXID != "" { 159 | return &g.MXID 160 | } 161 | return nil 162 | } 163 | 164 | func (g *Guild) Insert() { 165 | query := ` 166 | INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode) 167 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 168 | ` 169 | _, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode) 170 | if err != nil { 171 | g.log.Warnfln("Failed to insert %s: %v", g.ID, err) 172 | panic(err) 173 | } 174 | } 175 | 176 | func (g *Guild) Update() { 177 | query := ` 178 | UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8 179 | WHERE dcid=$9 180 | ` 181 | _, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID) 182 | if err != nil { 183 | g.log.Warnfln("Failed to update %s: %v", g.ID, err) 184 | panic(err) 185 | } 186 | } 187 | 188 | func (g *Guild) Delete() { 189 | _, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID) 190 | if err != nil { 191 | g.log.Warnfln("Failed to delete %s: %v", g.ID, err) 192 | panic(err) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /database/message.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "go.mau.fi/util/dbutil" 11 | log "maunium.net/go/maulogger/v2" 12 | "maunium.net/go/mautrix/id" 13 | ) 14 | 15 | type MessageQuery struct { 16 | db *Database 17 | log log.Logger 18 | } 19 | 20 | const ( 21 | 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" 22 | ) 23 | 24 | func (mq *MessageQuery) New() *Message { 25 | return &Message{ 26 | db: mq.db, 27 | log: mq.log, 28 | } 29 | } 30 | 31 | func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message { 32 | if err != nil { 33 | mq.log.Warnfln("Failed to query many messages: %v", err) 34 | panic(err) 35 | } else if rows == nil { 36 | return nil 37 | } 38 | 39 | var messages []*Message 40 | for rows.Next() { 41 | messages = append(messages, mq.New().Scan(rows)) 42 | } 43 | 44 | return messages 45 | } 46 | 47 | func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message { 48 | query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC" 49 | return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID)) 50 | } 51 | 52 | func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message { 53 | query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC LIMIT 1" 54 | return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID)) 55 | } 56 | 57 | func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message { 58 | query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id DESC LIMIT 1" 59 | return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID)) 60 | } 61 | 62 | func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message { 63 | 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" 64 | return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli())) 65 | } 66 | 67 | func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message { 68 | 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" 69 | return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID)) 70 | } 71 | 72 | func (mq *MessageQuery) GetLast(key PortalKey) *Message { 73 | query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 ORDER BY timestamp DESC LIMIT 1" 74 | return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver)) 75 | } 76 | 77 | func (mq *MessageQuery) DeleteAll(key PortalKey) { 78 | query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2" 79 | _, err := mq.db.Exec(query, key.ChannelID, key.Receiver) 80 | if err != nil { 81 | mq.log.Warnfln("Failed to delete messages of %s: %v", key, err) 82 | panic(err) 83 | } 84 | } 85 | 86 | func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message { 87 | query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3" 88 | 89 | row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid) 90 | if row == nil { 91 | return nil 92 | } 93 | 94 | return mq.New().Scan(row) 95 | } 96 | 97 | func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) { 98 | if len(msgs) == 0 { 99 | return 100 | } 101 | valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)" 102 | if mq.db.Dialect == dbutil.SQLite { 103 | valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?") 104 | } 105 | params := make([]interface{}, 2+len(msgs)*8) 106 | placeholders := make([]string, len(msgs)) 107 | params[0] = key.ChannelID 108 | params[1] = key.Receiver 109 | for i, msg := range msgs { 110 | baseIndex := 2 + i*8 111 | params[baseIndex] = msg.DiscordID 112 | params[baseIndex+1] = msg.AttachmentID 113 | params[baseIndex+2] = msg.SenderID 114 | params[baseIndex+3] = msg.Timestamp.UnixMilli() 115 | params[baseIndex+4] = msg.editTimestampVal() 116 | params[baseIndex+5] = msg.ThreadID 117 | params[baseIndex+6] = msg.MXID 118 | params[baseIndex+7] = msg.SenderMXID.String() 119 | placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8) 120 | } 121 | _, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...) 122 | if err != nil { 123 | mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err) 124 | panic(err) 125 | } 126 | } 127 | 128 | type Message struct { 129 | db *Database 130 | log log.Logger 131 | 132 | DiscordID string 133 | AttachmentID string 134 | Channel PortalKey 135 | SenderID string 136 | Timestamp time.Time 137 | EditTimestamp time.Time 138 | ThreadID string 139 | 140 | MXID id.EventID 141 | SenderMXID id.UserID 142 | } 143 | 144 | func (m *Message) DiscordProtoChannelID() string { 145 | if m.ThreadID != "" { 146 | return m.ThreadID 147 | } else { 148 | return m.Channel.ChannelID 149 | } 150 | } 151 | 152 | func (m *Message) Scan(row dbutil.Scannable) *Message { 153 | var ts, editTS int64 154 | 155 | err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &editTS, &m.ThreadID, &m.MXID, &m.SenderMXID) 156 | if err != nil { 157 | if !errors.Is(err, sql.ErrNoRows) { 158 | m.log.Errorln("Database scan failed:", err) 159 | panic(err) 160 | } 161 | 162 | return nil 163 | } 164 | 165 | if ts != 0 { 166 | m.Timestamp = time.UnixMilli(ts).UTC() 167 | } 168 | if editTS != 0 { 169 | m.EditTimestamp = time.Unix(0, editTS).UTC() 170 | } 171 | 172 | return m 173 | } 174 | 175 | const messageInsertQuery = ` 176 | INSERT INTO message ( 177 | dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid 178 | ) 179 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 180 | ` 181 | 182 | var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1) 183 | 184 | type MessagePart struct { 185 | AttachmentID string 186 | MXID id.EventID 187 | } 188 | 189 | func (m *Message) editTimestampVal() int64 { 190 | if m.EditTimestamp.IsZero() { 191 | return 0 192 | } 193 | return m.EditTimestamp.UnixNano() 194 | } 195 | 196 | func (m *Message) MassInsertParts(msgs []MessagePart) { 197 | if len(msgs) == 0 { 198 | return 199 | } 200 | valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)" 201 | if m.db.Dialect == dbutil.SQLite { 202 | valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?") 203 | } 204 | params := make([]interface{}, 8+len(msgs)*2) 205 | placeholders := make([]string, len(msgs)) 206 | params[0] = m.DiscordID 207 | params[1] = m.Channel.ChannelID 208 | params[2] = m.Channel.Receiver 209 | params[3] = m.SenderID 210 | params[4] = m.Timestamp.UnixMilli() 211 | params[5] = m.editTimestampVal() 212 | params[6] = m.ThreadID 213 | params[7] = m.SenderMXID.String() 214 | for i, msg := range msgs { 215 | params[8+i*2] = msg.AttachmentID 216 | params[8+i*2+1] = msg.MXID 217 | placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2) 218 | } 219 | _, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...) 220 | if err != nil { 221 | m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err) 222 | panic(err) 223 | } 224 | } 225 | 226 | func (m *Message) Insert() { 227 | _, err := m.db.Exec(messageInsertQuery, 228 | m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID, 229 | m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String()) 230 | 231 | if err != nil { 232 | m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err) 233 | panic(err) 234 | } 235 | } 236 | 237 | const editUpdateQuery = ` 238 | UPDATE message 239 | SET dc_edit_timestamp=$1 240 | WHERE dcid=$2 AND dc_attachment_id=$3 AND dc_chan_id=$4 AND dc_chan_receiver=$5 AND dc_edit_timestamp<$1 241 | ` 242 | 243 | func (m *Message) UpdateEditTimestamp(ts time.Time) { 244 | _, err := m.db.Exec(editUpdateQuery, ts.UnixNano(), m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver) 245 | if err != nil { 246 | m.log.Warnfln("Failed to update edit timestamp of %s@%s: %v", m.DiscordID, m.Channel, err) 247 | panic(err) 248 | } 249 | } 250 | 251 | func (m *Message) Delete() { 252 | query := "DELETE FROM message WHERE dcid=$1 AND dc_chan_id=$2 AND dc_chan_receiver=$3 AND dc_attachment_id=$4" 253 | _, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID) 254 | if err != nil { 255 | m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err) 256 | panic(err) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /database/portal.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/bwmarrin/discordgo" 7 | "go.mau.fi/util/dbutil" 8 | log "maunium.net/go/maulogger/v2" 9 | "maunium.net/go/mautrix/id" 10 | ) 11 | 12 | // language=postgresql 13 | const ( 14 | portalSelect = ` 15 | SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, 16 | plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set, 17 | encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret 18 | FROM portal 19 | ` 20 | ) 21 | 22 | type PortalKey struct { 23 | ChannelID string 24 | Receiver string 25 | } 26 | 27 | func NewPortalKey(channelID, receiver string) PortalKey { 28 | return PortalKey{ 29 | ChannelID: channelID, 30 | Receiver: receiver, 31 | } 32 | } 33 | 34 | func (key PortalKey) String() string { 35 | if key.Receiver == "" { 36 | return key.ChannelID 37 | } 38 | return key.ChannelID + "-" + key.Receiver 39 | } 40 | 41 | type PortalQuery struct { 42 | db *Database 43 | log log.Logger 44 | } 45 | 46 | func (pq *PortalQuery) New() *Portal { 47 | return &Portal{ 48 | db: pq.db, 49 | log: pq.log, 50 | } 51 | } 52 | 53 | func (pq *PortalQuery) GetAll() []*Portal { 54 | return pq.getAll(portalSelect) 55 | } 56 | 57 | func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal { 58 | return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID) 59 | } 60 | 61 | func (pq *PortalQuery) GetByID(key PortalKey) *Portal { 62 | return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver) 63 | } 64 | 65 | func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal { 66 | return pq.get(portalSelect+" WHERE mxid=$1", mxid) 67 | } 68 | 69 | func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal { 70 | return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM) 71 | } 72 | 73 | func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal { 74 | return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM) 75 | } 76 | 77 | func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal { 78 | query := portalSelect + " portal WHERE receiver=$1 AND type=$2;" 79 | 80 | return pq.getAll(query, receiver, discordgo.ChannelTypeDM) 81 | } 82 | 83 | func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal { 84 | rows, err := pq.db.Query(query, args...) 85 | if err != nil || rows == nil { 86 | return nil 87 | } 88 | defer rows.Close() 89 | 90 | var portals []*Portal 91 | for rows.Next() { 92 | portals = append(portals, pq.New().Scan(rows)) 93 | } 94 | 95 | return portals 96 | } 97 | 98 | func (pq *PortalQuery) get(query string, args ...interface{}) *Portal { 99 | return pq.New().Scan(pq.db.QueryRow(query, args...)) 100 | } 101 | 102 | type Portal struct { 103 | db *Database 104 | log log.Logger 105 | 106 | Key PortalKey 107 | Type discordgo.ChannelType 108 | OtherUserID string 109 | ParentID string 110 | GuildID string 111 | 112 | MXID id.RoomID 113 | 114 | PlainName string 115 | Name string 116 | NameSet bool 117 | FriendNick bool 118 | Topic string 119 | TopicSet bool 120 | Avatar string 121 | AvatarURL id.ContentURI 122 | AvatarSet bool 123 | Encrypted bool 124 | InSpace id.RoomID 125 | 126 | FirstEventID id.EventID 127 | 128 | RelayWebhookID string 129 | RelayWebhookSecret string 130 | } 131 | 132 | func (p *Portal) Scan(row dbutil.Scannable) *Portal { 133 | var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString 134 | var chanType int32 135 | var avatarURL string 136 | 137 | err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID, 138 | &mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet, 139 | &p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret) 140 | 141 | if err != nil { 142 | if err != sql.ErrNoRows { 143 | p.log.Errorln("Database scan failed:", err) 144 | panic(err) 145 | } 146 | 147 | return nil 148 | } 149 | 150 | p.MXID = id.RoomID(mxid.String) 151 | p.OtherUserID = otherUserID.String 152 | p.GuildID = guildID.String 153 | p.ParentID = parentID.String 154 | p.Type = discordgo.ChannelType(chanType) 155 | p.FirstEventID = id.EventID(firstEventID.String) 156 | p.AvatarURL, _ = id.ParseContentURI(avatarURL) 157 | p.RelayWebhookID = relayWebhookID.String 158 | p.RelayWebhookSecret = relayWebhookSecret.String 159 | 160 | return p 161 | } 162 | 163 | func (p *Portal) Insert() { 164 | query := ` 165 | INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, 166 | plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set, 167 | encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret) 168 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) 169 | ` 170 | _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type, 171 | strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), 172 | p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, 173 | p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret)) 174 | 175 | if err != nil { 176 | p.log.Warnfln("Failed to insert %s: %v", p.Key, err) 177 | panic(err) 178 | } 179 | } 180 | 181 | func (p *Portal) Update() { 182 | query := ` 183 | UPDATE portal 184 | SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5, 185 | plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11, 186 | avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17, 187 | relay_webhook_id=$18, relay_webhook_secret=$19 188 | WHERE dcid=$20 AND receiver=$21 189 | ` 190 | _, err := p.db.Exec(query, 191 | p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), 192 | p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, 193 | p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.Encrypted, p.InSpace, p.FirstEventID.String(), 194 | strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret), 195 | p.Key.ChannelID, p.Key.Receiver) 196 | 197 | if err != nil { 198 | p.log.Warnfln("Failed to update %s: %v", p.Key, err) 199 | panic(err) 200 | } 201 | } 202 | 203 | func (p *Portal) Delete() { 204 | query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2" 205 | _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver) 206 | if err != nil { 207 | p.log.Warnfln("Failed to delete %s: %v", p.Key, err) 208 | panic(err) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /database/puppet.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "go.mau.fi/util/dbutil" 7 | log "maunium.net/go/maulogger/v2" 8 | "maunium.net/go/mautrix/id" 9 | ) 10 | 11 | const ( 12 | puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," + 13 | " contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" + 14 | " FROM puppet " 15 | ) 16 | 17 | type PuppetQuery struct { 18 | db *Database 19 | log log.Logger 20 | } 21 | 22 | func (pq *PuppetQuery) New() *Puppet { 23 | return &Puppet{ 24 | db: pq.db, 25 | log: pq.log, 26 | } 27 | } 28 | 29 | func (pq *PuppetQuery) Get(id string) *Puppet { 30 | return pq.get(puppetSelect+" WHERE id=$1", id) 31 | } 32 | 33 | func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet { 34 | return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid) 35 | } 36 | 37 | func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet { 38 | return pq.New().Scan(pq.db.QueryRow(query, args...)) 39 | } 40 | 41 | func (pq *PuppetQuery) GetAll() []*Puppet { 42 | return pq.getAll(puppetSelect) 43 | } 44 | 45 | func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet { 46 | return pq.getAll(puppetSelect + " WHERE custom_mxid<>''") 47 | } 48 | 49 | func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet { 50 | rows, err := pq.db.Query(query, args...) 51 | if err != nil || rows == nil { 52 | return nil 53 | } 54 | defer rows.Close() 55 | 56 | var puppets []*Puppet 57 | for rows.Next() { 58 | puppets = append(puppets, pq.New().Scan(rows)) 59 | } 60 | 61 | return puppets 62 | } 63 | 64 | type Puppet struct { 65 | db *Database 66 | log log.Logger 67 | 68 | ID string 69 | Name string 70 | NameSet bool 71 | Avatar string 72 | AvatarURL id.ContentURI 73 | AvatarSet bool 74 | 75 | ContactInfoSet bool 76 | 77 | GlobalName string 78 | Username string 79 | Discriminator string 80 | IsBot bool 81 | IsWebhook bool 82 | IsApplication bool 83 | 84 | CustomMXID id.UserID 85 | AccessToken string 86 | NextBatch string 87 | } 88 | 89 | func (p *Puppet) Scan(row dbutil.Scannable) *Puppet { 90 | var avatarURL string 91 | var customMXID, accessToken, nextBatch sql.NullString 92 | 93 | err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet, 94 | &p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch) 95 | 96 | if err != nil { 97 | if err != sql.ErrNoRows { 98 | p.log.Errorln("Database scan failed:", err) 99 | panic(err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | p.AvatarURL, _ = id.ParseContentURI(avatarURL) 106 | p.CustomMXID = id.UserID(customMXID.String) 107 | p.AccessToken = accessToken.String 108 | p.NextBatch = nextBatch.String 109 | 110 | return p 111 | } 112 | 113 | func (p *Puppet) Insert() { 114 | query := ` 115 | INSERT INTO puppet ( 116 | id, name, name_set, avatar, avatar_url, avatar_set, contact_info_set, 117 | global_name, username, discriminator, is_bot, is_webhook, is_application, 118 | custom_mxid, access_token, next_batch 119 | ) 120 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) 121 | ` 122 | _, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet, 123 | p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication, 124 | strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch)) 125 | 126 | if err != nil { 127 | p.log.Warnfln("Failed to insert %s: %v", p.ID, err) 128 | panic(err) 129 | } 130 | } 131 | 132 | func (p *Puppet) Update() { 133 | query := ` 134 | UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6, 135 | global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12, 136 | custom_mxid=$13, access_token=$14, next_batch=$15 137 | WHERE id=$16 138 | ` 139 | _, err := p.db.Exec( 140 | query, 141 | p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet, 142 | p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication, 143 | strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch), 144 | p.ID, 145 | ) 146 | 147 | if err != nil { 148 | p.log.Warnfln("Failed to update %s: %v", p.ID, err) 149 | panic(err) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /database/reaction.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | 7 | "go.mau.fi/util/dbutil" 8 | log "maunium.net/go/maulogger/v2" 9 | "maunium.net/go/mautrix/id" 10 | ) 11 | 12 | type ReactionQuery struct { 13 | db *Database 14 | log log.Logger 15 | } 16 | 17 | const ( 18 | reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction" 19 | ) 20 | 21 | func (rq *ReactionQuery) New() *Reaction { 22 | return &Reaction{ 23 | db: rq.db, 24 | log: rq.log, 25 | } 26 | } 27 | 28 | func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction { 29 | query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3" 30 | 31 | return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID) 32 | } 33 | 34 | func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction { 35 | rows, err := rq.db.Query(query, args...) 36 | if err != nil || rows == nil { 37 | return nil 38 | } 39 | 40 | var reactions []*Reaction 41 | for rows.Next() { 42 | reactions = append(reactions, rq.New().Scan(rows)) 43 | } 44 | 45 | return reactions 46 | } 47 | 48 | func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction { 49 | 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" 50 | 51 | return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName) 52 | } 53 | 54 | func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction { 55 | query := reactionSelect + " WHERE mxid=$1" 56 | 57 | return rq.get(query, mxid) 58 | } 59 | 60 | func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction { 61 | row := rq.db.QueryRow(query, args...) 62 | if row == nil { 63 | return nil 64 | } 65 | 66 | return rq.New().Scan(row) 67 | } 68 | 69 | type Reaction struct { 70 | db *Database 71 | log log.Logger 72 | 73 | Channel PortalKey 74 | MessageID string 75 | Sender string 76 | EmojiName string 77 | ThreadID string 78 | 79 | MXID id.EventID 80 | 81 | FirstAttachmentID string 82 | } 83 | 84 | func (r *Reaction) Scan(row dbutil.Scannable) *Reaction { 85 | err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID) 86 | if err != nil { 87 | if !errors.Is(err, sql.ErrNoRows) { 88 | r.log.Errorln("Database scan failed:", err) 89 | panic(err) 90 | } 91 | return nil 92 | } 93 | 94 | return r 95 | } 96 | 97 | func (r *Reaction) DiscordProtoChannelID() string { 98 | if r.ThreadID != "" { 99 | return r.ThreadID 100 | } else { 101 | return r.Channel.ChannelID 102 | } 103 | } 104 | 105 | func (r *Reaction) Insert() { 106 | query := ` 107 | 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) 108 | VALUES($1, $2, $3, $4, $5, $6, $7, $8) 109 | ` 110 | _, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID) 111 | if err != nil { 112 | r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err) 113 | panic(err) 114 | } 115 | } 116 | 117 | func (r *Reaction) Delete() { 118 | query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3" 119 | _, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName) 120 | if err != nil { 121 | r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err) 122 | panic(err) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /database/role.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | 7 | "github.com/bwmarrin/discordgo" 8 | "go.mau.fi/util/dbutil" 9 | log "maunium.net/go/maulogger/v2" 10 | ) 11 | 12 | type RoleQuery struct { 13 | db *Database 14 | log log.Logger 15 | } 16 | 17 | // language=postgresql 18 | const ( 19 | roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role" 20 | roleUpsert = ` 21 | INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions) 22 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 23 | ON CONFLICT (dc_guild_id, dcid) DO UPDATE 24 | SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed, 25 | hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions 26 | ` 27 | roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2" 28 | ) 29 | 30 | func (rq *RoleQuery) New() *Role { 31 | return &Role{ 32 | db: rq.db, 33 | log: rq.log, 34 | } 35 | } 36 | 37 | func (rq *RoleQuery) GetByID(guildID, dcid string) *Role { 38 | query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2" 39 | return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid)) 40 | } 41 | 42 | func (rq *RoleQuery) DeleteByID(guildID, dcid string) { 43 | _, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid) 44 | if err != nil { 45 | rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err) 46 | panic(err) 47 | } 48 | } 49 | 50 | func (rq *RoleQuery) GetAll(guildID string) []*Role { 51 | rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID) 52 | if err != nil { 53 | rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err) 54 | return nil 55 | } 56 | 57 | var roles []*Role 58 | for rows.Next() { 59 | role := rq.New().Scan(rows) 60 | if role != nil { 61 | roles = append(roles, role) 62 | } 63 | } 64 | 65 | return roles 66 | } 67 | 68 | type Role struct { 69 | db *Database 70 | log log.Logger 71 | 72 | GuildID string 73 | 74 | discordgo.Role 75 | } 76 | 77 | func (r *Role) Scan(row dbutil.Scannable) *Role { 78 | var icon sql.NullString 79 | err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions) 80 | if err != nil { 81 | if !errors.Is(err, sql.ErrNoRows) { 82 | r.log.Errorln("Database scan failed:", err) 83 | panic(err) 84 | } 85 | 86 | return nil 87 | } 88 | r.Icon = icon.String 89 | return r 90 | } 91 | 92 | func (r *Role) Upsert(txn dbutil.Execable) { 93 | if txn == nil { 94 | txn = r.db 95 | } 96 | _, 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) 97 | if err != nil { 98 | r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err) 99 | panic(err) 100 | } 101 | } 102 | 103 | func (r *Role) Delete(txn dbutil.Execable) { 104 | if txn == nil { 105 | txn = r.db 106 | } 107 | _, err := txn.Exec(roleDelete, r.GuildID, r.Icon) 108 | if err != nil { 109 | r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err) 110 | panic(err) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /database/thread.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | 7 | "go.mau.fi/util/dbutil" 8 | log "maunium.net/go/maulogger/v2" 9 | "maunium.net/go/mautrix/id" 10 | ) 11 | 12 | type ThreadQuery struct { 13 | db *Database 14 | log log.Logger 15 | } 16 | 17 | const ( 18 | threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread" 19 | ) 20 | 21 | func (tq *ThreadQuery) New() *Thread { 22 | return &Thread{ 23 | db: tq.db, 24 | log: tq.log, 25 | } 26 | } 27 | 28 | func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread { 29 | query := threadSelect + " WHERE dcid=$1" 30 | 31 | row := tq.db.QueryRow(query, discordID) 32 | if row == nil { 33 | return nil 34 | } 35 | 36 | return tq.New().Scan(row) 37 | } 38 | 39 | func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread { 40 | query := threadSelect + " WHERE root_msg_mxid=$1" 41 | 42 | row := tq.db.QueryRow(query, mxid) 43 | if row == nil { 44 | return nil 45 | } 46 | 47 | return tq.New().Scan(row) 48 | } 49 | 50 | func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread { 51 | query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1" 52 | 53 | row := tq.db.QueryRow(query, mxid) 54 | if row == nil { 55 | return nil 56 | } 57 | 58 | return tq.New().Scan(row) 59 | } 60 | 61 | type Thread struct { 62 | db *Database 63 | log log.Logger 64 | 65 | ID string 66 | ParentID string 67 | 68 | RootDiscordID string 69 | RootMXID id.EventID 70 | 71 | CreationNoticeMXID id.EventID 72 | } 73 | 74 | func (t *Thread) Scan(row dbutil.Scannable) *Thread { 75 | err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID) 76 | if err != nil { 77 | if !errors.Is(err, sql.ErrNoRows) { 78 | t.log.Errorln("Database scan failed:", err) 79 | panic(err) 80 | } 81 | return nil 82 | } 83 | return t 84 | } 85 | 86 | func (t *Thread) Insert() { 87 | query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)" 88 | _, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID) 89 | if err != nil { 90 | t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err) 91 | panic(err) 92 | } 93 | } 94 | 95 | func (t *Thread) Update() { 96 | query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1" 97 | _, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID) 98 | if err != nil { 99 | t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err) 100 | panic(err) 101 | } 102 | } 103 | 104 | func (t *Thread) Delete() { 105 | query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2" 106 | _, err := t.db.Exec(query, t.ID, t.ParentID) 107 | if err != nil { 108 | t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err) 109 | panic(err) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /database/upgrades/00-latest-revision.sql: -------------------------------------------------------------------------------- 1 | -- v0 -> v23 (compatible with v19+): Latest revision 2 | 3 | CREATE TABLE guild ( 4 | dcid TEXT PRIMARY KEY, 5 | mxid TEXT UNIQUE, 6 | plain_name TEXT NOT NULL, 7 | name TEXT NOT NULL, 8 | name_set BOOLEAN NOT NULL, 9 | avatar TEXT NOT NULL, 10 | avatar_url TEXT NOT NULL, 11 | avatar_set BOOLEAN NOT NULL, 12 | 13 | bridging_mode INTEGER NOT NULL 14 | ); 15 | 16 | CREATE TABLE portal ( 17 | dcid TEXT, 18 | receiver TEXT, 19 | other_user_id TEXT, 20 | type INTEGER NOT NULL, 21 | 22 | dc_guild_id TEXT, 23 | dc_parent_id TEXT, 24 | -- This is not accessed by the bridge, it's only used for the portal parent foreign key. 25 | -- Only guild channels have parents, but only DMs have a receiver field. 26 | dc_parent_receiver TEXT NOT NULL DEFAULT '', 27 | 28 | mxid TEXT UNIQUE, 29 | plain_name TEXT NOT NULL, 30 | name TEXT NOT NULL, 31 | name_set BOOLEAN NOT NULL, 32 | friend_nick BOOLEAN NOT NULL, 33 | topic TEXT NOT NULL, 34 | topic_set BOOLEAN NOT NULL, 35 | avatar TEXT NOT NULL, 36 | avatar_url TEXT NOT NULL, 37 | avatar_set BOOLEAN NOT NULL, 38 | encrypted BOOLEAN NOT NULL, 39 | in_space TEXT NOT NULL, 40 | 41 | first_event_id TEXT NOT NULL, 42 | 43 | relay_webhook_id TEXT, 44 | relay_webhook_secret TEXT, 45 | 46 | PRIMARY KEY (dcid, receiver), 47 | CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE, 48 | CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE 49 | ); 50 | 51 | CREATE TABLE thread ( 52 | dcid TEXT PRIMARY KEY, 53 | parent_chan_id TEXT NOT NULL, 54 | root_msg_dcid TEXT NOT NULL, 55 | root_msg_mxid TEXT NOT NULL, 56 | creation_notice_mxid TEXT NOT NULL, 57 | -- This is also not accessed by the bridge. 58 | receiver TEXT NOT NULL DEFAULT '', 59 | 60 | CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE 61 | ); 62 | 63 | CREATE TABLE puppet ( 64 | id TEXT PRIMARY KEY, 65 | 66 | name TEXT NOT NULL, 67 | name_set BOOLEAN NOT NULL DEFAULT false, 68 | avatar TEXT NOT NULL, 69 | avatar_url TEXT NOT NULL, 70 | avatar_set BOOLEAN NOT NULL DEFAULT false, 71 | 72 | contact_info_set BOOLEAN NOT NULL DEFAULT false, 73 | 74 | global_name TEXT NOT NULL DEFAULT '', 75 | username TEXT NOT NULL DEFAULT '', 76 | discriminator TEXT NOT NULL DEFAULT '', 77 | is_bot BOOLEAN NOT NULL DEFAULT false, 78 | is_webhook BOOLEAN NOT NULL DEFAULT false, 79 | is_application BOOLEAN NOT NULL DEFAULT false, 80 | 81 | custom_mxid TEXT, 82 | access_token TEXT, 83 | next_batch TEXT 84 | ); 85 | 86 | CREATE TABLE "user" ( 87 | mxid TEXT PRIMARY KEY, 88 | dcid TEXT UNIQUE, 89 | 90 | discord_token TEXT, 91 | management_room TEXT, 92 | space_room TEXT, 93 | dm_space_room TEXT, 94 | 95 | read_state_version INTEGER NOT NULL DEFAULT 0 96 | ); 97 | 98 | CREATE TABLE user_portal ( 99 | discord_id TEXT, 100 | user_mxid TEXT, 101 | type TEXT NOT NULL, 102 | in_space BOOLEAN NOT NULL, 103 | timestamp BIGINT NOT NULL, 104 | 105 | PRIMARY KEY (discord_id, user_mxid), 106 | CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE 107 | ); 108 | 109 | CREATE TABLE message ( 110 | dcid TEXT, 111 | dc_attachment_id TEXT, 112 | dc_chan_id TEXT, 113 | dc_chan_receiver TEXT, 114 | dc_sender TEXT NOT NULL, 115 | timestamp BIGINT NOT NULL, 116 | dc_edit_timestamp BIGINT NOT NULL, 117 | dc_thread_id TEXT NOT NULL, 118 | 119 | mxid TEXT NOT NULL UNIQUE, 120 | sender_mxid TEXT NOT NULL DEFAULT '', 121 | 122 | PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver), 123 | CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE 124 | ); 125 | 126 | CREATE TABLE reaction ( 127 | dc_chan_id TEXT, 128 | dc_chan_receiver TEXT, 129 | dc_msg_id TEXT, 130 | dc_sender TEXT, 131 | dc_emoji_name TEXT, 132 | dc_thread_id TEXT NOT NULL, 133 | 134 | dc_first_attachment_id TEXT NOT NULL, 135 | 136 | mxid TEXT NOT NULL UNIQUE, 137 | 138 | PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), 139 | 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 140 | ); 141 | 142 | CREATE TABLE role ( 143 | dc_guild_id TEXT, 144 | dcid TEXT, 145 | 146 | name TEXT NOT NULL, 147 | icon TEXT, 148 | 149 | mentionable BOOLEAN NOT NULL, 150 | managed BOOLEAN NOT NULL, 151 | hoist BOOLEAN NOT NULL, 152 | 153 | color INTEGER NOT NULL, 154 | position INTEGER NOT NULL, 155 | permissions BIGINT NOT NULL, 156 | 157 | PRIMARY KEY (dc_guild_id, dcid), 158 | CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE 159 | ); 160 | 161 | CREATE TABLE discord_file ( 162 | url TEXT, 163 | encrypted BOOLEAN, 164 | mxc TEXT NOT NULL, 165 | 166 | id TEXT, 167 | emoji_name TEXT, 168 | 169 | size BIGINT NOT NULL, 170 | width INTEGER, 171 | height INTEGER, 172 | mime_type TEXT NOT NULL, 173 | decryption_info jsonb, 174 | timestamp BIGINT NOT NULL, 175 | 176 | PRIMARY KEY (url, encrypted) 177 | ); 178 | 179 | CREATE INDEX discord_file_mxc_idx ON discord_file (mxc); 180 | -------------------------------------------------------------------------------- /database/upgrades/02-column-renames.sql: -------------------------------------------------------------------------------- 1 | -- v2: Rename columns in message-related tables 2 | 3 | ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id; 4 | ALTER TABLE portal RENAME COLUMN channel_id TO dcid; 5 | 6 | ALTER TABLE "user" RENAME COLUMN id TO dcid; 7 | 8 | ALTER TABLE puppet DROP COLUMN enable_presence; 9 | ALTER TABLE puppet DROP COLUMN enable_receipts; 10 | 11 | DROP TABLE message; 12 | DROP TABLE reaction; 13 | DROP TABLE attachment; 14 | 15 | CREATE TABLE message ( 16 | dcid TEXT, 17 | dc_chan_id TEXT, 18 | dc_chan_receiver TEXT, 19 | dc_sender TEXT NOT NULL, 20 | timestamp BIGINT NOT NULL, 21 | 22 | mxid TEXT NOT NULL UNIQUE, 23 | 24 | PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver), 25 | CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE 26 | ); 27 | 28 | CREATE TABLE reaction ( 29 | dc_chan_id TEXT, 30 | dc_chan_receiver TEXT, 31 | dc_msg_id TEXT, 32 | dc_sender TEXT, 33 | dc_emoji_name TEXT, 34 | 35 | mxid TEXT NOT NULL UNIQUE, 36 | 37 | PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), 38 | 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 39 | ); 40 | 41 | CREATE TABLE attachment ( 42 | dcid TEXT, 43 | dc_msg_id TEXT, 44 | dc_chan_id TEXT, 45 | dc_chan_receiver TEXT, 46 | 47 | mxid TEXT NOT NULL UNIQUE, 48 | 49 | PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver), 50 | 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 51 | ); 52 | 53 | UPDATE portal SET receiver='' WHERE type<>1; 54 | -------------------------------------------------------------------------------- /database/upgrades/03-spaces.sql: -------------------------------------------------------------------------------- 1 | -- v3: Store portal parent metadata for spaces 2 | DROP TABLE guild; 3 | 4 | CREATE TABLE guild ( 5 | dcid TEXT PRIMARY KEY, 6 | mxid TEXT UNIQUE, 7 | name TEXT NOT NULL, 8 | name_set BOOLEAN NOT NULL, 9 | avatar TEXT NOT NULL, 10 | avatar_url TEXT NOT NULL, 11 | avatar_set BOOLEAN NOT NULL, 12 | 13 | auto_bridge_channels BOOLEAN NOT NULL 14 | ); 15 | 16 | CREATE TABLE user_portal ( 17 | discord_id TEXT, 18 | user_mxid TEXT, 19 | type TEXT NOT NULL, 20 | in_space BOOLEAN NOT NULL, 21 | timestamp BIGINT NOT NULL, 22 | 23 | PRIMARY KEY (discord_id, user_mxid), 24 | CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE 25 | ); 26 | 27 | ALTER TABLE portal ADD COLUMN dc_guild_id TEXT; 28 | ALTER TABLE portal ADD COLUMN dc_parent_id TEXT; 29 | ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT ''; 30 | ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE; 31 | ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE; 32 | DELETE FROM portal WHERE type IS NULL; 33 | -- only: postgres 34 | ALTER TABLE portal ALTER COLUMN type SET NOT NULL; 35 | 36 | ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT ''; 37 | ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false; 38 | ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false; 39 | ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false; 40 | -- only: postgres for next 5 lines 41 | ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT; 42 | ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT; 43 | ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT; 44 | ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT; 45 | ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT; 46 | 47 | ALTER TABLE puppet RENAME COLUMN display_name TO name; 48 | ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false; 49 | ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false; 50 | -- only: postgres for next 2 lines 51 | ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT; 52 | ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT; 53 | 54 | ALTER TABLE "user" ADD COLUMN space_room TEXT; 55 | ALTER TABLE "user" ADD COLUMN dm_space_room TEXT; 56 | ALTER TABLE "user" RENAME COLUMN token TO discord_token; 57 | 58 | UPDATE message SET timestamp=timestamp*1000; 59 | 60 | CREATE TABLE thread ( 61 | dcid TEXT PRIMARY KEY, 62 | parent_chan_id TEXT NOT NULL, 63 | root_msg_dcid TEXT NOT NULL, 64 | root_msg_mxid TEXT NOT NULL, 65 | -- This is also not accessed by the bridge. 66 | receiver TEXT NOT NULL DEFAULT '', 67 | 68 | CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE 69 | ); 70 | 71 | ALTER TABLE message ADD COLUMN dc_thread_id TEXT; 72 | ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT; 73 | ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT; 74 | -------------------------------------------------------------------------------- /database/upgrades/04-attachment-fix.postgres.sql: -------------------------------------------------------------------------------- 1 | -- v4: Fix storing attachments 2 | ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; 3 | ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey; 4 | ALTER TABLE message DROP CONSTRAINT message_pkey; 5 | ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT ''; 6 | ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0; 7 | ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT; 8 | ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT; 9 | ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver); 10 | INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) 11 | 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 12 | FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid; 13 | DROP TABLE attachment; 14 | 15 | ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT ''; 16 | ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT; 17 | ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0; 18 | ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey 19 | FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) 20 | REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver); 21 | -------------------------------------------------------------------------------- /database/upgrades/04-attachment-fix.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- v4: Fix storing attachments 2 | CREATE TABLE new_message ( 3 | dcid TEXT, 4 | dc_attachment_id TEXT, 5 | dc_edit_index INTEGER, 6 | dc_chan_id TEXT, 7 | dc_chan_receiver TEXT, 8 | dc_sender TEXT NOT NULL, 9 | timestamp BIGINT NOT NULL, 10 | dc_thread_id TEXT, 11 | 12 | mxid TEXT NOT NULL UNIQUE, 13 | 14 | PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver), 15 | CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE 16 | ); 17 | INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) 18 | SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message; 19 | INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid) 20 | 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 21 | FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid; 22 | DROP TABLE attachment; 23 | DROP TABLE message; 24 | ALTER TABLE new_message RENAME TO message; 25 | 26 | CREATE TABLE new_reaction ( 27 | dc_chan_id TEXT, 28 | dc_chan_receiver TEXT, 29 | dc_msg_id TEXT, 30 | dc_sender TEXT, 31 | dc_emoji_name TEXT, 32 | dc_thread_id TEXT, 33 | 34 | dc_first_attachment_id TEXT NOT NULL, 35 | _dc_first_edit_index INTEGER NOT NULL DEFAULT 0, 36 | 37 | mxid TEXT NOT NULL UNIQUE, 38 | 39 | PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), 40 | 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 41 | ); 42 | 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) 43 | SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction; 44 | DROP TABLE reaction; 45 | ALTER TABLE new_reaction RENAME TO reaction; 46 | -------------------------------------------------------------------------------- /database/upgrades/05-reaction-fkey-fix.sql: -------------------------------------------------------------------------------- 1 | -- v5: Fix foreign key broken in v4 2 | -- only: postgres 3 | 4 | ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; 5 | ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey 6 | FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) 7 | REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) 8 | ON DELETE CASCADE; 9 | -------------------------------------------------------------------------------- /database/upgrades/06-user-read-state-version.sql: -------------------------------------------------------------------------------- 1 | -- v6: Store user read state version 2 | ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /database/upgrades/07-store-role-info.sql: -------------------------------------------------------------------------------- 1 | -- v7: Store role info 2 | CREATE TABLE role ( 3 | dc_guild_id TEXT, 4 | dcid TEXT, 5 | 6 | name TEXT NOT NULL, 7 | icon TEXT, 8 | 9 | mentionable BOOLEAN NOT NULL, 10 | managed BOOLEAN NOT NULL, 11 | hoist BOOLEAN NOT NULL, 12 | 13 | color INTEGER NOT NULL, 14 | position INTEGER NOT NULL, 15 | permissions BIGINT NOT NULL, 16 | 17 | PRIMARY KEY (dc_guild_id, dcid), 18 | CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE 19 | ); 20 | -------------------------------------------------------------------------------- /database/upgrades/08-channel-plain-name.sql: -------------------------------------------------------------------------------- 1 | -- v8: Store plain name of channels and guilds 2 | ALTER TABLE guild ADD COLUMN plain_name TEXT; 3 | ALTER TABLE portal ADD COLUMN plain_name TEXT; 4 | UPDATE guild SET plain_name=name; 5 | UPDATE portal SET plain_name=name; 6 | UPDATE portal SET plain_name='' WHERE type=1; 7 | -- only: postgres for next 2 lines 8 | ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL; 9 | ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL; 10 | -------------------------------------------------------------------------------- /database/upgrades/09-more-thread-data.sql: -------------------------------------------------------------------------------- 1 | -- v9: Store more info for proper thread support 2 | ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT ''; 3 | UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL; 4 | UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL; 5 | 6 | -- only: postgres for next 3 lines 7 | ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT; 8 | ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL; 9 | ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL; 10 | -------------------------------------------------------------------------------- /database/upgrades/10-remove-broken-double-puppets.sql: -------------------------------------------------------------------------------- 1 | -- v10: Remove double puppet ghosts added while there was a bug in the bridge 2 | DELETE FROM puppet WHERE id=''; 3 | -------------------------------------------------------------------------------- /database/upgrades/11-cache-reuploaded-files.sql: -------------------------------------------------------------------------------- 1 | -- v11: Cache files copied from Discord to Matrix 2 | CREATE TABLE discord_file ( 3 | url TEXT, 4 | encrypted BOOLEAN, 5 | 6 | id TEXT, 7 | mxc TEXT NOT NULL, 8 | 9 | size BIGINT NOT NULL, 10 | width INTEGER, 11 | height INTEGER, 12 | 13 | decryption_info jsonb, 14 | 15 | timestamp BIGINT NOT NULL, 16 | 17 | PRIMARY KEY (url, encrypted) 18 | ); 19 | -------------------------------------------------------------------------------- /database/upgrades/12-file-cache-mime-type.sql: -------------------------------------------------------------------------------- 1 | -- v12: Cache mime type for reuploaded files 2 | ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT ''; 3 | -- only: postgres 4 | ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT; 5 | -------------------------------------------------------------------------------- /database/upgrades/13-merge-emoji-and-file.postgres.sql: -------------------------------------------------------------------------------- 1 | -- v13: Merge tables used for cached custom emojis and attachments 2 | ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc); 3 | ALTER TABLE discord_file ADD COLUMN emoji_name TEXT; 4 | DROP TABLE emoji; 5 | -------------------------------------------------------------------------------- /database/upgrades/13-merge-emoji-and-file.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- v13: Merge tables used for cached custom emojis and attachments 2 | CREATE TABLE new_discord_file ( 3 | url TEXT, 4 | encrypted BOOLEAN, 5 | mxc TEXT NOT NULL UNIQUE, 6 | 7 | id TEXT, 8 | emoji_name TEXT, 9 | 10 | size BIGINT NOT NULL, 11 | width INTEGER, 12 | height INTEGER, 13 | mime_type TEXT NOT NULL, 14 | decryption_info jsonb, 15 | timestamp BIGINT NOT NULL, 16 | 17 | PRIMARY KEY (url, encrypted) 18 | ); 19 | 20 | INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp) 21 | SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file; 22 | 23 | DROP TABLE discord_file; 24 | ALTER TABLE new_discord_file RENAME TO discord_file; 25 | -------------------------------------------------------------------------------- /database/upgrades/14-guild-bridging-mode.sql: -------------------------------------------------------------------------------- 1 | -- v14: Add more modes of bridging guilds 2 | ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0; 3 | UPDATE guild SET bridging_mode=2 WHERE mxid<>''; 4 | UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true; 5 | ALTER TABLE guild DROP COLUMN auto_bridge_channels; 6 | -- only: postgres 7 | ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT; 8 | -------------------------------------------------------------------------------- /database/upgrades/15-portal-relay-webhook.sql: -------------------------------------------------------------------------------- 1 | -- v15: Store relay webhook URL for portals 2 | ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT; 3 | ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT; 4 | -------------------------------------------------------------------------------- /database/upgrades/16-add-contact-info.sql: -------------------------------------------------------------------------------- 1 | -- v16: Store whether custom contact info has been set for the puppet 2 | 3 | ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false; 4 | -------------------------------------------------------------------------------- /database/upgrades/17-dm-portal-friend-nick.sql: -------------------------------------------------------------------------------- 1 | -- v17: Store whether DM portal name is a friend nickname 2 | ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /database/upgrades/18-extra-ghost-metadata.sql: -------------------------------------------------------------------------------- 1 | -- v18 (compatible with v15+): Store additional metadata for ghosts 2 | ALTER TABLE puppet ADD COLUMN username TEXT NOT NULL DEFAULT ''; 3 | ALTER TABLE puppet ADD COLUMN discriminator TEXT NOT NULL DEFAULT ''; 4 | ALTER TABLE puppet ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false; 5 | -------------------------------------------------------------------------------- /database/upgrades/19-message-edit-ts.postgres.sql: -------------------------------------------------------------------------------- 1 | -- v19: Replace dc_edit_index with dc_edit_timestamp 2 | -- transaction: off 3 | BEGIN; 4 | 5 | ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; 6 | ALTER TABLE message DROP CONSTRAINT message_pkey; 7 | ALTER TABLE message DROP COLUMN dc_edit_index; 8 | ALTER TABLE reaction DROP COLUMN _dc_first_edit_index; 9 | ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver); 10 | 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; 11 | 12 | ALTER TABLE message ADD COLUMN dc_edit_timestamp BIGINT NOT NULL DEFAULT 0; 13 | ALTER TABLE message ALTER COLUMN dc_edit_timestamp DROP DEFAULT; 14 | 15 | COMMIT; 16 | -------------------------------------------------------------------------------- /database/upgrades/19-message-edit-ts.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- v19: Replace dc_edit_index with dc_edit_timestamp 2 | -- transaction: off 3 | PRAGMA foreign_keys = OFF; 4 | BEGIN; 5 | 6 | CREATE TABLE message_new ( 7 | dcid TEXT, 8 | dc_attachment_id TEXT, 9 | dc_chan_id TEXT, 10 | dc_chan_receiver TEXT, 11 | dc_sender TEXT NOT NULL, 12 | timestamp BIGINT NOT NULL, 13 | dc_edit_timestamp BIGINT NOT NULL, 14 | dc_thread_id TEXT NOT NULL, 15 | 16 | mxid TEXT NOT NULL UNIQUE, 17 | 18 | PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver), 19 | CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE 20 | ); 21 | INSERT INTO message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid) 22 | SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message; 23 | DROP TABLE message; 24 | ALTER TABLE message_new RENAME TO message; 25 | 26 | CREATE TABLE reaction_new ( 27 | dc_chan_id TEXT, 28 | dc_chan_receiver TEXT, 29 | dc_msg_id TEXT, 30 | dc_sender TEXT, 31 | dc_emoji_name TEXT, 32 | dc_thread_id TEXT NOT NULL, 33 | 34 | dc_first_attachment_id TEXT NOT NULL, 35 | 36 | mxid TEXT NOT NULL UNIQUE, 37 | 38 | PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name), 39 | 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 40 | ); 41 | 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) 42 | 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; 43 | DROP TABLE reaction; 44 | ALTER TABLE reaction_new RENAME TO reaction; 45 | 46 | PRAGMA foreign_key_check; 47 | COMMIT; 48 | PRAGMA foreign_keys = ON; 49 | -------------------------------------------------------------------------------- /database/upgrades/20-message-sender-mxid.sql: -------------------------------------------------------------------------------- 1 | -- v20 (compatible with v19+): Store message sender Matrix user ID 2 | ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /database/upgrades/21-more-puppet-info.sql: -------------------------------------------------------------------------------- 1 | -- v21 (compatible with v19+): Store global displayname and is webhook status for puppets 2 | ALTER TABLE puppet ADD COLUMN global_name TEXT NOT NULL DEFAULT ''; 3 | ALTER TABLE puppet ADD COLUMN is_webhook BOOLEAN NOT NULL DEFAULT false; 4 | -------------------------------------------------------------------------------- /database/upgrades/22-file-cache-duplicate-mxc.sql: -------------------------------------------------------------------------------- 1 | -- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache 2 | CREATE TABLE new_discord_file ( 3 | url TEXT, 4 | encrypted BOOLEAN, 5 | mxc TEXT NOT NULL, 6 | 7 | id TEXT, 8 | emoji_name TEXT, 9 | 10 | size BIGINT NOT NULL, 11 | width INTEGER, 12 | height INTEGER, 13 | mime_type TEXT NOT NULL, 14 | decryption_info jsonb, 15 | timestamp BIGINT NOT NULL, 16 | 17 | PRIMARY KEY (url, encrypted) 18 | ); 19 | 20 | INSERT INTO new_discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp) 21 | SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file; 22 | 23 | DROP TABLE discord_file; 24 | ALTER TABLE new_discord_file RENAME TO discord_file; 25 | 26 | CREATE INDEX discord_file_mxc_idx ON discord_file (mxc); 27 | -------------------------------------------------------------------------------- /database/upgrades/23-puppet-is-application.sql: -------------------------------------------------------------------------------- 1 | -- v23 (compatible with v19+): Store is application status for puppets 2 | ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /database/upgrades/upgrades.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package upgrades 18 | 19 | import ( 20 | "embed" 21 | 22 | "go.mau.fi/util/dbutil" 23 | ) 24 | 25 | var Table dbutil.UpgradeTable 26 | 27 | //go:embed *.sql 28 | var rawUpgrades embed.FS 29 | 30 | func init() { 31 | Table.RegisterFS(rawUpgrades) 32 | } 33 | -------------------------------------------------------------------------------- /database/user.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "go.mau.fi/util/dbutil" 7 | log "maunium.net/go/maulogger/v2" 8 | "maunium.net/go/mautrix/id" 9 | ) 10 | 11 | type UserQuery struct { 12 | db *Database 13 | log log.Logger 14 | } 15 | 16 | func (uq *UserQuery) New() *User { 17 | return &User{ 18 | db: uq.db, 19 | log: uq.log, 20 | } 21 | } 22 | 23 | func (uq *UserQuery) GetByMXID(userID id.UserID) *User { 24 | query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1` 25 | return uq.New().Scan(uq.db.QueryRow(query, userID)) 26 | } 27 | 28 | func (uq *UserQuery) GetByID(id string) *User { 29 | query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1` 30 | return uq.New().Scan(uq.db.QueryRow(query, id)) 31 | } 32 | 33 | func (uq *UserQuery) GetAllWithToken() []*User { 34 | query := ` 35 | SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version 36 | FROM "user" WHERE discord_token IS NOT NULL 37 | ` 38 | rows, err := uq.db.Query(query) 39 | if err != nil || rows == nil { 40 | return nil 41 | } 42 | 43 | var users []*User 44 | for rows.Next() { 45 | user := uq.New().Scan(rows) 46 | if user != nil { 47 | users = append(users, user) 48 | } 49 | } 50 | return users 51 | } 52 | 53 | type User struct { 54 | db *Database 55 | log log.Logger 56 | 57 | MXID id.UserID 58 | DiscordID string 59 | DiscordToken string 60 | ManagementRoom id.RoomID 61 | SpaceRoom id.RoomID 62 | DMSpaceRoom id.RoomID 63 | 64 | ReadStateVersion int 65 | } 66 | 67 | func (u *User) Scan(row dbutil.Scannable) *User { 68 | var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString 69 | err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion) 70 | if err != nil { 71 | if err != sql.ErrNoRows { 72 | u.log.Errorln("Database scan failed:", err) 73 | panic(err) 74 | } 75 | return nil 76 | } 77 | u.DiscordID = discordID.String 78 | u.DiscordToken = discordToken.String 79 | u.ManagementRoom = id.RoomID(managementRoom.String) 80 | u.SpaceRoom = id.RoomID(spaceRoom.String) 81 | u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String) 82 | return u 83 | } 84 | 85 | func (u *User) Insert() { 86 | 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)` 87 | _, 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) 88 | if err != nil { 89 | u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) 90 | panic(err) 91 | } 92 | } 93 | 94 | func (u *User) Update() { 95 | 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` 96 | _, 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) 97 | if err != nil { 98 | u.log.Warnfln("Failed to update %q: %v", u.MXID, err) 99 | panic(err) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /database/userportal.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | 8 | "go.mau.fi/util/dbutil" 9 | log "maunium.net/go/maulogger/v2" 10 | "maunium.net/go/mautrix/id" 11 | ) 12 | 13 | const ( 14 | UserPortalTypeDM = "dm" 15 | UserPortalTypeGuild = "guild" 16 | UserPortalTypeThread = "thread" 17 | ) 18 | 19 | type UserPortal struct { 20 | DiscordID string 21 | Type string 22 | Timestamp time.Time 23 | InSpace bool 24 | } 25 | 26 | func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal { 27 | var ts int64 28 | err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace) 29 | if err != nil { 30 | l.Errorln("Error scanning user portal:", err) 31 | panic(err) 32 | } 33 | up.Timestamp = time.UnixMilli(ts).UTC() 34 | return &up 35 | } 36 | 37 | func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal { 38 | var ups []UserPortal 39 | for rows.Next() { 40 | up := UserPortal{}.Scan(u.log, rows) 41 | if up != nil { 42 | ups = append(ups, *up) 43 | } 44 | } 45 | return ups 46 | } 47 | 48 | func (db *Database) GetUsersInPortal(channelID string) []id.UserID { 49 | rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID) 50 | if err != nil { 51 | db.Portal.log.Errorln("Failed to get users in portal:", err) 52 | } 53 | var users []id.UserID 54 | for rows.Next() { 55 | var mxid id.UserID 56 | err = rows.Scan(&mxid) 57 | if err != nil { 58 | db.Portal.log.Errorln("Failed to scan user in portal:", err) 59 | } else { 60 | users = append(users, mxid) 61 | } 62 | } 63 | return users 64 | } 65 | 66 | func (u *User) GetPortals() []UserPortal { 67 | rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID) 68 | if err != nil { 69 | u.log.Errorln("Failed to get portals:", err) 70 | panic(err) 71 | } 72 | return u.scanUserPortals(rows) 73 | } 74 | 75 | func (u *User) IsInSpace(discordID string) (isIn bool) { 76 | query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2` 77 | err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn) 78 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 79 | u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err) 80 | panic(err) 81 | } 82 | return 83 | } 84 | 85 | func (u *User) IsInPortal(discordID string) (isIn bool) { 86 | query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)` 87 | err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn) 88 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 89 | u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err) 90 | panic(err) 91 | } 92 | return 93 | } 94 | 95 | func (u *User) MarkInPortal(portal UserPortal) { 96 | query := ` 97 | INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space) 98 | VALUES ($1, $2, $3, $4, $5) 99 | ON CONFLICT (discord_id, user_mxid) DO UPDATE 100 | SET timestamp=excluded.timestamp, in_space=excluded.in_space 101 | ` 102 | _, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace) 103 | if err != nil { 104 | u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err) 105 | panic(err) 106 | } 107 | } 108 | 109 | func (u *User) MarkNotInPortal(discordID string) { 110 | query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2` 111 | _, err := u.db.Exec(query, u.MXID, discordID) 112 | if err != nil { 113 | u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err) 114 | panic(err) 115 | } 116 | } 117 | 118 | func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) { 119 | query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2` 120 | err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers) 121 | if err != nil { 122 | u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err) 123 | panic(err) 124 | } 125 | return 126 | } 127 | 128 | func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal { 129 | query := ` 130 | DELETE FROM user_portal 131 | WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild') 132 | RETURNING discord_id, type, timestamp, in_space 133 | ` 134 | rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli()) 135 | if err != nil { 136 | u.log.Errorln("Failed to prune user guild list:", err) 137 | panic(err) 138 | } 139 | return u.scanUserPortals(rows) 140 | } 141 | -------------------------------------------------------------------------------- /directmedia_id.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "bytes" 21 | "crypto/hmac" 22 | "crypto/sha256" 23 | "encoding/base64" 24 | "encoding/binary" 25 | "errors" 26 | "fmt" 27 | "io" 28 | ) 29 | 30 | const MediaIDPrefix = "\U0001F408DISCORD" 31 | const MediaIDVersion = 1 32 | 33 | type MediaIDClass uint8 34 | 35 | const ( 36 | MediaIDClassAttachment MediaIDClass = 1 37 | MediaIDClassEmoji MediaIDClass = 2 38 | MediaIDClassSticker MediaIDClass = 3 39 | MediaIDClassUserAvatar MediaIDClass = 4 40 | MediaIDClassGuildMemberAvatar MediaIDClass = 5 41 | ) 42 | 43 | type MediaIDData interface { 44 | Write(to io.Writer) 45 | Read(from io.Reader) error 46 | Size() int 47 | Wrap() *MediaID 48 | } 49 | 50 | type MediaID struct { 51 | Version uint8 52 | TypeClass MediaIDClass 53 | Data MediaIDData 54 | } 55 | 56 | func ParseMediaID(id string, key [32]byte) (*MediaID, error) { 57 | data, err := base64.RawURLEncoding.DecodeString(id) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to decode base64: %w", err) 60 | } 61 | hasher := hmac.New(sha256.New, key[:]) 62 | checksum := data[len(data)-TruncatedHashLength:] 63 | data = data[:len(data)-TruncatedHashLength] 64 | hasher.Write(data) 65 | if !hmac.Equal(checksum, hasher.Sum(nil)[:TruncatedHashLength]) { 66 | return nil, ErrMediaIDChecksumMismatch 67 | } 68 | mid := &MediaID{} 69 | err = mid.Read(bytes.NewReader(data)) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to parse media ID: %w", err) 72 | } 73 | return mid, nil 74 | } 75 | 76 | const TruncatedHashLength = 16 77 | 78 | func (mid *MediaID) SignedString(key [32]byte) string { 79 | buf := bytes.NewBuffer(make([]byte, 0, mid.Size())) 80 | mid.Write(buf) 81 | hasher := hmac.New(sha256.New, key[:]) 82 | hasher.Write(buf.Bytes()) 83 | buf.Write(hasher.Sum(nil)[:TruncatedHashLength]) 84 | return base64.RawURLEncoding.EncodeToString(buf.Bytes()) 85 | } 86 | 87 | func (mid *MediaID) Write(to io.Writer) { 88 | _, _ = to.Write([]byte(MediaIDPrefix)) 89 | _ = binary.Write(to, binary.BigEndian, mid.Version) 90 | _ = binary.Write(to, binary.BigEndian, mid.TypeClass) 91 | mid.Data.Write(to) 92 | } 93 | 94 | func (mid *MediaID) Size() int { 95 | return len(MediaIDPrefix) + 2 + mid.Data.Size() + TruncatedHashLength 96 | } 97 | 98 | var ( 99 | ErrInvalidMediaID = errors.New("invalid media ID") 100 | ErrMediaIDChecksumMismatch = errors.New("invalid checksum in media ID") 101 | ErrUnsupportedMediaID = errors.New("unsupported media ID") 102 | ) 103 | 104 | func (mid *MediaID) Read(from io.Reader) error { 105 | prefix := make([]byte, len(MediaIDPrefix)) 106 | _, err := io.ReadFull(from, prefix) 107 | if err != nil || !bytes.Equal(prefix, []byte(MediaIDPrefix)) { 108 | return fmt.Errorf("%w: prefix not found", ErrInvalidMediaID) 109 | } 110 | versionAndClass := make([]byte, 2) 111 | _, err = io.ReadFull(from, versionAndClass) 112 | if err != nil { 113 | return fmt.Errorf("%w: version and class not found", ErrInvalidMediaID) 114 | } else if versionAndClass[0] != MediaIDVersion { 115 | return fmt.Errorf("%w: unknown version %d", ErrUnsupportedMediaID, versionAndClass[0]) 116 | } 117 | switch MediaIDClass(versionAndClass[1]) { 118 | case MediaIDClassAttachment: 119 | mid.Data = &AttachmentMediaData{} 120 | case MediaIDClassEmoji: 121 | mid.Data = &EmojiMediaData{} 122 | case MediaIDClassSticker: 123 | mid.Data = &StickerMediaData{} 124 | case MediaIDClassUserAvatar: 125 | mid.Data = &UserAvatarMediaData{} 126 | case MediaIDClassGuildMemberAvatar: 127 | mid.Data = &GuildMemberAvatarMediaData{} 128 | default: 129 | return fmt.Errorf("%w: unrecognized type class %d", ErrUnsupportedMediaID, versionAndClass[1]) 130 | } 131 | err = mid.Data.Read(from) 132 | if err != nil { 133 | return fmt.Errorf("failed to parse media ID data: %w", err) 134 | } 135 | return nil 136 | } 137 | 138 | type AttachmentMediaData struct { 139 | ChannelID uint64 140 | MessageID uint64 141 | AttachmentID uint64 142 | } 143 | 144 | func (amd *AttachmentMediaData) Write(to io.Writer) { 145 | _ = binary.Write(to, binary.BigEndian, amd) 146 | } 147 | 148 | func (amd *AttachmentMediaData) Read(from io.Reader) (err error) { 149 | return binary.Read(from, binary.BigEndian, amd) 150 | } 151 | 152 | func (amd *AttachmentMediaData) Size() int { 153 | return binary.Size(amd) 154 | } 155 | 156 | func (amd *AttachmentMediaData) Wrap() *MediaID { 157 | return &MediaID{ 158 | Version: MediaIDVersion, 159 | TypeClass: MediaIDClassAttachment, 160 | Data: amd, 161 | } 162 | } 163 | 164 | func (amd *AttachmentMediaData) CacheKey() AttachmentCacheKey { 165 | return AttachmentCacheKey{ 166 | ChannelID: amd.ChannelID, 167 | AttachmentID: amd.AttachmentID, 168 | } 169 | } 170 | 171 | type StickerMediaData struct { 172 | StickerID uint64 173 | Format uint8 174 | } 175 | 176 | func (smd *StickerMediaData) Write(to io.Writer) { 177 | _ = binary.Write(to, binary.BigEndian, smd) 178 | } 179 | 180 | func (smd *StickerMediaData) Read(from io.Reader) error { 181 | return binary.Read(from, binary.BigEndian, smd) 182 | } 183 | 184 | func (smd *StickerMediaData) Size() int { 185 | return binary.Size(smd) 186 | } 187 | 188 | func (smd *StickerMediaData) Wrap() *MediaID { 189 | return &MediaID{ 190 | Version: MediaIDVersion, 191 | TypeClass: MediaIDClassSticker, 192 | Data: smd, 193 | } 194 | } 195 | 196 | type EmojiMediaDataInner struct { 197 | EmojiID uint64 198 | Animated bool 199 | } 200 | 201 | type EmojiMediaData struct { 202 | EmojiMediaDataInner 203 | Name string 204 | } 205 | 206 | func (emd *EmojiMediaData) Write(to io.Writer) { 207 | _ = binary.Write(to, binary.BigEndian, &emd.EmojiMediaDataInner) 208 | _, _ = to.Write([]byte(emd.Name)) 209 | } 210 | 211 | func (emd *EmojiMediaData) Read(from io.Reader) (err error) { 212 | err = binary.Read(from, binary.BigEndian, &emd.EmojiMediaDataInner) 213 | if err != nil { 214 | return 215 | } 216 | name, err := io.ReadAll(from) 217 | if err != nil { 218 | return 219 | } 220 | emd.Name = string(name) 221 | return 222 | } 223 | 224 | func (emd *EmojiMediaData) Size() int { 225 | return binary.Size(&emd.EmojiMediaDataInner) + len(emd.Name) 226 | } 227 | 228 | func (emd *EmojiMediaData) Wrap() *MediaID { 229 | return &MediaID{ 230 | Version: MediaIDVersion, 231 | TypeClass: MediaIDClassEmoji, 232 | Data: emd, 233 | } 234 | } 235 | 236 | type UserAvatarMediaData struct { 237 | UserID uint64 238 | Animated bool 239 | AvatarID [16]byte 240 | } 241 | 242 | func (uamd *UserAvatarMediaData) Write(to io.Writer) { 243 | _ = binary.Write(to, binary.BigEndian, uamd) 244 | } 245 | 246 | func (uamd *UserAvatarMediaData) Read(from io.Reader) error { 247 | return binary.Read(from, binary.BigEndian, uamd) 248 | } 249 | 250 | func (uamd *UserAvatarMediaData) Size() int { 251 | return binary.Size(uamd) 252 | } 253 | 254 | func (uamd *UserAvatarMediaData) Wrap() *MediaID { 255 | return &MediaID{ 256 | Version: MediaIDVersion, 257 | TypeClass: MediaIDClassUserAvatar, 258 | Data: uamd, 259 | } 260 | } 261 | 262 | type GuildMemberAvatarMediaData struct { 263 | GuildID uint64 264 | UserID uint64 265 | Animated bool 266 | AvatarID [16]byte 267 | } 268 | 269 | func (guamd *GuildMemberAvatarMediaData) Write(to io.Writer) { 270 | _ = binary.Write(to, binary.BigEndian, guamd) 271 | } 272 | 273 | func (guamd *GuildMemberAvatarMediaData) Read(from io.Reader) error { 274 | return binary.Read(from, binary.BigEndian, guamd) 275 | } 276 | 277 | func (guamd *GuildMemberAvatarMediaData) Size() int { 278 | return binary.Size(guamd) 279 | } 280 | 281 | func (guamd *GuildMemberAvatarMediaData) Wrap() *MediaID { 282 | return &MediaID{ 283 | Version: MediaIDVersion, 284 | TypeClass: MediaIDClassGuildMemberAvatar, 285 | Data: guamd, 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /discord.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/bwmarrin/discordgo" 7 | ) 8 | 9 | func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool { 10 | switch channel.Type { 11 | case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews: 12 | // allowed 13 | case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM: 14 | // DMs are always bridgeable, no need for permission checks 15 | return true 16 | default: 17 | // everything else is not allowed 18 | return false 19 | } 20 | 21 | log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger() 22 | 23 | member, err := user.Session.State.Member(channel.GuildID, user.DiscordID) 24 | if errors.Is(err, discordgo.ErrStateNotFound) { 25 | log.Debug().Msg("Fetching own membership in guild to check roles") 26 | member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID) 27 | if err != nil { 28 | log.Warn().Err(err).Msg("Failed to get own membership in guild from server") 29 | } else { 30 | err = user.Session.State.MemberAdd(member) 31 | if err != nil { 32 | log.Warn().Err(err).Msg("Failed to add own membership in guild to cache") 33 | } 34 | } 35 | } else if err != nil { 36 | log.Warn().Err(err).Msg("Failed to get own membership in guild from cache") 37 | } 38 | err = user.Session.State.ChannelAdd(channel) 39 | if err != nil { 40 | log.Warn().Err(err).Msg("Failed to add channel to cache") 41 | } 42 | perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID) 43 | if err != nil { 44 | log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable") 45 | return true 46 | } 47 | log.Debug(). 48 | Int64("permissions", perms). 49 | Bool("view_channel", perms&discordgo.PermissionViewChannel > 0). 50 | Msg("Computed permissions in channel") 51 | return perms&discordgo.PermissionViewChannel > 0 52 | } 53 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ -z "$GID" ]]; then 4 | GID="$UID" 5 | fi 6 | 7 | # Define functions. 8 | function fixperms { 9 | chown -R $UID:$GID /data 10 | 11 | # /opt/mautrix-discord is read-only, so disable file logging if it's pointing there. 12 | if [[ "$(yq e '.logging.directory' /data/config.yaml)" == "./logs" ]]; then 13 | yq -I4 e -i '.logging.file_name_format = ""' /data/config.yaml 14 | fi 15 | } 16 | 17 | if [[ ! -f /data/config.yaml ]]; then 18 | cp /opt/mautrix-discord/example-config.yaml /data/config.yaml 19 | echo "Didn't find a config file." 20 | echo "Copied default config file to /data/config.yaml" 21 | echo "Modify that config file to your liking." 22 | echo "Start the container again after that to generate the registration file." 23 | exit 24 | fi 25 | 26 | if [[ ! -f /data/registration.yaml ]]; then 27 | /usr/bin/mautrix-discord -g -c /data/config.yaml -r /data/registration.yaml 28 | echo "Didn't find a registration file." 29 | echo "Generated one for you." 30 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." 31 | exit 32 | fi 33 | 34 | cd /data 35 | fixperms 36 | exec su-exec $UID:$GID /usr/bin/mautrix-discord 37 | -------------------------------------------------------------------------------- /formatter.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2023 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "regexp" 22 | "strings" 23 | 24 | "github.com/bwmarrin/discordgo" 25 | "github.com/yuin/goldmark" 26 | "github.com/yuin/goldmark/extension" 27 | "github.com/yuin/goldmark/parser" 28 | "github.com/yuin/goldmark/util" 29 | "go.mau.fi/util/variationselector" 30 | "golang.org/x/exp/slices" 31 | "maunium.net/go/mautrix/event" 32 | "maunium.net/go/mautrix/format" 33 | "maunium.net/go/mautrix/format/mdext" 34 | "maunium.net/go/mautrix/id" 35 | ) 36 | 37 | // escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement 38 | // 39 | // Discord allows escaping with just one backslash, e.g. \__a__, 40 | // but standard markdown requires both to be escaped (\_\_a__) 41 | var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) 42 | 43 | func escapeReplacement(s string) string { 44 | return s[:2] + `\` + s[2:] 45 | } 46 | 47 | // indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. 48 | // Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). 49 | type indentableParagraphParser struct { 50 | parser.BlockParser 51 | } 52 | 53 | var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} 54 | 55 | func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { 56 | return true 57 | } 58 | 59 | var removeFeaturesExceptLinks = []any{ 60 | parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), 61 | parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(), 62 | parser.NewCodeBlockParser(), 63 | } 64 | var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser()) 65 | var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) 66 | var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag) 67 | 68 | var discordRenderer = goldmark.New( 69 | goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)), 70 | fixIndentedParagraphs, format.HTMLOptions, discordExtensions, 71 | ) 72 | var discordRendererWithInlineLinks = goldmark.New( 73 | goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)), 74 | fixIndentedParagraphs, format.HTMLOptions, discordExtensions, 75 | ) 76 | 77 | func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string { 78 | text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement) 79 | 80 | var buf strings.Builder 81 | ctx := parser.NewContext() 82 | ctx.Set(parserContextPortal, portal) 83 | renderer := discordRenderer 84 | if allowInlineLinks { 85 | renderer = discordRendererWithInlineLinks 86 | } 87 | err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx)) 88 | if err != nil { 89 | panic(fmt.Errorf("markdown parser errored: %w", err)) 90 | } 91 | return format.UnwrapSingleParagraph(buf.String()) 92 | } 93 | 94 | const formatterContextPortalKey = "fi.mau.discord.portal" 95 | const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions" 96 | const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions" 97 | 98 | func appendIfNotContains(arr []string, newItem string) []string { 99 | for _, item := range arr { 100 | if item == newItem { 101 | return arr 102 | } 103 | } 104 | return append(arr, newItem) 105 | } 106 | 107 | func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string { 108 | if len(mxid) == 0 { 109 | return displayname 110 | } 111 | if mxid[0] == '#' { 112 | alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid)) 113 | if err != nil { 114 | return displayname 115 | } 116 | mxid = alias.RoomID.String() 117 | } 118 | if mxid[0] == '!' { 119 | portal := br.GetPortalByMXID(id.RoomID(mxid)) 120 | if portal != nil { 121 | if eventID == "" { 122 | //currentPortal := ctx[formatterContextPortalKey].(*Portal) 123 | return fmt.Sprintf("<#%s>", portal.Key.ChannelID) 124 | //if currentPortal.GuildID == portal.GuildID { 125 | //} else if portal.GuildID != "" { 126 | // return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name) 127 | //} else { 128 | // // TODO is mentioning private channels possible at all? 129 | //} 130 | } else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil { 131 | guildID := portal.GuildID 132 | if guildID == "" { 133 | guildID = "@me" 134 | } 135 | return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID) 136 | } 137 | } 138 | } else if mxid[0] == '@' { 139 | allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID) 140 | if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) { 141 | return displayname 142 | } 143 | mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions) 144 | parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid)) 145 | if ok { 146 | mentions.Users = appendIfNotContains(mentions.Users, parsedID) 147 | return fmt.Sprintf("<@%s>", parsedID) 148 | } 149 | mentionedUser := br.GetUserByMXID(id.UserID(mxid)) 150 | if mentionedUser != nil && mentionedUser.DiscordID != "" { 151 | mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID) 152 | return fmt.Sprintf("<@%s>", mentionedUser.DiscordID) 153 | } 154 | } 155 | return displayname 156 | } 157 | 158 | const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]` 159 | 160 | // Discord links start with http:// or https://, contain at least two characters afterwards, 161 | // don't contain < or whitespace anywhere, and don't end with "'),.:;] 162 | // 163 | // Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason 164 | var discordLinkRegex = regexp.MustCompile(discordLinkPattern) 165 | var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") 166 | 167 | var discordMarkdownEscaper = strings.NewReplacer( 168 | `\`, `\\`, 169 | `_`, `\_`, 170 | `*`, `\*`, 171 | `~`, `\~`, 172 | "`", "\\`", 173 | `|`, `\|`, 174 | `<`, `\<`, 175 | `#`, `\#`, 176 | ) 177 | 178 | func escapeDiscordMarkdown(s string) string { 179 | submatches := discordLinkRegex.FindAllStringIndex(s, -1) 180 | if submatches == nil { 181 | return discordMarkdownEscaper.Replace(s) 182 | } 183 | var builder strings.Builder 184 | offset := 0 185 | for _, match := range submatches { 186 | start := match[0] 187 | end := match[1] 188 | builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start])) 189 | builder.WriteString(s[start:end]) 190 | offset = end 191 | } 192 | builder.WriteString(discordMarkdownEscaper.Replace(s[offset:])) 193 | return builder.String() 194 | } 195 | 196 | var matrixHTMLParser = &format.HTMLParser{ 197 | TabsToSpaces: 4, 198 | Newline: "\n", 199 | HorizontalLine: "\n---\n", 200 | ItalicConverter: func(s string, ctx format.Context) string { 201 | return fmt.Sprintf("*%s*", s) 202 | }, 203 | UnderlineConverter: func(s string, ctx format.Context) string { 204 | return fmt.Sprintf("__%s__", s) 205 | }, 206 | TextConverter: func(s string, ctx format.Context) string { 207 | if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") { 208 | // If we're in a code block, don't escape markdown 209 | return s 210 | } 211 | return escapeDiscordMarkdown(s) 212 | }, 213 | SpoilerConverter: func(text, reason string, ctx format.Context) string { 214 | if reason != "" { 215 | return fmt.Sprintf("(%s) ||%s||", reason, text) 216 | } 217 | return fmt.Sprintf("||%s||", text) 218 | }, 219 | LinkConverter: func(text, href string, ctx format.Context) string { 220 | if text == href { 221 | return text 222 | } else if !discordLinkRegexFull.MatchString(href) { 223 | return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href)) 224 | } 225 | return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href) 226 | }, 227 | } 228 | 229 | func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) { 230 | allowedMentions := &discordgo.MessageAllowedMentions{ 231 | Parse: []discordgo.AllowedMentionType{}, 232 | Users: []string{}, 233 | RepliedUser: true, 234 | } 235 | if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { 236 | ctx := format.NewContext() 237 | ctx.ReturnData[formatterContextPortalKey] = portal 238 | ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions 239 | if content.Mentions != nil { 240 | ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs 241 | } 242 | return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions 243 | } else { 244 | return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /formatter_everyone.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2023 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "regexp" 22 | 23 | "github.com/yuin/goldmark" 24 | "github.com/yuin/goldmark/ast" 25 | "github.com/yuin/goldmark/parser" 26 | "github.com/yuin/goldmark/renderer" 27 | "github.com/yuin/goldmark/text" 28 | "github.com/yuin/goldmark/util" 29 | ) 30 | 31 | type astDiscordEveryone struct { 32 | ast.BaseInline 33 | onlyHere bool 34 | } 35 | 36 | var _ ast.Node = (*astDiscordEveryone)(nil) 37 | var astKindDiscordEveryone = ast.NewNodeKind("DiscordEveryone") 38 | 39 | func (n *astDiscordEveryone) Dump(source []byte, level int) { 40 | ast.DumpHelper(n, source, level, nil, nil) 41 | } 42 | 43 | func (n *astDiscordEveryone) Kind() ast.NodeKind { 44 | return astKindDiscordEveryone 45 | } 46 | 47 | func (n *astDiscordEveryone) String() string { 48 | if n.onlyHere { 49 | return "@here" 50 | } 51 | return "@everyone" 52 | } 53 | 54 | type discordEveryoneParser struct{} 55 | 56 | var discordEveryoneRegex = regexp.MustCompile(`@(everyone|here)`) 57 | var defaultDiscordEveryoneParser = &discordEveryoneParser{} 58 | 59 | func (s *discordEveryoneParser) Trigger() []byte { 60 | return []byte{'@'} 61 | } 62 | 63 | func (s *discordEveryoneParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 64 | line, _ := block.PeekLine() 65 | match := discordEveryoneRegex.FindSubmatch(line) 66 | if match == nil { 67 | return nil 68 | } 69 | block.Advance(len(match[0])) 70 | return &astDiscordEveryone{ 71 | onlyHere: string(match[1]) == "here", 72 | } 73 | } 74 | 75 | func (s *discordEveryoneParser) CloseBlock(parent ast.Node, pc parser.Context) { 76 | // nothing to do 77 | } 78 | 79 | type discordEveryoneHTMLRenderer struct{} 80 | 81 | func (r *discordEveryoneHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 82 | reg.Register(astKindDiscordEveryone, r.renderDiscordEveryone) 83 | } 84 | 85 | func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { 86 | status = ast.WalkContinue 87 | if !entering { 88 | return 89 | } 90 | mention, _ := n.(*astDiscordEveryone) 91 | class := "everyone" 92 | if mention != nil && mention.onlyHere { 93 | class = "here" 94 | } 95 | _, _ = fmt.Fprintf(w, `@room`, class) 96 | return 97 | } 98 | 99 | type discordEveryone struct{} 100 | 101 | var ExtDiscordEveryone = &discordEveryone{} 102 | 103 | func (e *discordEveryone) Extend(m goldmark.Markdown) { 104 | m.Parser().AddOptions(parser.WithInlineParsers( 105 | util.Prioritized(defaultDiscordEveryoneParser, 600), 106 | )) 107 | m.Renderer().AddOptions(renderer.WithNodeRenderers( 108 | util.Prioritized(&discordEveryoneHTMLRenderer{}, 600), 109 | )) 110 | } 111 | -------------------------------------------------------------------------------- /formatter_tag.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "math" 22 | "regexp" 23 | "strconv" 24 | "strings" 25 | "time" 26 | 27 | "github.com/yuin/goldmark" 28 | "github.com/yuin/goldmark/ast" 29 | "github.com/yuin/goldmark/parser" 30 | "github.com/yuin/goldmark/renderer" 31 | "github.com/yuin/goldmark/text" 32 | "github.com/yuin/goldmark/util" 33 | "maunium.net/go/mautrix/id" 34 | 35 | "go.mau.fi/mautrix-discord/database" 36 | ) 37 | 38 | type astDiscordTag struct { 39 | ast.BaseInline 40 | portal *Portal 41 | id int64 42 | } 43 | 44 | var _ ast.Node = (*astDiscordTag)(nil) 45 | var astKindDiscordTag = ast.NewNodeKind("DiscordTag") 46 | 47 | func (n *astDiscordTag) Dump(source []byte, level int) { 48 | ast.DumpHelper(n, source, level, nil, nil) 49 | } 50 | 51 | func (n *astDiscordTag) Kind() ast.NodeKind { 52 | return astKindDiscordTag 53 | } 54 | 55 | type astDiscordUserMention struct { 56 | astDiscordTag 57 | hasNick bool 58 | } 59 | 60 | func (n *astDiscordUserMention) String() string { 61 | if n.hasNick { 62 | return fmt.Sprintf("<@!%d>", n.id) 63 | } 64 | return fmt.Sprintf("<@%d>", n.id) 65 | } 66 | 67 | type astDiscordRoleMention struct { 68 | astDiscordTag 69 | } 70 | 71 | func (n *astDiscordRoleMention) String() string { 72 | return fmt.Sprintf("<@&%d>", n.id) 73 | } 74 | 75 | type astDiscordChannelMention struct { 76 | astDiscordTag 77 | 78 | guildID int64 79 | name string 80 | } 81 | 82 | func (n *astDiscordChannelMention) String() string { 83 | if n.guildID != 0 { 84 | return fmt.Sprintf("<#%d:%d:%s>", n.id, n.guildID, n.name) 85 | } 86 | return fmt.Sprintf("<#%d>", n.id) 87 | } 88 | 89 | type discordTimestampStyle rune 90 | 91 | func (dts discordTimestampStyle) Format() string { 92 | switch dts { 93 | case 't': 94 | return "15:04 MST" 95 | case 'T': 96 | return "15:04:05 MST" 97 | case 'd': 98 | return "2006-01-02 MST" 99 | case 'D': 100 | return "2 January 2006 MST" 101 | case 'F': 102 | return "Monday, 2 January 2006 15:04 MST" 103 | case 'f': 104 | fallthrough 105 | default: 106 | return "2 January 2006 15:04 MST" 107 | } 108 | } 109 | 110 | type astDiscordTimestamp struct { 111 | astDiscordTag 112 | 113 | timestamp int64 114 | style discordTimestampStyle 115 | } 116 | 117 | func (n *astDiscordTimestamp) String() string { 118 | if n.style == 'f' { 119 | return fmt.Sprintf("", n.timestamp) 120 | } 121 | return fmt.Sprintf("", n.timestamp, n.style) 122 | } 123 | 124 | type astDiscordCustomEmoji struct { 125 | astDiscordTag 126 | name string 127 | animated bool 128 | } 129 | 130 | func (n *astDiscordCustomEmoji) String() string { 131 | if n.animated { 132 | return fmt.Sprintf("", n.name, n.id) 133 | } 134 | return fmt.Sprintf("<%s%d>", n.name, n.id) 135 | } 136 | 137 | type discordTagParser struct{} 138 | 139 | // Regex to match everything in https://discord.com/developers/docs/reference#message-formatting 140 | var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`) 141 | var defaultDiscordTagParser = &discordTagParser{} 142 | 143 | func (s *discordTagParser) Trigger() []byte { 144 | return []byte{'<'} 145 | } 146 | 147 | var parserContextPortal = parser.NewContextKey() 148 | 149 | func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 150 | portal := pc.Get(parserContextPortal).(*Portal) 151 | //before := block.PrecendingCharacter() 152 | line, _ := block.PeekLine() 153 | match := discordTagRegex.FindSubmatch(line) 154 | if match == nil { 155 | return nil 156 | } 157 | //seg := segment.WithStop(segment.Start + len(match[0])) 158 | block.Advance(len(match[0])) 159 | 160 | id, err := strconv.ParseInt(string(match[2]), 10, 64) 161 | if err != nil { 162 | return nil 163 | } 164 | tag := astDiscordTag{id: id, portal: portal} 165 | tagName := string(match[1]) 166 | switch { 167 | case tagName == "@": 168 | return &astDiscordUserMention{astDiscordTag: tag} 169 | case tagName == "@!": 170 | return &astDiscordUserMention{astDiscordTag: tag, hasNick: true} 171 | case tagName == "@&": 172 | return &astDiscordRoleMention{astDiscordTag: tag} 173 | case tagName == "#": 174 | var guildID int64 175 | var channelName string 176 | if len(match[4]) > 0 && len(match[5]) > 0 { 177 | guildID, _ = strconv.ParseInt(string(match[4]), 10, 64) 178 | channelName = string(match[5]) 179 | } 180 | return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName} 181 | case tagName == "t:": 182 | var style discordTimestampStyle 183 | if len(match[3]) == 0 { 184 | style = 'f' 185 | } else { 186 | style = discordTimestampStyle(match[3][0]) 187 | } 188 | return &astDiscordTimestamp{ 189 | astDiscordTag: tag, 190 | timestamp: id, 191 | style: style, 192 | } 193 | case strings.HasPrefix(tagName, ":"): 194 | return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag} 195 | case strings.HasPrefix(tagName, "a:"): 196 | return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true} 197 | default: 198 | return nil 199 | } 200 | } 201 | 202 | func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) { 203 | // nothing to do 204 | } 205 | 206 | type discordTagHTMLRenderer struct{} 207 | 208 | var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{} 209 | 210 | func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 211 | reg.Register(astKindDiscordTag, r.renderDiscordMention) 212 | } 213 | 214 | func relativeTimeFormat(ts time.Time) string { 215 | now := time.Now() 216 | if ts.Year() >= 2262 { 217 | return "date out of range for relative format" 218 | } 219 | duration := ts.Sub(now) 220 | word := "in %s" 221 | if duration < 0 { 222 | duration = -duration 223 | word = "%s ago" 224 | } 225 | var count int 226 | var unit string 227 | switch { 228 | case duration < time.Second: 229 | count = int(duration.Milliseconds()) 230 | unit = "millisecond" 231 | case duration < time.Minute: 232 | count = int(math.Round(duration.Seconds())) 233 | unit = "second" 234 | case duration < time.Hour: 235 | count = int(math.Round(duration.Minutes())) 236 | unit = "minute" 237 | case duration < 24*time.Hour: 238 | count = int(math.Round(duration.Hours())) 239 | unit = "hour" 240 | case duration < 30*24*time.Hour: 241 | count = int(math.Round(duration.Hours() / 24)) 242 | unit = "day" 243 | case duration < 365*24*time.Hour: 244 | count = int(math.Round(duration.Hours() / 24 / 30)) 245 | unit = "month" 246 | default: 247 | count = int(math.Round(duration.Hours() / 24 / 365)) 248 | unit = "year" 249 | } 250 | var diff string 251 | if count == 1 { 252 | diff = fmt.Sprintf("a %s", unit) 253 | } else { 254 | diff = fmt.Sprintf("%d %ss", count, unit) 255 | } 256 | return fmt.Sprintf(word, diff) 257 | } 258 | 259 | func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { 260 | status = ast.WalkContinue 261 | if !entering { 262 | return 263 | } 264 | switch node := n.(type) { 265 | case *astDiscordUserMention: 266 | var mxid id.UserID 267 | var name string 268 | if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil { 269 | mxid = puppet.MXID 270 | name = puppet.Name 271 | } 272 | if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil { 273 | mxid = user.MXID 274 | if name == "" { 275 | name = user.MXID.Localpart() 276 | } 277 | } 278 | _, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), name) 279 | return 280 | case *astDiscordRoleMention: 281 | role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10)) 282 | if role != nil { 283 | _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name) 284 | return 285 | } 286 | case *astDiscordChannelMention: 287 | portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{ 288 | ChannelID: strconv.FormatInt(node.id, 10), 289 | Receiver: "", 290 | }) 291 | if portal != nil { 292 | if portal.MXID != "" { 293 | _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name) 294 | } else { 295 | _, _ = w.WriteString(portal.Name) 296 | } 297 | return 298 | } 299 | case *astDiscordCustomEmoji: 300 | reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated) 301 | if !reactionMXC.IsEmpty() { 302 | attrs := "data-mx-emoticon" 303 | if node.animated { 304 | attrs += " data-mau-animated-emoji" 305 | } 306 | _, _ = fmt.Fprintf(w, `%[2]s`, reactionMXC.String(), node.name, attrs) 307 | return 308 | } 309 | case *astDiscordTimestamp: 310 | ts := time.Unix(node.timestamp, 0).UTC() 311 | var formatted string 312 | if node.style == 'R' { 313 | formatted = relativeTimeFormat(ts) 314 | } else { 315 | formatted = ts.Format(node.style.Format()) 316 | } 317 | // https://github.com/matrix-org/matrix-spec-proposals/pull/3160 318 | const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700" 319 | fullRFC := ts.Format(fullDatetimeFormat) 320 | fullHumanReadable := ts.Format(discordTimestampStyle('F').Format()) 321 | _, _ = fmt.Fprintf(w, ``, fullHumanReadable, fullRFC, node.style, formatted) 322 | } 323 | stringifiable, ok := n.(fmt.Stringer) 324 | if ok { 325 | _, _ = w.WriteString(stringifiable.String()) 326 | } else { 327 | _, _ = w.Write(source) 328 | } 329 | return 330 | } 331 | 332 | type discordTag struct{} 333 | 334 | var ExtDiscordTag = &discordTag{} 335 | 336 | func (e *discordTag) Extend(m goldmark.Markdown) { 337 | m.Parser().AddOptions(parser.WithInlineParsers( 338 | util.Prioritized(defaultDiscordTagParser, 600), 339 | )) 340 | m.Renderer().AddOptions(renderer.WithNodeRenderers( 341 | util.Prioritized(defaultDiscordTagHTMLRenderer, 600), 342 | )) 343 | } 344 | -------------------------------------------------------------------------------- /formatter_test.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestEscapeDiscordMarkdown(t *testing.T) { 26 | type escapeTest struct { 27 | name string 28 | input string 29 | expected string 30 | } 31 | 32 | tests := []escapeTest{ 33 | {"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."}, 34 | {"Backslash", `foo\bar`, `foo\\bar`}, 35 | {"Underscore", `foo_bar`, `foo\_bar`}, 36 | {"Asterisk", `foo*bar`, `foo\*bar`}, 37 | {"Tilde", `foo~bar`, `foo\~bar`}, 38 | {"Backtick", "foo`bar", "foo\\`bar"}, 39 | {"Forward tick", `foo´bar`, `foo´bar`}, 40 | {"Pipe", `foo|bar`, `foo\|bar`}, 41 | {"Less than", `foobar`, `foo>bar`}, 43 | {"Multiple things", `\_*~|`, `\\\_\*\~\|`}, 44 | {"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`}, 45 | {"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*`}, 46 | {"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"}, 47 | {"URL ends with less than", `https://example.com github.com/beeper/discordgo v0.0.0-20250320154217-0d7f942e6b38 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= 2 | github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 3 | github.com/beeper/discordgo v0.0.0-20250320154217-0d7f942e6b38 h1:1WoSvVGM1pI9f+x7EGD2QPaXSUQeF3B7Lox7bmVe//s= 4 | github.com/beeper/discordgo v0.0.0-20250320154217-0d7f942e6b38/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= 5 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 6 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 10 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 11 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 12 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 13 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 14 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 15 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 16 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 17 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 18 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 19 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 20 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 21 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 22 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 23 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 24 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 25 | github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 26 | github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 27 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 31 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 32 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 33 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 34 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 35 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 36 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 37 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 38 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 39 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 40 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 41 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 42 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 43 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 44 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 45 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 46 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 47 | github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= 48 | github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 49 | go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs= 50 | go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw= 51 | go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= 52 | go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= 53 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 54 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 55 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 56 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 57 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 58 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 59 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 60 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 61 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 65 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 69 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 70 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 71 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= 73 | maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 74 | maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= 75 | maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= 76 | maunium.net/go/mautrix v0.16.3-0.20250503191143-e173d97939b4 h1:54y/T8ukHgmQsaSYoRWwhxLAijVVyLbOsTsglyjZ7Ro= 77 | maunium.net/go/mautrix v0.16.3-0.20250503191143-e173d97939b4/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q= 78 | -------------------------------------------------------------------------------- /guildportal.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "sync" 23 | 24 | log "maunium.net/go/maulogger/v2" 25 | "maunium.net/go/maulogger/v2/maulogadapt" 26 | 27 | "maunium.net/go/mautrix" 28 | "maunium.net/go/mautrix/event" 29 | "maunium.net/go/mautrix/id" 30 | 31 | "github.com/bwmarrin/discordgo" 32 | 33 | "go.mau.fi/mautrix-discord/config" 34 | "go.mau.fi/mautrix-discord/database" 35 | ) 36 | 37 | type Guild struct { 38 | *database.Guild 39 | 40 | bridge *DiscordBridge 41 | log log.Logger 42 | 43 | roomCreateLock sync.Mutex 44 | } 45 | 46 | func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild { 47 | if dbGuild == nil { 48 | if id == "" || !createIfNotExist { 49 | return nil 50 | } 51 | 52 | dbGuild = br.DB.Guild.New() 53 | dbGuild.ID = id 54 | dbGuild.Insert() 55 | } 56 | 57 | guild := br.NewGuild(dbGuild) 58 | 59 | br.guildsByID[guild.ID] = guild 60 | if guild.MXID != "" { 61 | br.guildsByMXID[guild.MXID] = guild 62 | } 63 | 64 | return guild 65 | } 66 | 67 | func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild { 68 | br.guildsLock.Lock() 69 | defer br.guildsLock.Unlock() 70 | 71 | portal, ok := br.guildsByMXID[mxid] 72 | if !ok { 73 | return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false) 74 | } 75 | 76 | return portal 77 | } 78 | 79 | func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild { 80 | br.guildsLock.Lock() 81 | defer br.guildsLock.Unlock() 82 | 83 | guild, ok := br.guildsByID[id] 84 | if !ok { 85 | return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist) 86 | } 87 | 88 | return guild 89 | } 90 | 91 | func (br *DiscordBridge) GetAllGuilds() []*Guild { 92 | return br.dbGuildsToGuilds(br.DB.Guild.GetAll()) 93 | } 94 | 95 | func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild { 96 | br.guildsLock.Lock() 97 | defer br.guildsLock.Unlock() 98 | 99 | output := make([]*Guild, len(dbGuilds)) 100 | for index, dbGuild := range dbGuilds { 101 | if dbGuild == nil { 102 | continue 103 | } 104 | 105 | guild, ok := br.guildsByID[dbGuild.ID] 106 | if !ok { 107 | guild = br.loadGuild(dbGuild, "", false) 108 | } 109 | 110 | output[index] = guild 111 | } 112 | 113 | return output 114 | } 115 | 116 | func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild { 117 | guild := &Guild{ 118 | Guild: dbGuild, 119 | bridge: br, 120 | log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)), 121 | } 122 | 123 | return guild 124 | } 125 | 126 | func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) { 127 | bridgeInfo := event.BridgeEventContent{ 128 | BridgeBot: guild.bridge.Bot.UserID, 129 | Creator: guild.bridge.Bot.UserID, 130 | Protocol: event.BridgeInfoSection{ 131 | ID: "discordgo", 132 | DisplayName: "Discord", 133 | AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), 134 | ExternalURL: "https://discord.com/", 135 | }, 136 | Channel: event.BridgeInfoSection{ 137 | ID: guild.ID, 138 | DisplayName: guild.Name, 139 | AvatarURL: guild.AvatarURL.CUString(), 140 | }, 141 | } 142 | bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID) 143 | return bridgeInfoStateKey, bridgeInfo 144 | } 145 | 146 | func (guild *Guild) UpdateBridgeInfo() { 147 | if len(guild.MXID) == 0 { 148 | guild.log.Debugln("Not updating bridge info: no Matrix room created") 149 | return 150 | } 151 | guild.log.Debugln("Updating bridge info...") 152 | stateKey, content := guild.getBridgeInfo() 153 | _, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content) 154 | if err != nil { 155 | guild.log.Warnln("Failed to update m.bridge:", err) 156 | } 157 | // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec 158 | _, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content) 159 | if err != nil { 160 | guild.log.Warnln("Failed to update uk.half-shot.bridge:", err) 161 | } 162 | } 163 | 164 | func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error { 165 | guild.roomCreateLock.Lock() 166 | defer guild.roomCreateLock.Unlock() 167 | if guild.MXID != "" { 168 | return nil 169 | } 170 | guild.log.Infoln("Creating Matrix room for guild") 171 | guild.UpdateInfo(user, meta) 172 | 173 | bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo() 174 | 175 | initialState := []*event.Event{{ 176 | Type: event.StateBridge, 177 | Content: event.Content{Parsed: bridgeInfo}, 178 | StateKey: &bridgeInfoStateKey, 179 | }, { 180 | // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec 181 | Type: event.StateHalfShotBridge, 182 | Content: event.Content{Parsed: bridgeInfo}, 183 | StateKey: &bridgeInfoStateKey, 184 | }} 185 | 186 | if !guild.AvatarURL.IsEmpty() { 187 | initialState = append(initialState, &event.Event{ 188 | Type: event.StateRoomAvatar, 189 | Content: event.Content{Parsed: &event.RoomAvatarEventContent{ 190 | URL: guild.AvatarURL, 191 | }}, 192 | }) 193 | } 194 | 195 | creationContent := map[string]interface{}{ 196 | "type": event.RoomTypeSpace, 197 | } 198 | if !guild.bridge.Config.Bridge.FederateRooms { 199 | creationContent["m.federate"] = false 200 | } 201 | 202 | resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ 203 | Visibility: "private", 204 | Name: guild.Name, 205 | Preset: "private_chat", 206 | InitialState: initialState, 207 | CreationContent: creationContent, 208 | }) 209 | if err != nil { 210 | guild.log.Warnln("Failed to create room:", err) 211 | return err 212 | } 213 | 214 | guild.MXID = resp.RoomID 215 | guild.NameSet = true 216 | guild.AvatarSet = !guild.AvatarURL.IsEmpty() 217 | guild.Update() 218 | guild.bridge.guildsLock.Lock() 219 | guild.bridge.guildsByMXID[guild.MXID] = guild 220 | guild.bridge.guildsLock.Unlock() 221 | guild.log.Infoln("Matrix room created:", guild.MXID) 222 | 223 | user.ensureInvited(nil, guild.MXID, false, true) 224 | 225 | return nil 226 | } 227 | 228 | func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild { 229 | if meta.Unavailable { 230 | guild.log.Debugfln("Ignoring unavailable guild update") 231 | return meta 232 | } 233 | changed := false 234 | changed = guild.UpdateName(meta) || changed 235 | changed = guild.UpdateAvatar(meta.Icon) || changed 236 | if changed { 237 | guild.UpdateBridgeInfo() 238 | guild.Update() 239 | } 240 | source.ensureInvited(nil, guild.MXID, false, false) 241 | return meta 242 | } 243 | 244 | func (guild *Guild) UpdateName(meta *discordgo.Guild) bool { 245 | name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{ 246 | Name: meta.Name, 247 | }) 248 | if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") { 249 | return false 250 | } 251 | guild.log.Debugfln("Updating name %q -> %q", guild.Name, name) 252 | guild.Name = name 253 | guild.PlainName = meta.Name 254 | guild.NameSet = false 255 | if guild.MXID != "" { 256 | _, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name) 257 | if err != nil { 258 | guild.log.Warnln("Failed to update room name: %s", err) 259 | } else { 260 | guild.NameSet = true 261 | } 262 | } 263 | return true 264 | } 265 | 266 | func (guild *Guild) UpdateAvatar(iconID string) bool { 267 | if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") { 268 | return false 269 | } 270 | guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID) 271 | guild.AvatarSet = false 272 | guild.Avatar = iconID 273 | guild.AvatarURL = id.ContentURI{} 274 | if guild.Avatar != "" { 275 | // TODO direct media support 276 | copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{ 277 | AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID), 278 | }) 279 | if err != nil { 280 | guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err) 281 | return true 282 | } 283 | guild.AvatarURL = copied.MXC 284 | } 285 | if guild.MXID != "" { 286 | _, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL) 287 | if err != nil { 288 | guild.log.Warnln("Failed to update room avatar:", err) 289 | } else { 290 | guild.AvatarSet = true 291 | } 292 | } 293 | return true 294 | } 295 | 296 | func (guild *Guild) cleanup() { 297 | if guild.MXID == "" { 298 | return 299 | } 300 | intent := guild.bridge.Bot 301 | if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { 302 | err := intent.BeeperDeleteRoom(guild.MXID) 303 | if err != nil && !errors.Is(err, mautrix.MNotFound) { 304 | guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err) 305 | } 306 | return 307 | } 308 | guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log)) 309 | } 310 | 311 | func (guild *Guild) RemoveMXID() { 312 | guild.bridge.guildsLock.Lock() 313 | defer guild.bridge.guildsLock.Unlock() 314 | if guild.MXID == "" { 315 | return 316 | } 317 | delete(guild.bridge.guildsByMXID, guild.MXID) 318 | guild.MXID = "" 319 | guild.AvatarSet = false 320 | guild.NameSet = false 321 | guild.BridgingMode = database.GuildBridgeNothing 322 | guild.Update() 323 | } 324 | 325 | func (guild *Guild) Delete() { 326 | guild.Guild.Delete() 327 | guild.bridge.guildsLock.Lock() 328 | delete(guild.bridge.guildsByID, guild.ID) 329 | if guild.MXID != "" { 330 | delete(guild.bridge.guildsByMXID, guild.MXID) 331 | } 332 | guild.bridge.guildsLock.Unlock() 333 | 334 | } 335 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // mautrix-discord - A Matrix-Discord puppeting bridge. 2 | // Copyright (C) 2022 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | _ "embed" 21 | "net/http" 22 | "sync" 23 | 24 | "go.mau.fi/util/configupgrade" 25 | "go.mau.fi/util/exsync" 26 | "golang.org/x/sync/semaphore" 27 | "maunium.net/go/mautrix/bridge" 28 | "maunium.net/go/mautrix/bridge/commands" 29 | "maunium.net/go/mautrix/id" 30 | 31 | "go.mau.fi/mautrix-discord/config" 32 | "go.mau.fi/mautrix-discord/database" 33 | ) 34 | 35 | // Information to find out exactly which commit the bridge was built from. 36 | // These are filled at build time with the -X linker flag. 37 | var ( 38 | Tag = "unknown" 39 | Commit = "unknown" 40 | BuildTime = "unknown" 41 | ) 42 | 43 | //go:embed example-config.yaml 44 | var ExampleConfig string 45 | 46 | type DiscordBridge struct { 47 | bridge.Bridge 48 | 49 | Config *config.Config 50 | DB *database.Database 51 | 52 | DMA *DirectMediaAPI 53 | provisioning *ProvisioningAPI 54 | 55 | usersByMXID map[id.UserID]*User 56 | usersByID map[string]*User 57 | usersLock sync.Mutex 58 | 59 | managementRooms map[id.RoomID]*User 60 | managementRoomsLock sync.Mutex 61 | 62 | portalsByMXID map[id.RoomID]*Portal 63 | portalsByID map[database.PortalKey]*Portal 64 | portalsLock sync.Mutex 65 | 66 | threadsByID map[string]*Thread 67 | threadsByRootMXID map[id.EventID]*Thread 68 | threadsByCreationNoticeMXID map[id.EventID]*Thread 69 | threadsLock sync.Mutex 70 | 71 | guildsByMXID map[id.RoomID]*Guild 72 | guildsByID map[string]*Guild 73 | guildsLock sync.Mutex 74 | 75 | puppets map[string]*Puppet 76 | puppetsByCustomMXID map[id.UserID]*Puppet 77 | puppetsLock sync.Mutex 78 | 79 | attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]] 80 | parallelAttachmentSemaphore *semaphore.Weighted 81 | } 82 | 83 | func (br *DiscordBridge) GetExampleConfig() string { 84 | return ExampleConfig 85 | } 86 | 87 | func (br *DiscordBridge) GetConfigPtr() interface{} { 88 | br.Config = &config.Config{ 89 | BaseConfig: &br.Bridge.Config, 90 | } 91 | br.Config.BaseConfig.Bridge = &br.Config.Bridge 92 | return br.Config 93 | } 94 | 95 | func (br *DiscordBridge) Init() { 96 | br.CommandProcessor = commands.NewProcessor(&br.Bridge) 97 | br.RegisterCommands() 98 | 99 | matrixHTMLParser.PillConverter = br.pillConverter 100 | 101 | br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) 102 | discordLog = br.ZLog.With().Str("component", "discordgo").Logger() 103 | } 104 | 105 | func (br *DiscordBridge) Start() { 106 | if br.Config.Bridge.Provisioning.SharedSecret != "disable" { 107 | br.provisioning = newProvisioningAPI(br) 108 | } 109 | if br.Config.Bridge.PublicAddress != "" { 110 | br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet) 111 | } 112 | br.DMA = newDirectMediaAPI(br) 113 | br.WaitWebsocketConnected() 114 | go br.startUsers() 115 | } 116 | 117 | func (br *DiscordBridge) Stop() { 118 | for _, user := range br.usersByMXID { 119 | if user.Session == nil { 120 | continue 121 | } 122 | 123 | br.Log.Debugln("Disconnecting", user.MXID) 124 | user.Session.Close() 125 | } 126 | } 127 | 128 | func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal { 129 | p := br.GetPortalByMXID(mxid) 130 | if p == nil { 131 | return nil 132 | } 133 | return p 134 | } 135 | 136 | func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User { 137 | p := br.GetUserByMXID(mxid) 138 | if p == nil { 139 | return nil 140 | } 141 | return p 142 | } 143 | 144 | func (br *DiscordBridge) IsGhost(mxid id.UserID) bool { 145 | _, isGhost := br.ParsePuppetMXID(mxid) 146 | return isGhost 147 | } 148 | 149 | func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost { 150 | p := br.GetPuppetByMXID(mxid) 151 | if p == nil { 152 | return nil 153 | } 154 | return p 155 | } 156 | 157 | func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) { 158 | //TODO implement 159 | } 160 | 161 | func main() { 162 | br := &DiscordBridge{ 163 | usersByMXID: make(map[id.UserID]*User), 164 | usersByID: make(map[string]*User), 165 | 166 | managementRooms: make(map[id.RoomID]*User), 167 | 168 | portalsByMXID: make(map[id.RoomID]*Portal), 169 | portalsByID: make(map[database.PortalKey]*Portal), 170 | 171 | threadsByID: make(map[string]*Thread), 172 | threadsByRootMXID: make(map[id.EventID]*Thread), 173 | threadsByCreationNoticeMXID: make(map[id.EventID]*Thread), 174 | 175 | guildsByID: make(map[string]*Guild), 176 | guildsByMXID: make(map[id.RoomID]*Guild), 177 | 178 | puppets: make(map[string]*Puppet), 179 | puppetsByCustomMXID: make(map[id.UserID]*Puppet), 180 | 181 | attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](), 182 | parallelAttachmentSemaphore: semaphore.NewWeighted(3), 183 | } 184 | br.Bridge = bridge.Bridge{ 185 | Name: "mautrix-discord", 186 | URL: "https://github.com/mautrix/discord", 187 | Description: "A Matrix-Discord puppeting bridge.", 188 | Version: "0.7.3", 189 | ProtocolName: "Discord", 190 | BeeperServiceName: "discordgo", 191 | BeeperNetworkName: "discord", 192 | 193 | CryptoPickleKey: "maunium.net/go/mautrix-whatsapp", 194 | 195 | ConfigUpgrader: &configupgrade.StructUpgrader{ 196 | SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), 197 | Blocks: config.SpacedBlocks, 198 | Base: ExampleConfig, 199 | }, 200 | 201 | Child: br, 202 | } 203 | br.InitVersion(Tag, Commit, BuildTime) 204 | 205 | br.Main() 206 | } 207 | -------------------------------------------------------------------------------- /puppet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/bwmarrin/discordgo" 10 | "github.com/rs/zerolog" 11 | 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/appservice" 14 | "maunium.net/go/mautrix/bridge" 15 | "maunium.net/go/mautrix/id" 16 | 17 | "go.mau.fi/mautrix-discord/database" 18 | ) 19 | 20 | type Puppet struct { 21 | *database.Puppet 22 | 23 | bridge *DiscordBridge 24 | log zerolog.Logger 25 | 26 | MXID id.UserID 27 | 28 | customIntent *appservice.IntentAPI 29 | customUser *User 30 | 31 | syncLock sync.Mutex 32 | } 33 | 34 | var _ bridge.Ghost = (*Puppet)(nil) 35 | var _ bridge.GhostWithProfile = (*Puppet)(nil) 36 | 37 | func (puppet *Puppet) GetMXID() id.UserID { 38 | return puppet.MXID 39 | } 40 | 41 | var userIDRegex *regexp.Regexp 42 | 43 | func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { 44 | return &Puppet{ 45 | Puppet: dbPuppet, 46 | bridge: br, 47 | log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(), 48 | 49 | MXID: br.FormatPuppetMXID(dbPuppet.ID), 50 | } 51 | } 52 | 53 | func (br *DiscordBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) { 54 | if userIDRegex == nil { 55 | pattern := fmt.Sprintf( 56 | "^@%s:%s$", 57 | br.Config.Bridge.FormatUsername("([0-9]+)"), 58 | br.Config.Homeserver.Domain, 59 | ) 60 | 61 | userIDRegex = regexp.MustCompile(pattern) 62 | } 63 | 64 | match := userIDRegex.FindStringSubmatch(string(mxid)) 65 | if len(match) == 2 { 66 | return match[1], true 67 | } 68 | 69 | return "", false 70 | } 71 | 72 | func (br *DiscordBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { 73 | discordID, ok := br.ParsePuppetMXID(mxid) 74 | if !ok { 75 | return nil 76 | } 77 | 78 | return br.GetPuppetByID(discordID) 79 | } 80 | 81 | func (br *DiscordBridge) GetPuppetByID(id string) *Puppet { 82 | br.puppetsLock.Lock() 83 | defer br.puppetsLock.Unlock() 84 | 85 | puppet, ok := br.puppets[id] 86 | if !ok { 87 | dbPuppet := br.DB.Puppet.Get(id) 88 | if dbPuppet == nil { 89 | dbPuppet = br.DB.Puppet.New() 90 | dbPuppet.ID = id 91 | dbPuppet.Insert() 92 | } 93 | 94 | puppet = br.NewPuppet(dbPuppet) 95 | br.puppets[puppet.ID] = puppet 96 | } 97 | 98 | return puppet 99 | } 100 | 101 | func (br *DiscordBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { 102 | br.puppetsLock.Lock() 103 | defer br.puppetsLock.Unlock() 104 | 105 | puppet, ok := br.puppetsByCustomMXID[mxid] 106 | if !ok { 107 | dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid) 108 | if dbPuppet == nil { 109 | return nil 110 | } 111 | 112 | puppet = br.NewPuppet(dbPuppet) 113 | br.puppets[puppet.ID] = puppet 114 | br.puppetsByCustomMXID[puppet.CustomMXID] = puppet 115 | } 116 | 117 | return puppet 118 | } 119 | 120 | func (br *DiscordBridge) GetAllPuppetsWithCustomMXID() []*Puppet { 121 | return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID()) 122 | } 123 | 124 | func (br *DiscordBridge) GetAllPuppets() []*Puppet { 125 | return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll()) 126 | } 127 | 128 | func (br *DiscordBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { 129 | br.puppetsLock.Lock() 130 | defer br.puppetsLock.Unlock() 131 | 132 | output := make([]*Puppet, len(dbPuppets)) 133 | for index, dbPuppet := range dbPuppets { 134 | if dbPuppet == nil { 135 | continue 136 | } 137 | 138 | puppet, ok := br.puppets[dbPuppet.ID] 139 | if !ok { 140 | puppet = br.NewPuppet(dbPuppet) 141 | br.puppets[dbPuppet.ID] = puppet 142 | 143 | if dbPuppet.CustomMXID != "" { 144 | br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet 145 | } 146 | } 147 | 148 | output[index] = puppet 149 | } 150 | 151 | return output 152 | } 153 | 154 | func (br *DiscordBridge) FormatPuppetMXID(did string) id.UserID { 155 | return id.NewUserID( 156 | br.Config.Bridge.FormatUsername(did), 157 | br.Config.Homeserver.Domain, 158 | ) 159 | } 160 | 161 | func (puppet *Puppet) GetDisplayname() string { 162 | return puppet.Name 163 | } 164 | 165 | func (puppet *Puppet) GetAvatarURL() id.ContentURI { 166 | return puppet.AvatarURL 167 | } 168 | 169 | func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { 170 | return puppet.bridge.AS.Intent(puppet.MXID) 171 | } 172 | 173 | func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { 174 | if puppet.customIntent == nil || (portal.Key.Receiver != "" && portal.Key.Receiver != puppet.ID) { 175 | return puppet.DefaultIntent() 176 | } 177 | 178 | return puppet.customIntent 179 | } 180 | 181 | func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { 182 | if puppet == nil { 183 | return nil 184 | } 185 | return puppet.customIntent 186 | } 187 | 188 | func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { 189 | for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.ID) { 190 | // Get room create lock to prevent races between receiving contact info and room creation. 191 | portal.roomCreateLock.Lock() 192 | meta(portal) 193 | portal.roomCreateLock.Unlock() 194 | } 195 | } 196 | 197 | func (puppet *Puppet) UpdateName(info *discordgo.User) bool { 198 | newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication) 199 | if puppet.Name == newName && puppet.NameSet { 200 | return false 201 | } 202 | puppet.Name = newName 203 | puppet.NameSet = false 204 | err := puppet.DefaultIntent().SetDisplayName(newName) 205 | if err != nil { 206 | puppet.log.Warn().Err(err).Msg("Failed to update displayname") 207 | } else { 208 | go puppet.updatePortalMeta(func(portal *Portal) { 209 | if portal.UpdateNameDirect(puppet.Name, false) { 210 | portal.Update() 211 | portal.UpdateBridgeInfo() 212 | } 213 | }) 214 | puppet.NameSet = true 215 | } 216 | return true 217 | } 218 | 219 | func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) { 220 | var downloadURL string 221 | if guildID == "" { 222 | if strings.HasPrefix(avatarID, "a_") { 223 | downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID) 224 | } else { 225 | downloadURL = discordgo.EndpointUserAvatar(userID, avatarID) 226 | } 227 | } else { 228 | if strings.HasPrefix(avatarID, "a_") { 229 | downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID) 230 | } else { 231 | downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID) 232 | } 233 | } 234 | url := br.DMA.AvatarMXC(guildID, userID, avatarID) 235 | if !url.IsEmpty() { 236 | return url, downloadURL, nil 237 | } 238 | copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{ 239 | AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID), 240 | }) 241 | if err != nil { 242 | return id.ContentURI{}, downloadURL, err 243 | } 244 | return copied.MXC, downloadURL, nil 245 | } 246 | 247 | func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool { 248 | avatarID := info.Avatar 249 | if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars { 250 | avatarID = "" 251 | } 252 | if puppet.Avatar == avatarID && puppet.AvatarSet { 253 | return false 254 | } 255 | avatarChanged := avatarID != puppet.Avatar 256 | puppet.Avatar = avatarID 257 | puppet.AvatarSet = false 258 | puppet.AvatarURL = id.ContentURI{} 259 | 260 | if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) { 261 | url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar) 262 | if err != nil { 263 | puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar") 264 | return true 265 | } 266 | puppet.AvatarURL = url 267 | } 268 | 269 | err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) 270 | if err != nil { 271 | puppet.log.Warn().Err(err).Msg("Failed to update avatar") 272 | } else { 273 | go puppet.updatePortalMeta(func(portal *Portal) { 274 | if portal.UpdateAvatarFromPuppet(puppet) { 275 | portal.Update() 276 | portal.UpdateBridgeInfo() 277 | } 278 | }) 279 | puppet.AvatarSet = true 280 | } 281 | return true 282 | } 283 | 284 | func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) { 285 | puppet.syncLock.Lock() 286 | defer puppet.syncLock.Unlock() 287 | 288 | if info == nil || len(info.Username) == 0 || len(info.Discriminator) == 0 { 289 | if puppet.Name != "" || source == nil { 290 | return 291 | } 292 | var err error 293 | puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet") 294 | info, err = source.Session.User(puppet.ID) 295 | if err != nil { 296 | puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user") 297 | return 298 | } 299 | } 300 | 301 | err := puppet.DefaultIntent().EnsureRegistered() 302 | if err != nil { 303 | puppet.log.Error().Err(err).Msg("Failed to ensure registered") 304 | } 305 | 306 | changed := false 307 | if message != nil { 308 | if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook { 309 | puppet.log.Debug(). 310 | Str("message_id", message.ID). 311 | Str("webhook_id", message.WebhookID). 312 | Msg("Found webhook ID in message, marking ghost as a webhook") 313 | puppet.IsWebhook = true 314 | changed = true 315 | } 316 | if message.ApplicationID != "" && !puppet.IsApplication { 317 | puppet.log.Debug(). 318 | Str("message_id", message.ID). 319 | Str("application_id", message.ApplicationID). 320 | Msg("Found application ID in message, marking ghost as an application") 321 | puppet.IsApplication = true 322 | puppet.IsWebhook = false 323 | changed = true 324 | } 325 | } 326 | changed = puppet.UpdateContactInfo(info) || changed 327 | changed = puppet.UpdateName(info) || changed 328 | changed = puppet.UpdateAvatar(info) || changed 329 | if changed { 330 | puppet.Update() 331 | } 332 | } 333 | 334 | func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool { 335 | changed := false 336 | if puppet.Username != info.Username { 337 | puppet.Username = info.Username 338 | changed = true 339 | } 340 | if puppet.GlobalName != info.GlobalName { 341 | puppet.GlobalName = info.GlobalName 342 | changed = true 343 | } 344 | if puppet.Discriminator != info.Discriminator { 345 | puppet.Discriminator = info.Discriminator 346 | changed = true 347 | } 348 | if puppet.IsBot != info.Bot { 349 | puppet.IsBot = info.Bot 350 | changed = true 351 | } 352 | if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet { 353 | puppet.ContactInfoSet = false 354 | puppet.ResendContactInfo() 355 | return true 356 | } 357 | return false 358 | } 359 | 360 | func (puppet *Puppet) ResendContactInfo() { 361 | if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet { 362 | return 363 | } 364 | discordUsername := puppet.Username 365 | if puppet.Discriminator != "0" { 366 | discordUsername += "#" + puppet.Discriminator 367 | } 368 | contactInfo := map[string]any{ 369 | "com.beeper.bridge.identifiers": []string{ 370 | fmt.Sprintf("discord:%s", discordUsername), 371 | }, 372 | "com.beeper.bridge.remote_id": puppet.ID, 373 | "com.beeper.bridge.service": puppet.bridge.BeeperServiceName, 374 | "com.beeper.bridge.network": puppet.bridge.BeeperNetworkName, 375 | "com.beeper.bridge.is_network_bot": puppet.IsBot, 376 | } 377 | if puppet.IsWebhook { 378 | contactInfo["com.beeper.bridge.identifiers"] = []string{} 379 | } 380 | err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo) 381 | if err != nil { 382 | puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile") 383 | } else { 384 | puppet.ContactInfoSet = true 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /remoteauth/README.md: -------------------------------------------------------------------------------- 1 | # Discord Remote Authentication 2 | 3 | This library implements the desktop side of Discord's remote authentication 4 | protocol. 5 | 6 | It is completely based off of the 7 | [Unofficial Discord API Documentation](https://luna.gitlab.io/discord-unofficial-docs/desktop_remote_auth.html). 8 | 9 | ## Example 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "context" 16 | "fmt" 17 | 18 | "github.com/skip2/go-qrcode" 19 | ) 20 | 21 | func main() { 22 | client, err := New() 23 | if err != nil { 24 | fmt.Printf("error: %v\n", err) 25 | 26 | return 27 | } 28 | 29 | ctx := context.Background() 30 | 31 | qrChan := make(chan *qrcode.QRCode) 32 | go func() { 33 | qrCode := <-qrChan 34 | fmt.Println(qrCode.ToSmallString(true)) 35 | }() 36 | 37 | doneChan := make(chan struct{}) 38 | 39 | if err := client.Dial(ctx, qrChan, doneChan); err != nil { 40 | close(qrChan) 41 | close(doneChan) 42 | 43 | fmt.Printf("dial error: %v\n", err) 44 | 45 | return 46 | } 47 | 48 | <-doneChan 49 | 50 | user, err := client.Result() 51 | fmt.Printf("user: %q\n", user) 52 | fmt.Printf("err: %v\n", err) 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /remoteauth/client.go: -------------------------------------------------------------------------------- 1 | package remoteauth 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "encoding/json" 10 | "net/http" 11 | "sync" 12 | 13 | "github.com/gorilla/websocket" 14 | 15 | "github.com/bwmarrin/discordgo" 16 | ) 17 | 18 | type Client struct { 19 | sync.Mutex 20 | 21 | URL string 22 | 23 | conn *websocket.Conn 24 | 25 | qrChan chan string 26 | doneChan chan struct{} 27 | 28 | user User 29 | err error 30 | 31 | heartbeats int 32 | closed bool 33 | 34 | privateKey *rsa.PrivateKey 35 | } 36 | 37 | // New creates a new Discord remote auth client. qrChan is a channel that will 38 | // receive the qrcode once it is available. 39 | func New() (*Client, error) { 40 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &Client{ 46 | URL: "wss://remote-auth-gateway.discord.gg/?v=2", 47 | privateKey: privateKey, 48 | }, nil 49 | } 50 | 51 | // Dial will start the QRCode login process. ctx may be used to abandon the 52 | // process. 53 | func (c *Client) Dial(ctx context.Context, qrChan chan string, doneChan chan struct{}) error { 54 | c.Lock() 55 | defer c.Unlock() 56 | 57 | header := http.Header{} 58 | for key, value := range discordgo.DroidWSHeaders { 59 | header.Set(key, value) 60 | } 61 | 62 | c.qrChan = qrChan 63 | c.doneChan = doneChan 64 | 65 | conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.URL, header) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | c.conn = conn 71 | 72 | go c.processMessages() 73 | 74 | return nil 75 | } 76 | 77 | func (c *Client) Result() (User, error) { 78 | c.Lock() 79 | defer c.Unlock() 80 | 81 | return c.user, c.err 82 | } 83 | 84 | func (c *Client) close() error { 85 | c.Lock() 86 | defer c.Unlock() 87 | 88 | if c.closed { 89 | return nil 90 | } 91 | 92 | c.conn.WriteMessage( 93 | websocket.CloseMessage, 94 | websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), 95 | ) 96 | 97 | c.closed = true 98 | 99 | defer close(c.doneChan) 100 | 101 | return c.conn.Close() 102 | } 103 | 104 | func (c *Client) write(p clientPacket) error { 105 | c.Lock() 106 | defer c.Unlock() 107 | 108 | payload, err := json.Marshal(p) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return c.conn.WriteMessage(websocket.TextMessage, payload) 114 | } 115 | 116 | func (c *Client) decrypt(payload string) ([]byte, error) { 117 | // Decode the base64 string. 118 | raw, err := base64.StdEncoding.DecodeString(payload) 119 | if err != nil { 120 | return []byte{}, err 121 | } 122 | 123 | // Decrypt the data. 124 | return rsa.DecryptOAEP(sha256.New(), nil, c.privateKey, raw, nil) 125 | } 126 | -------------------------------------------------------------------------------- /remoteauth/clientpackets.go: -------------------------------------------------------------------------------- 1 | package remoteauth 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/base64" 6 | "fmt" 7 | ) 8 | 9 | type clientPacket interface { 10 | send(client *Client) error 11 | } 12 | 13 | // ///////////////////////////////////////////////////////////////////////////// 14 | // Heartbeat 15 | // ///////////////////////////////////////////////////////////////////////////// 16 | type clientHeartbeat struct { 17 | OP string `json:"op"` 18 | } 19 | 20 | func (h *clientHeartbeat) send(client *Client) error { 21 | // make sure our op string is set 22 | h.OP = "heartbeat" 23 | 24 | client.heartbeats += 1 25 | if client.heartbeats > 2 { 26 | return fmt.Errorf("server failed to acknowledge our heartbeats") 27 | } 28 | 29 | return client.write(h) 30 | } 31 | 32 | // ///////////////////////////////////////////////////////////////////////////// 33 | // Init 34 | // ///////////////////////////////////////////////////////////////////////////// 35 | type clientInit struct { 36 | OP string `json:"op"` 37 | EncodedPublicKey string `json:"encoded_public_key"` 38 | } 39 | 40 | func (i *clientInit) send(client *Client) error { 41 | i.OP = "init" 42 | 43 | pubkey := client.privateKey.Public() 44 | 45 | raw, err := x509.MarshalPKIXPublicKey(pubkey) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | i.EncodedPublicKey = base64.RawStdEncoding.EncodeToString(raw) 51 | 52 | return client.write(i) 53 | } 54 | 55 | // ///////////////////////////////////////////////////////////////////////////// 56 | // NonceProof 57 | // ///////////////////////////////////////////////////////////////////////////// 58 | type clientNonceProof struct { 59 | OP string `json:"op"` 60 | Proof string `json:"proof"` 61 | } 62 | 63 | func (n *clientNonceProof) send(client *Client) error { 64 | n.OP = "nonce_proof" 65 | 66 | // All of the other work was taken care of by the server packet as it knows 67 | // the payload. 68 | 69 | return client.write(n) 70 | } 71 | -------------------------------------------------------------------------------- /remoteauth/serverpackets.go: -------------------------------------------------------------------------------- 1 | package remoteauth 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/gorilla/websocket" 11 | 12 | "github.com/bwmarrin/discordgo" 13 | ) 14 | 15 | type serverPacket interface { 16 | process(client *Client) error 17 | } 18 | 19 | func (c *Client) processMessages() { 20 | type rawPacket struct { 21 | OP string `json:"op"` 22 | } 23 | 24 | defer c.close() 25 | 26 | for { 27 | c.Lock() 28 | _, packet, err := c.conn.ReadMessage() 29 | c.Unlock() 30 | 31 | if err != nil { 32 | if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) { 33 | c.Lock() 34 | c.err = err 35 | c.Unlock() 36 | } 37 | 38 | return 39 | } 40 | 41 | raw := rawPacket{} 42 | if err := json.Unmarshal(packet, &raw); err != nil { 43 | c.Lock() 44 | c.err = err 45 | c.Unlock() 46 | 47 | return 48 | } 49 | 50 | var dest interface{} 51 | switch raw.OP { 52 | case "hello": 53 | dest = new(serverHello) 54 | case "nonce_proof": 55 | dest = new(serverNonceProof) 56 | case "pending_remote_init": 57 | dest = new(serverPendingRemoteInit) 58 | case "pending_ticket": 59 | dest = new(serverPendingTicket) 60 | case "pending_login": 61 | dest = new(serverPendingLogin) 62 | case "cancel": 63 | dest = new(serverCancel) 64 | case "heartbeat_ack": 65 | dest = new(serverHeartbeatAck) 66 | default: 67 | c.Lock() 68 | c.err = fmt.Errorf("unknown op %s", raw.OP) 69 | c.Unlock() 70 | return 71 | } 72 | 73 | if err := json.Unmarshal(packet, dest); err != nil { 74 | c.Lock() 75 | c.err = err 76 | c.Unlock() 77 | 78 | return 79 | } 80 | 81 | op := dest.(serverPacket) 82 | err = op.process(c) 83 | if err != nil { 84 | c.Lock() 85 | c.err = err 86 | c.Unlock() 87 | 88 | return 89 | } 90 | } 91 | } 92 | 93 | // ///////////////////////////////////////////////////////////////////////////// 94 | // Hello 95 | // ///////////////////////////////////////////////////////////////////////////// 96 | type serverHello struct { 97 | Timeout int `json:"timeout_ms"` 98 | HeartbeatInterval int `json:"heartbeat_interval"` 99 | } 100 | 101 | func (h *serverHello) process(client *Client) error { 102 | // Create our heartbeat handler 103 | ticker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond) 104 | go func() { 105 | defer ticker.Stop() 106 | for { 107 | select { 108 | // case <-client.ctx.Done(): 109 | // return 110 | case <-ticker.C: 111 | h := clientHeartbeat{} 112 | if err := h.send(client); err != nil { 113 | client.Lock() 114 | client.err = err 115 | client.Unlock() 116 | 117 | return 118 | } 119 | } 120 | } 121 | }() 122 | 123 | go func() { 124 | duration := time.Duration(h.Timeout) * time.Millisecond 125 | 126 | <-time.After(duration) 127 | 128 | client.Lock() 129 | client.err = fmt.Errorf("Timed out after %s", duration) 130 | client.close() 131 | client.Unlock() 132 | }() 133 | 134 | i := clientInit{} 135 | 136 | return i.send(client) 137 | } 138 | 139 | // ///////////////////////////////////////////////////////////////////////////// 140 | // NonceProof 141 | // ///////////////////////////////////////////////////////////////////////////// 142 | type serverNonceProof struct { 143 | EncryptedNonce string `json:"encrypted_nonce"` 144 | } 145 | 146 | func (n *serverNonceProof) process(client *Client) error { 147 | plaintext, err := client.decrypt(n.EncryptedNonce) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | rawProof := sha256.Sum256(plaintext) 153 | // The [:] syntax is to return an unsized slice as the sum function returns 154 | // one. 155 | proof := base64.RawURLEncoding.EncodeToString(rawProof[:]) 156 | 157 | c := clientNonceProof{Proof: proof} 158 | 159 | return c.send(client) 160 | } 161 | 162 | // ///////////////////////////////////////////////////////////////////////////// 163 | // HeartbeatAck 164 | // ///////////////////////////////////////////////////////////////////////////// 165 | type serverHeartbeatAck struct{} 166 | 167 | func (h *serverHeartbeatAck) process(client *Client) error { 168 | client.heartbeats -= 1 169 | 170 | return nil 171 | } 172 | 173 | // ///////////////////////////////////////////////////////////////////////////// 174 | // PendingRemoteInit 175 | // ///////////////////////////////////////////////////////////////////////////// 176 | type serverPendingRemoteInit struct { 177 | Fingerprint string `json:"fingerprint"` 178 | } 179 | 180 | func (p *serverPendingRemoteInit) process(client *Client) error { 181 | url := "https://discordapp.com/ra/" + p.Fingerprint 182 | 183 | client.qrChan <- url 184 | close(client.qrChan) 185 | 186 | return nil 187 | } 188 | 189 | // ///////////////////////////////////////////////////////////////////////////// 190 | // PendingFinish 191 | // ///////////////////////////////////////////////////////////////////////////// 192 | type serverPendingTicket struct { 193 | EncryptedUserPayload string `json:"encrypted_user_payload"` 194 | } 195 | 196 | func (p *serverPendingTicket) process(client *Client) error { 197 | plaintext, err := client.decrypt(p.EncryptedUserPayload) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | return client.user.update(string(plaintext)) 203 | } 204 | 205 | // ///////////////////////////////////////////////////////////////////////////// 206 | // Finish 207 | // ///////////////////////////////////////////////////////////////////////////// 208 | type serverPendingLogin struct { 209 | Ticket string `json:"ticket"` 210 | } 211 | 212 | func (p *serverPendingLogin) process(client *Client) error { 213 | sess, err := discordgo.New("") 214 | if err != nil { 215 | return err 216 | } 217 | encryptedToken, err := sess.RemoteAuthLogin(p.Ticket) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | plaintext, err := client.decrypt(encryptedToken) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | client.user.Token = string(plaintext) 228 | 229 | client.close() 230 | 231 | return nil 232 | } 233 | 234 | // ///////////////////////////////////////////////////////////////////////////// 235 | // Cancel 236 | // ///////////////////////////////////////////////////////////////////////////// 237 | type serverCancel struct{} 238 | 239 | func (c *serverCancel) process(client *Client) error { 240 | client.close() 241 | 242 | return nil 243 | } 244 | -------------------------------------------------------------------------------- /remoteauth/user.go: -------------------------------------------------------------------------------- 1 | package remoteauth 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type User struct { 9 | UserID string 10 | Discriminator string 11 | AvatarHash string 12 | Username string 13 | 14 | Token string 15 | } 16 | 17 | func (u *User) update(payload string) error { 18 | parts := strings.Split(payload, ":") 19 | if len(parts) != 4 { 20 | return fmt.Errorf("expected 4 parts but got %d", len(parts)) 21 | } 22 | 23 | u.UserID = parts[0] 24 | u.Discriminator = parts[1] 25 | u.AvatarHash = parts[2] 26 | u.Username = parts[3] 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /thread.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/bwmarrin/discordgo" 9 | "github.com/rs/zerolog" 10 | "golang.org/x/exp/slices" 11 | "maunium.net/go/mautrix/id" 12 | 13 | "go.mau.fi/mautrix-discord/database" 14 | ) 15 | 16 | type Thread struct { 17 | *database.Thread 18 | Parent *Portal 19 | 20 | creationNoticeLock sync.Mutex 21 | initialBackfillAttempted bool 22 | } 23 | 24 | func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread { 25 | br.threadsLock.Lock() 26 | defer br.threadsLock.Unlock() 27 | thread, ok := br.threadsByID[id] 28 | if !ok { 29 | return br.loadThread(br.DB.Thread.GetByDiscordID(id), id, root) 30 | } 31 | return thread 32 | } 33 | 34 | func (br *DiscordBridge) GetThreadByRootMXID(mxid id.EventID) *Thread { 35 | br.threadsLock.Lock() 36 | defer br.threadsLock.Unlock() 37 | thread, ok := br.threadsByRootMXID[mxid] 38 | if !ok { 39 | return br.loadThread(br.DB.Thread.GetByMatrixRootMsg(mxid), "", nil) 40 | } 41 | return thread 42 | } 43 | 44 | func (br *DiscordBridge) GetThreadByRootOrCreationNoticeMXID(mxid id.EventID) *Thread { 45 | br.threadsLock.Lock() 46 | defer br.threadsLock.Unlock() 47 | thread, ok := br.threadsByRootMXID[mxid] 48 | if !ok { 49 | thread, ok = br.threadsByCreationNoticeMXID[mxid] 50 | if !ok { 51 | return br.loadThread(br.DB.Thread.GetByMatrixRootOrCreationNoticeMsg(mxid), "", nil) 52 | } 53 | } 54 | return thread 55 | } 56 | 57 | func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *database.Message) *Thread { 58 | if dbThread == nil { 59 | if root == nil { 60 | return nil 61 | } 62 | dbThread = br.DB.Thread.New() 63 | dbThread.ID = id 64 | dbThread.RootDiscordID = root.DiscordID 65 | dbThread.RootMXID = root.MXID 66 | dbThread.ParentID = root.Channel.ChannelID 67 | dbThread.Insert() 68 | } 69 | thread := &Thread{ 70 | Thread: dbThread, 71 | } 72 | thread.Parent = br.GetExistingPortalByID(database.NewPortalKey(thread.ParentID, "")) 73 | br.threadsByID[thread.ID] = thread 74 | br.threadsByRootMXID[thread.RootMXID] = thread 75 | if thread.CreationNoticeMXID != "" { 76 | br.threadsByCreationNoticeMXID[thread.CreationNoticeMXID] = thread 77 | } 78 | return thread 79 | } 80 | 81 | func (br *DiscordBridge) threadFound(ctx context.Context, source *User, rootMessage *database.Message, id string, metadata *discordgo.Channel) { 82 | thread := br.GetThreadByID(id, rootMessage) 83 | log := zerolog.Ctx(ctx) 84 | log.Debug().Msg("Marked message as thread root") 85 | if thread.CreationNoticeMXID == "" { 86 | thread.Parent.sendThreadCreationNotice(ctx, thread) 87 | } 88 | // TODO member_ids_preview is probably not guaranteed to contain the source user 89 | if source != nil && metadata != nil && slices.Contains(metadata.MemberIDsPreview, source.DiscordID) && !source.IsInPortal(thread.ID) { 90 | source.MarkInPortal(database.UserPortal{ 91 | DiscordID: thread.ID, 92 | Type: database.UserPortalTypeThread, 93 | Timestamp: time.Now(), 94 | }) 95 | if metadata.MessageCount > 0 { 96 | go thread.maybeInitialBackfill(source) 97 | } else { 98 | thread.initialBackfillAttempted = true 99 | } 100 | } 101 | } 102 | 103 | func (thread *Thread) maybeInitialBackfill(source *User) { 104 | if thread.initialBackfillAttempted || thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread == 0 { 105 | return 106 | } 107 | thread.Parent.forwardBackfillLock.Lock() 108 | if thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) != nil { 109 | thread.Parent.forwardBackfillLock.Unlock() 110 | return 111 | } 112 | thread.Parent.forwardBackfillInitial(source, thread) 113 | } 114 | 115 | func (thread *Thread) RefererOpt() discordgo.RequestOption { 116 | return discordgo.WithThreadReferer(thread.Parent.GuildID, thread.ParentID, thread.ID) 117 | } 118 | 119 | func (thread *Thread) Join(user *User) { 120 | if user.IsInPortal(thread.ID) { 121 | return 122 | } 123 | log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger() 124 | log.Debug().Msg("Joining thread") 125 | 126 | var doBackfill, backfillStarted bool 127 | if !thread.initialBackfillAttempted && thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread > 0 { 128 | thread.Parent.forwardBackfillLock.Lock() 129 | lastMessage := thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) 130 | if lastMessage != nil { 131 | thread.Parent.forwardBackfillLock.Unlock() 132 | } else { 133 | doBackfill = true 134 | defer func() { 135 | if !backfillStarted { 136 | thread.Parent.forwardBackfillLock.Unlock() 137 | } 138 | }() 139 | } 140 | } 141 | 142 | var err error 143 | if user.Session.IsUser { 144 | err = user.Session.ThreadJoin(thread.ID, discordgo.WithLocationParam(discordgo.ThreadJoinLocationContextMenu), thread.RefererOpt()) 145 | } else { 146 | err = user.Session.ThreadJoin(thread.ID) 147 | } 148 | if err != nil { 149 | log.Error().Err(err).Msg("Error joining thread") 150 | } else { 151 | user.MarkInPortal(database.UserPortal{ 152 | DiscordID: thread.ID, 153 | Type: database.UserPortalTypeThread, 154 | Timestamp: time.Now(), 155 | }) 156 | if doBackfill { 157 | go thread.Parent.forwardBackfillInitial(user, thread) 158 | backfillStarted = true 159 | } 160 | } 161 | } 162 | --------------------------------------------------------------------------------