├── .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, `
`, 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 |
--------------------------------------------------------------------------------