mirror of
https://github.com/mautrix/discord.git
synced 2026-05-14 13:16:55 -04:00
Compare commits
246 commits
main
...
megadiscor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12aea6a534 | ||
|
|
5f1cbbce5f | ||
|
|
4fc08f5fc9 | ||
|
|
e456929f66 | ||
|
|
37156a3ba2 | ||
|
|
d1996fbf44 | ||
|
|
a92fe234c1 | ||
|
|
98f61ad97a | ||
|
|
3fb27b7ed5 | ||
|
|
44360fedf0 | ||
|
|
1ab2788861 | ||
|
|
d01d6f9edd | ||
|
|
ba3df6fec2 | ||
|
|
6ab8144feb | ||
|
|
04c90e0875 | ||
|
|
da6a633470 | ||
|
|
6ea9ef2013 | ||
|
|
80a46e1fa7 | ||
|
|
ed7fd2b380 | ||
|
|
7ee769714e | ||
|
|
81a99e9dda | ||
|
|
85072b04bf | ||
|
|
f179e50b47 | ||
|
|
c7d16fb9ca | ||
|
|
d6d91c415c | ||
|
|
5d3715341d | ||
|
|
ae2321805c | ||
|
|
ecd1fabd37 | ||
|
|
d58edf7b65 | ||
|
|
bef6e4b9e8 | ||
|
|
032ac4440b | ||
|
|
0c28feadea | ||
|
|
af4cf49217 | ||
|
|
d138c12bbd | ||
|
|
d20451cea8 | ||
|
|
e824270244 | ||
|
|
36d918670a | ||
|
|
6ccb507298 | ||
|
|
1d77d59fd5 | ||
|
|
a0a049f527 | ||
|
|
a62136eae2 | ||
|
|
26546297bb | ||
|
|
04fbf2870b | ||
|
|
356b66224e | ||
|
|
99b25c58f9 | ||
|
|
2df7bf7886 | ||
|
|
5dbbe625c7 | ||
|
|
9dc717ff45 | ||
|
|
9ac70fd1bd | ||
|
|
754159ed81 | ||
|
|
65478d4424 | ||
|
|
bb787717ee | ||
|
|
8a98bd2b25 | ||
|
|
6f148b0bbf | ||
|
|
a58e88cf93 | ||
|
|
a627d2d510 | ||
|
|
d8f3ed3e36 | ||
|
|
289f92660f | ||
|
|
654e3fd898 | ||
|
|
82b1645e00 | ||
|
|
c88ffbc9ba | ||
|
|
d40b1fc68e |
||
|
|
dd2c2ec0c9 | ||
|
|
744ba4190b | ||
|
|
8270a9462d | ||
|
|
134c5ed446 | ||
|
|
b71744a287 | ||
|
|
672f4b8c23 | ||
|
|
c1b90ef84e | ||
|
|
f041f78c09 | ||
|
|
f796e4cef6 | ||
|
|
961a89beba | ||
|
|
a2c65ee47c | ||
|
|
1dce5fa9ff | ||
|
|
2b3b058750 | ||
|
|
cb9bf5b04b | ||
|
|
7363538bae | ||
|
|
3ae8e8d585 | ||
|
|
1534b2b984 | ||
|
|
6959888bbd | ||
|
|
03fb7bc83e | ||
|
|
02b35812ab | ||
|
|
1c8bb3531d | ||
|
|
4f2dc2bf30 | ||
|
|
edcad997ac | ||
|
|
cd1f9ddd85 | ||
|
|
51473cf210 | ||
|
|
2e7e39a91c | ||
|
|
261d041ebb | ||
|
|
a06b09f201 | ||
|
|
2cd22e0100 | ||
|
|
9719c6d1d5 | ||
|
|
833085bd95 | ||
|
|
3f5993e6e4 | ||
|
|
0c0aa7e212 | ||
|
|
1bb9b0c244 | ||
|
|
35dcea1f03 | ||
|
|
a9eccd574d | ||
|
|
25cf272ca1 | ||
|
|
03d733da42 | ||
|
|
4c14457c52 | ||
|
|
7e01e8aec3 | ||
|
|
55501bf50f | ||
|
|
3c53f7ed2d | ||
|
|
a2933d989b | ||
|
|
cfe1d24d19 | ||
|
|
786a6712fd | ||
|
|
8e51cf25aa | ||
|
|
c1f82b9157 | ||
|
|
1ead5baf2f | ||
|
|
5d9e2373d9 | ||
|
|
28f20d8f62 | ||
|
|
a1ad290a29 | ||
|
|
09ace9ce8b | ||
|
|
bc50b3c79d | ||
|
|
7e20338ea9 | ||
|
|
eae5128f91 | ||
|
|
6ff087ef60 | ||
|
|
661234452f | ||
|
|
343ae04ea6 | ||
|
|
55c0272903 | ||
|
|
6a2d8d569e | ||
|
|
ea3052e567 | ||
|
|
0e1ff96e62 | ||
|
|
fa25e2784a | ||
|
|
1bebafcc3d | ||
|
|
19cfff24d2 | ||
|
|
91f8d2b269 | ||
|
|
1014576dc0 | ||
|
|
dfa9c52974 | ||
|
|
04c15d15a7 | ||
|
|
66badc0709 | ||
|
|
d36528400d | ||
|
|
c80fba31d6 | ||
|
|
aba6f5aafc | ||
|
|
6407a3e3e0 | ||
|
|
40ae884e7f | ||
|
|
07ba87f9d6 | ||
|
|
82aab381ab | ||
|
|
c8561de9c4 | ||
|
|
9013e01b49 | ||
|
|
7a6f59ad73 | ||
|
|
2ddba507c2 | ||
|
|
abcc0dca47 | ||
|
|
2310d2c036 | ||
|
|
1fcc910184 | ||
|
|
808993c174 | ||
|
|
a1d4c4cb28 | ||
|
|
ce6404ac78 | ||
|
|
7cfa17023b | ||
|
|
d8ca44ecd9 | ||
|
|
c611e8f116 | ||
|
|
a7ae544999 | ||
|
|
4f420c4662 | ||
|
|
4bdb0de559 | ||
|
|
869d8c5412 | ||
|
|
094bc9bd77 | ||
|
|
36c23bef87 | ||
|
|
6adf319cfb | ||
|
|
9dfc91ff14 | ||
|
|
47095f1993 | ||
|
|
1900993acd | ||
|
|
2682175508 | ||
|
|
8c02a80f85 | ||
|
|
92352ce603 | ||
|
|
e7554b212f | ||
|
|
7d26eae8e5 | ||
|
|
f3a797d5e5 | ||
|
|
d89746d099 | ||
|
|
1a3144d2d0 | ||
|
|
b8a01bf9d4 | ||
|
|
578030a9dd | ||
|
|
2f8de6635a | ||
|
|
9b3ead7186 | ||
|
|
138c77c34e | ||
|
|
5e0f9b909a | ||
|
|
b4fdd8b9ed | ||
|
|
689f8b9998 | ||
|
|
7849c09443 | ||
|
|
5b7a7a430c | ||
|
|
ac338ee722 | ||
|
|
bdbfd661a2 | ||
|
|
3d59a0eb3f | ||
|
|
e38998e68b | ||
|
|
f5292e6a7d | ||
|
|
86544bc7af | ||
|
|
fdcfb2b083 | ||
|
|
bfebeeb7e5 | ||
|
|
4fb0cdb847 | ||
|
|
b764f489de | ||
|
|
8a28fa0f95 | ||
|
|
4314aa9206 | ||
|
|
761a850a50 | ||
|
|
ca1168bfc2 | ||
|
|
e71075cd0d | ||
|
|
cbfbe65619 | ||
|
|
c015148b63 | ||
|
|
1fb161f379 | ||
|
|
b18d908489 | ||
|
|
099b464f84 | ||
|
|
2075a4b853 | ||
|
|
776ddd7c96 | ||
|
|
2c669413cc | ||
|
|
0c82f6551d | ||
|
|
d79406e05b | ||
|
|
7a19f09683 | ||
|
|
e030c9548c | ||
|
|
2cacd4ec81 | ||
|
|
09414cb59d | ||
|
|
d82b74fb29 | ||
|
|
60171b4fca | ||
|
|
ab82f8b131 | ||
|
|
506f42f93b | ||
|
|
25b73bd7cb | ||
|
|
d464cb8b66 | ||
|
|
7b32aad13f | ||
|
|
b5e6db06f8 | ||
|
|
86e18c1f7d | ||
|
|
17fed9aca5 | ||
|
|
66d9ca6394 | ||
|
|
45dae8fafb | ||
|
|
5fa9645012 | ||
|
|
114df5f2a2 | ||
|
|
31c1cdda0c | ||
|
|
56f05bc02c | ||
|
|
f8b65fe1f0 | ||
|
|
61ef0c1051 | ||
|
|
ab68fae8dd | ||
|
|
063b9d00dd | ||
|
|
8c8f029e11 | ||
|
|
ae98d58dbe | ||
|
|
c15fd3fc82 | ||
|
|
aecc5234e6 | ||
|
|
1c599a33bc | ||
|
|
91edeb6054 | ||
|
|
1442b356f2 | ||
|
|
f04a8658d9 | ||
|
|
4e41c2f227 | ||
|
|
bc13724b0a | ||
|
|
586cb2bfe6 | ||
|
|
e0e18d7822 | ||
|
|
cda3a84ea5 | ||
|
|
fed9bc7655 | ||
|
|
4f1ae630fc | ||
|
|
0a7b8bf41b | ||
|
|
64c92ca783 |
151 changed files with 13000 additions and 13091 deletions
|
|
@ -11,5 +11,8 @@ insert_final_newline = true
|
|||
[*.{yaml,yml,sql}]
|
||||
indent_style = space
|
||||
|
||||
[*.html]
|
||||
indent_size = 2
|
||||
|
||||
[.gitlab-ci.yml]
|
||||
indent_size = 2
|
||||
|
|
|
|||
17
.github/workflows/go.yml
vendored
17
.github/workflows/go.yml
vendored
|
|
@ -2,14 +2,17 @@ name: Go
|
|||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go-version: ["1.24", "1.25"]
|
||||
name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }}
|
||||
go-version: ["1.25", "1.26"]
|
||||
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -23,13 +26,11 @@ jobs:
|
|||
- name: Install libolm
|
||||
run: sudo apt-get install libolm-dev libolm3
|
||||
|
||||
- name: Install goimports
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
export PATH="$HOME/go/bin:$PATH"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit
|
||||
|
||||
- name: Lint
|
||||
run: pre-commit run -a
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
include:
|
||||
- project: 'mautrix/ci'
|
||||
file: '/go.yml'
|
||||
file: '/gov2-as-default.yml'
|
||||
|
|
|
|||
16
.idea/icon.svg
generated
Normal file
16
.idea/icon.svg
generated
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
width="128"
|
||||
height="128"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs8" />
|
||||
<path
|
||||
fill="#5865f2"
|
||||
d="m 108.12978,23.89 a 105.15,105.15 0 0 0 -26.230005,-8.07 72.06,72.06 0 0 0 -3.36,6.83 97.68,97.68 0 0 0 -29.11,0 72.37,72.37 0 0 0 -3.36,-6.83 105.89,105.89 0 0 0 -26.25,8.09 C 3.2197751,48.47 -1.2802249,72.42 0.96977514,96.03 v 0 a 105.73,105.73 0 0 0 32.16999986,16.15 77.7,77.7 0 0 0 6.89,-11.11 68.42,68.42 0 0 1 -10.85,-5.18 c 0.91,-0.66 1.8,-1.34 2.66,-2 a 75.57,75.57 0 0 0 64.32,0 c 0.87,0.71 1.76,1.39 2.66,2 a 68.68,68.68 0 0 1 -10.87,5.19 77,77 0 0 0 6.89,11.1 105.25,105.25 0 0 0 32.190005,-16.14 v 0 c 2.64,-27.38 -4.51,-51.11 -18.9,-72.15 z M 42.879775,81.51 c -6.27,0 -11.45,-5.69 -11.45,-12.69 0,-7 5,-12.74 11.43,-12.74 6.43,0 11.57,5.74 11.46,12.74 -0.11,7 -5.05,12.69 -11.44,12.69 z m 42.24,0 c -6.28,0 -11.44,-5.69 -11.44,-12.69 0,-7 5,-12.74 11.44,-12.74 6.44,0 11.54,5.74 11.43,12.74 -0.11,7 -5.04,12.69 -11.43,12.69 z"
|
||||
id="path2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude_types: [markdown]
|
||||
|
|
@ -9,12 +9,18 @@ repos:
|
|||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||
rev: v1.0.0-rc.1
|
||||
rev: v1.0.0-rc.4
|
||||
hooks:
|
||||
- id: go-imports-repo
|
||||
args:
|
||||
- "-local"
|
||||
- "go.mau.fi/mautrix-discord"
|
||||
- "-w"
|
||||
- id: go-vet-repo-mod
|
||||
- id: go-staticcheck-repo-mod
|
||||
|
||||
- repo: https://github.com/beeper/pre-commit-go
|
||||
rev: v0.3.1
|
||||
rev: v0.4.2
|
||||
hooks:
|
||||
- id: zerolog-ban-msgf
|
||||
- id: zerolog-use-stringer
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ ENV UID=1337 \
|
|||
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
|
||||
|
||||
COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
|
||||
COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
|
||||
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||
VOLUME /data
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottiec
|
|||
|
||||
ARG EXECUTABLE=./mautrix-discord
|
||||
COPY $EXECUTABLE /usr/bin/mautrix-discord
|
||||
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
|
||||
COPY ./docker-run.sh /docker-run.sh
|
||||
VOLUME /data
|
||||
|
||||
|
|
|
|||
12
LICENSE.exceptions
Normal file
12
LICENSE.exceptions
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
The mautrix-discord developers grant the following special exceptions:
|
||||
|
||||
* to Beeper the right to embed the program in the Beeper clients and servers,
|
||||
and use and distribute the collective work without applying the license to
|
||||
the whole.
|
||||
* to Element the right to distribute compiled binaries of the program as a part
|
||||
of the Element Server Suite and other server bundles without applying the
|
||||
license.
|
||||
|
||||
All exceptions are only valid under the condition that any modifications to
|
||||
the source code of mautrix-discord remain publicly available under the terms
|
||||
of the GNU AGPL version 3 or later.
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
# mautrix-discord
|
||||
|
||||
> [!CAUTION]
|
||||
> This branch houses a work-in-progress rewrite of the bridge to interface with
|
||||
> [Megabridge/"bridgev2"][bridgev2]. This branch is **NOT** ready for general
|
||||
> consumption, especially for self-hosting.
|
||||
|
||||
[bridgev2]: https://github.com/mautrix/go/tree/38278ef37d199d3a9deba04b825a094eea6c1d10/bridgev2/unorganized-docs
|
||||
|
||||
A Matrix-Discord puppeting bridge based on [discordgo](https://github.com/bwmarrin/discordgo).
|
||||
|
||||
## Documentation
|
||||
|
|
|
|||
79
ROADMAP.md
79
ROADMAP.md
|
|
@ -1,24 +1,24 @@
|
|||
# Features & roadmap
|
||||
* Matrix → Discord
|
||||
* [ ] Message content
|
||||
* [x] Plain text
|
||||
* [x] Formatted messages
|
||||
* [x] Media/files
|
||||
* [x] Replies
|
||||
* [x] Threads
|
||||
* [ ] Plain text
|
||||
* [ ] Formatted messages
|
||||
* [ ] Media/files
|
||||
* [ ] Replies
|
||||
* [ ] Threads
|
||||
* [ ] Custom emojis
|
||||
* [x] Message redactions
|
||||
* [x] Reactions
|
||||
* [x] Unicode emojis
|
||||
* [ ] Message redactions
|
||||
* [ ] Reactions
|
||||
* [ ] Unicode emojis
|
||||
* [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works)
|
||||
* [ ] Executing Discord bot commands
|
||||
* [x] Basic arguments and subcommands
|
||||
* [ ] Basic arguments and subcommands
|
||||
* [ ] Subcommand groups
|
||||
* [ ] Mention arguments
|
||||
* [ ] Attachment arguments
|
||||
* [ ] Presence
|
||||
* [x] Typing notifications
|
||||
* [x] Own read status
|
||||
* [ ] Typing notifications
|
||||
* [ ] Own read status
|
||||
* [ ] Power level
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
|
|
@ -31,37 +31,37 @@
|
|||
* [ ] Initial room metadata
|
||||
* Discord → Matrix
|
||||
* [ ] Message content
|
||||
* [x] Plain text
|
||||
* [x] Formatted messages
|
||||
* [x] Media/files
|
||||
* [x] Replies
|
||||
* [x] Threads
|
||||
* [x] Auto-joining threads when opening
|
||||
* [ ] Plain text
|
||||
* [ ] Formatted messages
|
||||
* [ ] Media/files
|
||||
* [ ] Replies
|
||||
* [ ] Threads
|
||||
* [ ] Auto-joining threads when opening
|
||||
* [ ] Backfilling threads after joining
|
||||
* [x] Custom emojis
|
||||
* [x] Embeds
|
||||
* [ ] Custom emojis
|
||||
* [ ] Embeds
|
||||
* [ ] Interactive components
|
||||
* [x] Interactions (commands)
|
||||
* [x] @everyone/@here mentions into @room
|
||||
* [x] Message deletions
|
||||
* [x] Reactions
|
||||
* [x] Unicode emojis
|
||||
* [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
|
||||
* [x] Avatars
|
||||
* [ ] Interactions (commands)
|
||||
* [ ] @everyone/@here mentions into @room
|
||||
* [ ] Message deletions
|
||||
* [ ] Reactions
|
||||
* [ ] Unicode emojis
|
||||
* [ ] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
|
||||
* [ ] Avatars
|
||||
* [ ] Presence
|
||||
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
|
||||
* [x] Own read status
|
||||
* [ ] Own read status
|
||||
* [ ] Role permissions
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
* [ ] Join
|
||||
* [ ] Leave
|
||||
* [ ] Kick
|
||||
* [x] Channel/group DM metadata changes
|
||||
* [x] Title
|
||||
* [x] Avatar
|
||||
* [x] Description
|
||||
* [x] Initial channel/group DM metadata
|
||||
* [ ] Channel/group DM metadata changes
|
||||
* [ ] Title
|
||||
* [ ] Avatar
|
||||
* [ ] Description
|
||||
* [ ] Initial channel/group DM metadata
|
||||
* [ ] User metadata changes
|
||||
* [ ] Display name
|
||||
* [ ] Avatar
|
||||
|
|
@ -69,11 +69,12 @@
|
|||
* [ ] Display name
|
||||
* [ ] Avatar
|
||||
* Misc
|
||||
* [x] Login methods
|
||||
* [x] QR scan from mobile
|
||||
* [x] Manually providing access token
|
||||
* [x] Automatic portal creation
|
||||
* [x] After login
|
||||
* [x] When receiving DM
|
||||
* [ ] Login methods
|
||||
* [ ] QR scan from mobile
|
||||
* [ ] Username/password
|
||||
* [ ] Manually providing access token
|
||||
* [ ] Automatic portal creation
|
||||
* [ ] After login
|
||||
* [ ] When receiving DM
|
||||
* [ ] Private chat creation by inviting Matrix puppet of Discord user to new room
|
||||
* [x] Option to use own Matrix account for messages sent from other Discord clients
|
||||
* [ ] Option to use own Matrix account for messages sent from other Discord clients
|
||||
|
|
|
|||
353
attachments.go
353
attachments.go
|
|
@ -1,353 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"go.mau.fi/util/exsync"
|
||||
"go.mau.fi/util/ffmpeg"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
)
|
||||
|
||||
func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range discordgo.DroidDownloadHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode > 300 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
|
||||
}
|
||||
if resp.Header.Get("Content-Length") != "" {
|
||||
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse content length: %w", err)
|
||||
} else if length > maxSize {
|
||||
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
} else {
|
||||
var mbe *http.MaxBytesError
|
||||
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
|
||||
if err != nil && errors.As(err, &mbe) {
|
||||
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error {
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range discordgo.DroidBaseHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Referer", "https://discord.com/")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode > 300 {
|
||||
respData, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
|
||||
var file *event.EncryptedFileInfo
|
||||
rawMXC := content.URL
|
||||
|
||||
if content.File != nil {
|
||||
file = content.File
|
||||
rawMXC = file.URL
|
||||
}
|
||||
|
||||
mxc, err := rawMXC.Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := intent.DownloadBytes(mxc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
err = file.DecryptInPlace(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
|
||||
dbFile := br.DB.File.New()
|
||||
dbFile.Timestamp = time.Now()
|
||||
dbFile.URL = url
|
||||
dbFile.ID = meta.AttachmentID
|
||||
dbFile.EmojiName = meta.EmojiName
|
||||
dbFile.Size = len(data)
|
||||
dbFile.MimeType = mimetype.Detect(data).String()
|
||||
if meta.MimeType == "" {
|
||||
meta.MimeType = dbFile.MimeType
|
||||
}
|
||||
if strings.HasPrefix(meta.MimeType, "image/") {
|
||||
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
||||
dbFile.Width = cfg.Width
|
||||
dbFile.Height = cfg.Height
|
||||
}
|
||||
|
||||
uploadMime := meta.MimeType
|
||||
if encrypt {
|
||||
dbFile.Encrypted = true
|
||||
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
|
||||
dbFile.DecryptionInfo.EncryptInPlace(data)
|
||||
uploadMime = "application/octet-stream"
|
||||
}
|
||||
req := mautrix.ReqUploadMedia{
|
||||
ContentBytes: data,
|
||||
ContentType: uploadMime,
|
||||
}
|
||||
if br.Config.Homeserver.AsyncMedia {
|
||||
resp, err := intent.CreateMXC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbFile.MXC = resp.ContentURI
|
||||
req.MXC = resp.ContentURI
|
||||
req.UnstableUploadURL = resp.UnstableUploadURL
|
||||
semaWg.Add(1)
|
||||
go func() {
|
||||
defer semaWg.Done()
|
||||
_, err = intent.UploadMedia(req)
|
||||
if err != nil {
|
||||
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
|
||||
dbFile.Delete()
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
uploaded, err := intent.UploadMedia(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbFile.MXC = uploaded.ContentURI
|
||||
}
|
||||
return dbFile, nil
|
||||
}
|
||||
|
||||
type AttachmentMeta struct {
|
||||
AttachmentID string
|
||||
MimeType string
|
||||
EmojiName string
|
||||
CopyIfMissing bool
|
||||
Converter func([]byte) ([]byte, string, error)
|
||||
}
|
||||
|
||||
var NoMeta = AttachmentMeta{}
|
||||
|
||||
type attachmentKey struct {
|
||||
URL string
|
||||
Encrypt bool
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
|
||||
fps := br.Config.Bridge.AnimatedSticker.Args.FPS
|
||||
width := br.Config.Bridge.AnimatedSticker.Args.Width
|
||||
height := br.Config.Bridge.AnimatedSticker.Args.Height
|
||||
target := br.Config.Bridge.AnimatedSticker.Target
|
||||
var lottieTarget, outputMime string
|
||||
switch target {
|
||||
case "png":
|
||||
lottieTarget = "png"
|
||||
outputMime = "image/png"
|
||||
fps = 1
|
||||
case "gif":
|
||||
lottieTarget = "gif"
|
||||
outputMime = "image/gif"
|
||||
case "webm":
|
||||
lottieTarget = "pngs"
|
||||
outputMime = "video/webm"
|
||||
case "webp":
|
||||
lottieTarget = "pngs"
|
||||
outputMime = "image/webp"
|
||||
case "disable":
|
||||
return data, "application/json", nil
|
||||
default:
|
||||
return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
removErr := os.RemoveAll(tempdir)
|
||||
if removErr != nil {
|
||||
br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr)
|
||||
}
|
||||
}()
|
||||
|
||||
lottieOutput := filepath.Join(tempdir, "out_")
|
||||
if lottieTarget != "pngs" {
|
||||
lottieOutput = filepath.Join(tempdir, "output."+lottieTarget)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps))
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err)
|
||||
}
|
||||
var path string
|
||||
if lottieTarget == "pngs" {
|
||||
var videoCodec string
|
||||
outputExtension := "." + target
|
||||
if target == "webm" {
|
||||
videoCodec = "libvpx-vp9"
|
||||
} else if target == "webp" {
|
||||
videoCodec = "libwebp_anim"
|
||||
} else {
|
||||
panic(fmt.Errorf("impossible case: unknown target %q", target))
|
||||
}
|
||||
path, err = ffmpeg.ConvertPath(
|
||||
ctx, lottieOutput+"*.png", outputExtension,
|
||||
[]string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"},
|
||||
[]string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err)
|
||||
}
|
||||
} else {
|
||||
path = lottieOutput
|
||||
}
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read converted file: %w", err)
|
||||
}
|
||||
return data, outputMime, nil
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
|
||||
isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
|
||||
returnDBFile = br.DB.File.Get(url, encrypt)
|
||||
if returnDBFile == nil {
|
||||
transferKey := attachmentKey{url, encrypt}
|
||||
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{})
|
||||
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
|
||||
if isCacheable {
|
||||
onceDBFile = br.DB.File.Get(url, encrypt)
|
||||
if onceDBFile != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentSizeVal = 1
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
|
||||
cancel()
|
||||
if onceErr != nil {
|
||||
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
|
||||
onceErr = fmt.Errorf("reuploading timed out")
|
||||
return
|
||||
}
|
||||
var semaWg sync.WaitGroup
|
||||
semaWg.Add(1)
|
||||
defer semaWg.Done()
|
||||
go func() {
|
||||
semaWg.Wait()
|
||||
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
|
||||
}()
|
||||
|
||||
var data []byte
|
||||
data, onceErr = downloadDiscordAttachment(http.DefaultClient, url, br.MediaConfig.UploadSize)
|
||||
if onceErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if meta.Converter != nil {
|
||||
data, meta.MimeType, onceErr = meta.Converter(data)
|
||||
if onceErr != nil {
|
||||
onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg)
|
||||
if onceErr != nil {
|
||||
return
|
||||
}
|
||||
if isCacheable {
|
||||
onceDBFile.Insert(nil)
|
||||
}
|
||||
br.attachmentTransfers.Delete(transferKey)
|
||||
return
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
|
||||
mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated)
|
||||
if !mxc.IsEmpty() {
|
||||
return mxc
|
||||
}
|
||||
var url, mimeType string
|
||||
if animated {
|
||||
url = discordgo.EndpointEmojiAnimated(emojiID)
|
||||
mimeType = "image/gif"
|
||||
} else {
|
||||
url = discordgo.EndpointEmoji(emojiID)
|
||||
mimeType = "image/png"
|
||||
}
|
||||
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
|
||||
AttachmentID: emojiID,
|
||||
MimeType: mimeType,
|
||||
EmojiName: name,
|
||||
})
|
||||
if err != nil {
|
||||
portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
|
||||
return id.ContentURI{}
|
||||
}
|
||||
return dbFile.MXC
|
||||
}
|
||||
383
backfill.go
383
backfill.go
|
|
@ -1,383 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
)
|
||||
|
||||
func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) {
|
||||
log := portal.log
|
||||
defer func() {
|
||||
log.Debug().Msg("Forward backfill finished, unlocking lock")
|
||||
portal.forwardBackfillLock.Unlock()
|
||||
}()
|
||||
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
|
||||
if portal.forwardBackfillLock.TryLock() {
|
||||
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
|
||||
}
|
||||
|
||||
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
|
||||
if portal.GuildID == "" {
|
||||
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
|
||||
if thread != nil {
|
||||
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread
|
||||
thread.initialBackfillAttempted = true
|
||||
}
|
||||
}
|
||||
if limit == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
with := log.With().
|
||||
Str("action", "initial backfill").
|
||||
Str("room_id", portal.MXID.String()).
|
||||
Int("limit", limit)
|
||||
if thread != nil {
|
||||
with = with.Str("thread_id", thread.ID)
|
||||
}
|
||||
log = with.Logger()
|
||||
|
||||
portal.backfillLimited(log, source, limit, "", thread)
|
||||
}
|
||||
|
||||
func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) {
|
||||
if portal.MXID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
|
||||
if portal.GuildID == "" {
|
||||
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
|
||||
if thread != nil {
|
||||
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread
|
||||
}
|
||||
}
|
||||
if limit == 0 {
|
||||
return
|
||||
}
|
||||
with := portal.log.With().
|
||||
Str("action", "missed event backfill").
|
||||
Str("room_id", portal.MXID.String()).
|
||||
Int("limit", limit)
|
||||
if thread != nil {
|
||||
with = with.Str("thread_id", thread.ID)
|
||||
}
|
||||
log := with.Logger()
|
||||
|
||||
portal.forwardBackfillLock.Lock()
|
||||
defer portal.forwardBackfillLock.Unlock()
|
||||
|
||||
var lastMessage *database.Message
|
||||
if thread != nil {
|
||||
lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
|
||||
} else {
|
||||
lastMessage = portal.bridge.DB.Message.GetLast(portal.Key)
|
||||
}
|
||||
if lastMessage == nil || serverLastMessageID == "" {
|
||||
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
|
||||
return
|
||||
} else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) {
|
||||
log.Debug().
|
||||
Str("last_bridged_message", lastMessage.DiscordID).
|
||||
Str("last_server_message", serverLastMessageID).
|
||||
Msg("Not backfilling, last message in database is newer than last message in metadata")
|
||||
return
|
||||
}
|
||||
log.Debug().
|
||||
Str("last_bridged_message", lastMessage.DiscordID).
|
||||
Str("last_server_message", serverLastMessageID).
|
||||
Msg("Backfilling missed messages")
|
||||
if limit < 0 {
|
||||
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread)
|
||||
} else {
|
||||
portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread)
|
||||
}
|
||||
}
|
||||
|
||||
const messageFetchChunkSize = 50
|
||||
|
||||
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) {
|
||||
var messages []*discordgo.Message
|
||||
var before string
|
||||
var foundAll bool
|
||||
protoChannelID := portal.Key.ChannelID
|
||||
if thread != nil {
|
||||
protoChannelID = thread.ID
|
||||
}
|
||||
for {
|
||||
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
|
||||
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if until != "" {
|
||||
for i, msg := range newMessages {
|
||||
if compareMessageIDs(msg.ID, until) <= 0 {
|
||||
log.Debug().
|
||||
Str("message_id", msg.ID).
|
||||
Str("until_id", until).
|
||||
Msg("Found message that was already bridged")
|
||||
newMessages = newMessages[:i]
|
||||
foundAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
messages = append(messages, newMessages...)
|
||||
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
|
||||
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
|
||||
break
|
||||
}
|
||||
before = newMessages[len(newMessages)-1].ID
|
||||
}
|
||||
if len(messages) > limit {
|
||||
foundAll = false
|
||||
messages = messages[:limit]
|
||||
}
|
||||
return messages, foundAll, nil
|
||||
}
|
||||
|
||||
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) {
|
||||
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread)
|
||||
if err != nil {
|
||||
if source.handlePossible40002(err) {
|
||||
panic(err)
|
||||
}
|
||||
log.Err(err).Msg("Error collecting messages to forward backfill")
|
||||
return
|
||||
}
|
||||
log.Info().
|
||||
Int("count", len(messages)).
|
||||
Bool("found_all", foundAll).
|
||||
Msg("Collected messages to backfill")
|
||||
sort.Sort(MessageSlice(messages))
|
||||
if !foundAll && after != "" {
|
||||
_, err = portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: "Some messages may have been missed here while the bridge was offline.",
|
||||
}, nil, 0)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to send missed message warning")
|
||||
} else {
|
||||
log.Debug().Msg("Sent warning about possibly missed messages")
|
||||
}
|
||||
}
|
||||
portal.sendBackfillBatch(log, source, messages, thread)
|
||||
}
|
||||
|
||||
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) {
|
||||
protoChannelID := portal.Key.ChannelID
|
||||
if thread != nil {
|
||||
protoChannelID = thread.ID
|
||||
}
|
||||
for {
|
||||
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
|
||||
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
|
||||
return
|
||||
}
|
||||
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
|
||||
sort.Sort(MessageSlice(messages))
|
||||
|
||||
portal.sendBackfillBatch(log, source, messages, thread)
|
||||
|
||||
if len(messages) < messageFetchChunkSize {
|
||||
// Assume that was all the missing messages
|
||||
log.Debug().Msg("Chunk had less than 50 messages, stopping backfill")
|
||||
return
|
||||
}
|
||||
after = messages[len(messages)-1].ID
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
|
||||
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) {
|
||||
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
|
||||
portal.forwardBatchSend(log, source, messages, thread)
|
||||
} else {
|
||||
log.Debug().Msg("Not using hungryserv, sending messages one by one")
|
||||
for _, msg := range messages {
|
||||
portal.handleDiscordMessageCreate(source, msg, thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
|
||||
evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread)
|
||||
if len(evts) == 0 {
|
||||
log.Warn().Msg("Didn't get any events to backfill")
|
||||
return
|
||||
}
|
||||
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
|
||||
resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{
|
||||
Forward: true,
|
||||
Events: evts,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error sending backfill batch")
|
||||
return
|
||||
}
|
||||
for i, evtID := range resp.EventIDs {
|
||||
dbMessages[i].MXID = evtID
|
||||
if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread {
|
||||
// TODO proper context
|
||||
ctx := log.WithContext(context.Background())
|
||||
portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread)
|
||||
}
|
||||
}
|
||||
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
|
||||
}
|
||||
|
||||
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) {
|
||||
var discordThreadID string
|
||||
var threadRootEvent, lastThreadEvent id.EventID
|
||||
if thread != nil {
|
||||
discordThreadID = thread.ID
|
||||
threadRootEvent = thread.RootMXID
|
||||
lastThreadEvent = threadRootEvent
|
||||
lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
|
||||
if lastInThread != nil {
|
||||
lastThreadEvent = lastInThread.MXID
|
||||
}
|
||||
}
|
||||
|
||||
evts := make([]*event.Event, 0, len(messages))
|
||||
dbMessages := make([]database.Message, 0, len(messages))
|
||||
metas := make([]*discordgo.Message, 0, len(messages))
|
||||
ctx := context.Background()
|
||||
for _, msg := range messages {
|
||||
for _, mention := range msg.Mentions {
|
||||
puppet := portal.bridge.GetPuppetByID(mention.ID)
|
||||
puppet.UpdateInfo(nil, mention, nil)
|
||||
}
|
||||
|
||||
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
|
||||
puppet.UpdateInfo(source, msg.Author, msg)
|
||||
intent := puppet.IntentFor(portal)
|
||||
replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true)
|
||||
mentions := portal.convertDiscordMentions(msg, false)
|
||||
|
||||
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
|
||||
log := log.With().
|
||||
Str("message_id", msg.ID).
|
||||
Int("message_type", int(msg.Type)).
|
||||
Str("author_id", msg.Author.ID).
|
||||
Logger()
|
||||
parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg)
|
||||
for i, part := range parts {
|
||||
if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
|
||||
part.Content.RelatesTo = &event.RelatesTo{}
|
||||
}
|
||||
if threadRootEvent != "" {
|
||||
part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
|
||||
}
|
||||
if replyTo != nil {
|
||||
part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
|
||||
// Only set reply for first event
|
||||
replyTo = nil
|
||||
}
|
||||
|
||||
part.Content.Mentions = mentions
|
||||
// Only set mentions for first event, but keep empty object for rest
|
||||
mentions = &event.Mentions{}
|
||||
|
||||
partName := part.AttachmentID
|
||||
// Always use blank part name for first part so that replies and other things
|
||||
// can reference it without knowing about attachments.
|
||||
if i == 0 {
|
||||
partName = ""
|
||||
}
|
||||
evt := &event.Event{
|
||||
ID: portal.deterministicEventID(msg.ID, partName),
|
||||
Type: part.Type,
|
||||
Sender: intent.UserID,
|
||||
Timestamp: ts.UnixMilli(),
|
||||
Content: event.Content{
|
||||
Parsed: part.Content,
|
||||
Raw: part.Extra,
|
||||
},
|
||||
}
|
||||
var err error
|
||||
evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to encrypt event")
|
||||
continue
|
||||
}
|
||||
intent.AddDoublePuppetValue(&evt.Content)
|
||||
evts = append(evts, evt)
|
||||
dbMessages = append(dbMessages, database.Message{
|
||||
Channel: portal.Key,
|
||||
DiscordID: msg.ID,
|
||||
SenderID: msg.Author.ID,
|
||||
Timestamp: ts,
|
||||
AttachmentID: part.AttachmentID,
|
||||
SenderMXID: intent.UserID,
|
||||
})
|
||||
if i == 0 {
|
||||
metas = append(metas, msg)
|
||||
} else {
|
||||
metas = append(metas, nil)
|
||||
}
|
||||
lastThreadEvent = evt.ID
|
||||
}
|
||||
}
|
||||
return evts, metas, dbMessages
|
||||
}
|
||||
|
||||
func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID {
|
||||
data := fmt.Sprintf("%s/discord/%s/%s", portal.MXID, messageID, partName)
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return id.EventID(fmt.Sprintf("$%s:discord.com", base64.RawURLEncoding.EncodeToString(sum[:])))
|
||||
}
|
||||
|
||||
// compareMessageIDs compares two Discord message IDs.
|
||||
//
|
||||
// If the first ID is lower, -1 is returned.
|
||||
// If the second ID is lower, 1 is returned.
|
||||
// If the IDs are equal, 0 is returned.
|
||||
func compareMessageIDs(id1, id2 string) int {
|
||||
if id1 == id2 {
|
||||
return 0
|
||||
}
|
||||
if len(id1) < len(id2) {
|
||||
return -1
|
||||
} else if len(id2) < len(id1) {
|
||||
return 1
|
||||
}
|
||||
if id1 < id2 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool {
|
||||
return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1
|
||||
}
|
||||
|
||||
type MessageSlice []*discordgo.Message
|
||||
|
||||
var _ sort.Interface = (MessageSlice)(nil)
|
||||
|
||||
func (a MessageSlice) Len() int {
|
||||
return len(a)
|
||||
}
|
||||
|
||||
func (a MessageSlice) Swap(i, j int) {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
|
||||
func (a MessageSlice) Less(i, j int) bool {
|
||||
return compareMessageIDs(a[i].ID, a[j].ID) == -1
|
||||
}
|
||||
4
build.sh
4
build.sh
|
|
@ -1,2 +1,4 @@
|
|||
#!/bin/sh
|
||||
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
|
||||
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
||||
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
|
||||
go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@"
|
||||
|
|
|
|||
491
cmd/authtester/main.go
Normal file
491
cmd/authtester/main.go
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/term"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordauth"
|
||||
)
|
||||
|
||||
const fallbackClientBuildNumber = 497254
|
||||
|
||||
var mainJSRegex = regexp.MustCompile(`src="(/assets/web\.[a-f0-9]{12,32}\.js)"`)
|
||||
var buildNumberRegex = regexp.MustCompile(`(?:buildNumber|build_number):\s?['"]?(\d{6,})['"]?`)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var buildNumberFlag int
|
||||
var apiBase string
|
||||
var verbose bool
|
||||
|
||||
flag.IntVar(&buildNumberFlag, "build-number", 0, "Discord client build number (default: auto-detect from discord.com)")
|
||||
flag.StringVar(&apiBase, "api-base", "https://discord.com/api/v9", "Discord API base URL")
|
||||
flag.BoolVar(&verbose, "verbose", false, "Lower the log level to debug")
|
||||
flag.Parse()
|
||||
|
||||
log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).
|
||||
Level(zerolog.InfoLevel).
|
||||
With().
|
||||
Timestamp().
|
||||
Logger()
|
||||
if verbose {
|
||||
log = log.Level(zerolog.DebugLevel)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(log.WithContext(context.Background()), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
captchaServer := newCaptchaServer(log.With().Str("component", "authtester captcha").Logger())
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := captchaServer.Close(shutdownCtx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to gracefully terminate CAPTCHA server: %v\n", err)
|
||||
}
|
||||
}()
|
||||
prompter := newPrompter(os.Stdin, os.Stdout, captchaServer)
|
||||
|
||||
buildNumber := buildNumberFlag
|
||||
if buildNumber == 0 {
|
||||
fmt.Fprintln(os.Stdout, "Detecting an appropriate Discord client build number...")
|
||||
buildNumber, err = fetchClientBuildNumber(ctx, client)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to detect build number automatically: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Falling back to build number %d\n", fallbackClientBuildNumber)
|
||||
buildNumber = fallbackClientBuildNumber
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Using client build number %d\n", buildNumber)
|
||||
|
||||
personality, err := newDefaultPersonality(buildNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth personality: %w", err)
|
||||
}
|
||||
|
||||
machine := discordauth.NewAuthMachine(ctx, client, personality, prompter)
|
||||
machine.APIBase = apiBase
|
||||
if verbose {
|
||||
machine.LogFilters = discordauth.LeakyDevelopmentAuthMachineLogFilters
|
||||
} else {
|
||||
machine.LogFilters = discordauth.DefaultAuthMachineLogFilters
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Preparing Discord auth...")
|
||||
if err = machine.Prepare(ctx); err != nil {
|
||||
return fmt.Errorf("failed to prepare auth machine: %w", err)
|
||||
}
|
||||
|
||||
login, err := prompter.promptRequired("Email or phone")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read login: %w", err)
|
||||
}
|
||||
password, err := prompter.promptSecretRequired("Password")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Logging in...")
|
||||
resp, err := machine.Login(ctx, discordauth.NewCreds(login, password))
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Login succeeded.")
|
||||
fmt.Fprintf(os.Stdout, "User ID: %s\n", resp.UserID)
|
||||
fmt.Fprintf(os.Stdout, "Token length: %d\n", len(resp.Token.UnwrapSensitive()))
|
||||
if resp.UserSettings.Locale != "" {
|
||||
fmt.Fprintf(os.Stdout, "User locale: %s\n", resp.UserSettings.Locale)
|
||||
}
|
||||
if resp.UserSettings.Theme != "" {
|
||||
fmt.Fprintf(os.Stdout, "User theme: %s\n", resp.UserSettings.Theme)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newDefaultPersonality(buildNumber int) (*discordauth.Personality, error) {
|
||||
launchSignature, err := discordgo.NewVanillaSignature()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate launch signature: %w", err)
|
||||
}
|
||||
|
||||
extraHeaders := maps.Clone(discordgo.DroidFetchHeaders)
|
||||
delete(extraHeaders, "User-Agent")
|
||||
|
||||
return &discordauth.Personality{
|
||||
UserAgent: discordgo.DroidBrowserUserAgent,
|
||||
Locale: "en-US",
|
||||
TimeZone: defaultTimeZone(),
|
||||
DebugOptions: discordauth.DefaultDebugOptions,
|
||||
SuperProperties: discordauth.SuperProperties{
|
||||
OS: "Windows",
|
||||
Browser: "Chrome",
|
||||
SystemLocale: "en-US",
|
||||
HasClientMods: false,
|
||||
BrowserUserAgent: discordgo.DroidBrowserUserAgent,
|
||||
BrowserVersion: discordgo.DroidBrowserVersion,
|
||||
OSVersion: "10",
|
||||
ReleaseChannel: "stable",
|
||||
ClientBuildNumber: buildNumber,
|
||||
ClientLaunchID: uuid.NewString(),
|
||||
LaunchSignature: launchSignature,
|
||||
ClientAppState: "focused",
|
||||
},
|
||||
ExtraHeaders: extraHeaders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func defaultTimeZone() string {
|
||||
timeZone := time.Now().Location().String()
|
||||
if timeZone == "" || timeZone == "Local" {
|
||||
return "UTC"
|
||||
}
|
||||
|
||||
return timeZone
|
||||
}
|
||||
|
||||
func fetchClientBuildNumber(ctx context.Context, client *http.Client) (int, error) {
|
||||
mainPageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/channels/@me", nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create main page request: %w", err)
|
||||
}
|
||||
addHeaders(mainPageReq.Header, discordgo.DroidBaseHeaders)
|
||||
mainPageReq.Header.Set("Sec-Fetch-Dest", "document")
|
||||
mainPageReq.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
mainPageReq.Header.Set("Sec-Fetch-Site", "none")
|
||||
mainPageReq.Header.Set("Sec-Fetch-User", "?1")
|
||||
mainPageReq.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
mainPageReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
|
||||
mainPageData, err := doRequest(ctx, client, mainPageReq)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to fetch main page: %w", err)
|
||||
}
|
||||
|
||||
mainJSMatch := mainJSRegex.FindSubmatch(mainPageData)
|
||||
if mainJSMatch == nil {
|
||||
return 0, fmt.Errorf("failed to find main JS URL in Discord main page")
|
||||
}
|
||||
|
||||
jsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com"+string(mainJSMatch[1]), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
addHeaders(jsReq.Header, discordgo.DroidBaseHeaders)
|
||||
jsReq.Header.Set("Sec-Fetch-Dest", "script")
|
||||
jsReq.Header.Set("Sec-Fetch-Mode", "no-cors")
|
||||
jsReq.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
jsReq.Header.Set("Accept", "*/*")
|
||||
|
||||
jsData, err := doRequest(ctx, client, jsReq)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to fetch main JS: %w", err)
|
||||
}
|
||||
|
||||
buildNumberMatch := buildNumberRegex.FindSubmatch(jsData)
|
||||
if buildNumberMatch == nil {
|
||||
return 0, fmt.Errorf("failed to find build number in Discord JS bundle")
|
||||
}
|
||||
|
||||
buildNumber, err := strconv.Atoi(string(buildNumberMatch[1]))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse build number %q: %w", buildNumberMatch[1], err)
|
||||
}
|
||||
|
||||
return buildNumber, nil
|
||||
}
|
||||
|
||||
func doRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s %s response body: %w", req.Method, req.URL, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unexpected status %s for %s %s", resp.Status, req.Method, req.URL)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func addHeaders(header http.Header, values map[string]string) {
|
||||
for key, value := range values {
|
||||
header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
type prompter struct {
|
||||
in *bufio.Reader
|
||||
inFile *os.File
|
||||
out io.Writer
|
||||
captchaServer *captchaServer
|
||||
}
|
||||
|
||||
var _ discordauth.ChallengeHandler = (*prompter)(nil)
|
||||
|
||||
type mfaMethodOption struct {
|
||||
Type discordauth.AuthenticatorType
|
||||
Label string
|
||||
CodePrompt string
|
||||
}
|
||||
|
||||
func newPrompter(in io.Reader, out io.Writer, captchaServer *captchaServer) *prompter {
|
||||
file, _ := in.(*os.File)
|
||||
|
||||
return &prompter{
|
||||
in: bufio.NewReader(in),
|
||||
inFile: file,
|
||||
out: out,
|
||||
captchaServer: captchaServer,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prompter) promptRequired(label string) (string, error) {
|
||||
value, err := p.prompt(label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", strings.ToLower(label))
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *prompter) prompt(label string) (string, error) {
|
||||
fmt.Fprintf(p.out, "%s: ", label)
|
||||
line, err := p.in.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
if errors.Is(err, io.EOF) && len(line) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
func (p *prompter) promptSecretRequired(label string) (string, error) {
|
||||
value, err := p.promptSecret(label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", strings.ToLower(label))
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *prompter) promptSecret(label string) (string, error) {
|
||||
if p.inFile == nil || !term.IsTerminal(int(p.inFile.Fd())) {
|
||||
return p.prompt(label)
|
||||
}
|
||||
|
||||
fmt.Fprintf(p.out, "%s: ", label)
|
||||
line, err := term.ReadPassword(int(p.inFile.Fd()))
|
||||
fmt.Fprintln(p.out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimRight(string(line), "\r\n"), nil
|
||||
}
|
||||
|
||||
func (p *prompter) promptMFAChoice(options []mfaMethodOption) (mfaMethodOption, error) {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Available MFA methods:")
|
||||
for i, option := range options {
|
||||
fmt.Fprintf(p.out, " %d. %s\n", i+1, option.Label)
|
||||
}
|
||||
|
||||
for {
|
||||
choice, err := p.promptRequired("Choose MFA method")
|
||||
if err != nil {
|
||||
return mfaMethodOption{}, err
|
||||
}
|
||||
|
||||
index, err := strconv.Atoi(choice)
|
||||
if err == nil && index >= 1 && index <= len(options) {
|
||||
return options[index-1], nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(p.out, "Invalid choice %q. Enter a number from 1 to %d.\n", choice, len(options))
|
||||
}
|
||||
}
|
||||
|
||||
func supportedMFAMethods(challenge *discordauth.MFAChallenge) []mfaMethodOption {
|
||||
options := make([]mfaMethodOption, 0, 3)
|
||||
if challenge.TOTPEnabled {
|
||||
options = append(options, mfaMethodOption{
|
||||
Type: discordauth.AuthenticatorTOTP,
|
||||
Label: "TOTP authenticator",
|
||||
CodePrompt: "TOTP code",
|
||||
})
|
||||
}
|
||||
if challenge.SMSEnabled {
|
||||
options = append(options, mfaMethodOption{
|
||||
Type: discordauth.AuthenticatorSMS,
|
||||
Label: "SMS code",
|
||||
CodePrompt: "SMS code",
|
||||
})
|
||||
}
|
||||
if challenge.BackupCodesAccepted {
|
||||
options = append(options, mfaMethodOption{
|
||||
Type: discordauth.AuthenticatorBackup,
|
||||
Label: "Backup code",
|
||||
CodePrompt: "Backup code",
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func newMFAContinue(challenge *discordauth.MFAChallenge, authType discordauth.AuthenticatorType, code string) *discordauth.MFAContinue {
|
||||
return &discordauth.MFAContinue{
|
||||
Type: authType,
|
||||
MFAContinuation: discordauth.MFAContinuation{
|
||||
MFAState: challenge.MFAState,
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prompter) ContinueMFA(ctx context.Context, challenge *discordauth.MFAChallenge) (*discordauth.MFAContinue, error) {
|
||||
options := supportedMFAMethods(challenge)
|
||||
if len(options) == 0 {
|
||||
if challenge.WebAuthnCredential != nil {
|
||||
panic("authtester does not support WebAuthn MFA")
|
||||
}
|
||||
return nil, fmt.Errorf("discord did not offer a supported MFA method")
|
||||
}
|
||||
|
||||
selected := options[0]
|
||||
if len(options) == 1 {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintf(p.out, "Using MFA method: %s\n", selected.Label)
|
||||
} else {
|
||||
var err error
|
||||
selected, err = p.promptMFAChoice(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
switch selected.Type {
|
||||
case discordauth.AuthenticatorSMS:
|
||||
if challenge.RequestSMS == nil {
|
||||
return nil, fmt.Errorf("discord MFA challenge did not provide an SMS request callback")
|
||||
}
|
||||
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Requesting an MFA SMS code...")
|
||||
resp, err := challenge.RequestSMS(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request SMS code: %w", err)
|
||||
}
|
||||
if resp != nil && resp.Phone != "" {
|
||||
fmt.Fprintf(p.out, "Discord sent an MFA SMS code to %s\n", resp.Phone)
|
||||
} else {
|
||||
fmt.Fprintln(p.out, "Discord sent an MFA SMS code.")
|
||||
}
|
||||
}
|
||||
|
||||
code, err := p.promptSecretRequired(selected.CodePrompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newMFAContinue(challenge, selected.Type, code), nil
|
||||
}
|
||||
|
||||
func (p *prompter) SolveCaptcha(ctx context.Context, captcha *discordauth.Captcha) (*discordauth.CaptchaSolution, error) {
|
||||
captchaData, err := json.MarshalIndent(captcha, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode captcha challenge: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Received CAPTCHA challenge:")
|
||||
fmt.Fprintln(p.out, string(captchaData))
|
||||
|
||||
if p.captchaServer != nil && supportsBrowserCaptcha(captcha) {
|
||||
pageURL, waitForSolution, err := p.captchaServer.startChallenge(captcha)
|
||||
if err != nil {
|
||||
fmt.Fprintf(p.out, "Failed to start local CAPTCHA page: %v\n", err)
|
||||
fmt.Fprintln(p.out, "Falling back to manual token entry.")
|
||||
} else {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Open this page in your browser and solve the CAPTCHA:")
|
||||
fmt.Fprintf(p.out, " %s\n", pageURL)
|
||||
fmt.Fprintln(p.out, "If the page reports an error or you cancel it, authtester will fall back to manual token entry.")
|
||||
|
||||
solution, err := waitForSolution(ctx)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &discordauth.CaptchaSolution{Solution: solution}, nil
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
return nil, err
|
||||
case errors.Is(err, errCaptchaBrowserCanceled):
|
||||
fmt.Fprintln(p.out, "Local CAPTCHA page was canceled.")
|
||||
fmt.Fprintln(p.out, "Falling back to manual token entry.")
|
||||
default:
|
||||
fmt.Fprintf(p.out, "Local CAPTCHA page failed: %v\n", err)
|
||||
fmt.Fprintln(p.out, "Falling back to manual token entry.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(p.out, "Local browser flow only supports hCaptcha challenges with a sitekey.")
|
||||
}
|
||||
|
||||
solution, err := p.promptRequired("CAPTCHA solution")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &discordauth.CaptchaSolution{Solution: solution}, nil
|
||||
}
|
||||
460
cmd/authtester/main_captcha.go
Normal file
460
cmd/authtester/main_captcha.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordauth"
|
||||
)
|
||||
|
||||
var errCaptchaBrowserCanceled = errors.New("captcha browser flow canceled")
|
||||
|
||||
//go:embed main_captcha.html
|
||||
var captchaPageHTML string
|
||||
|
||||
type captchaServer struct {
|
||||
mu sync.Mutex
|
||||
log zerolog.Logger
|
||||
handler http.Handler
|
||||
server *http.Server
|
||||
ln net.Listener
|
||||
baseURL string
|
||||
active *activeCaptcha
|
||||
}
|
||||
|
||||
type activeCaptcha struct {
|
||||
challenge browserCaptchaChallenge
|
||||
resultCh chan captchaBrowserResult
|
||||
}
|
||||
|
||||
type browserCaptchaChallenge struct {
|
||||
ID string `json:"id"`
|
||||
Service discordauth.CaptchaService `json:"service"`
|
||||
SiteKey string `json:"site_key"`
|
||||
RqData string `json:"rqdata,omitempty"`
|
||||
Invisible bool `json:"invisible"`
|
||||
}
|
||||
|
||||
type captchaBrowserResult struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
type captchaSolveRequest struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type captchaCancelRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type captchaErrorRequest struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type captchaErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func newCaptchaServer(log zerolog.Logger) *captchaServer {
|
||||
cs := &captchaServer{log: log}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", cs.handlePage)
|
||||
mux.HandleFunc("/api/challenge", cs.handleChallenge)
|
||||
mux.HandleFunc("/api/solve", cs.handleSolve)
|
||||
mux.HandleFunc("/api/cancel", cs.handleCancel)
|
||||
mux.HandleFunc("/api/error", cs.handleError)
|
||||
cs.handler = mux
|
||||
return cs
|
||||
}
|
||||
|
||||
func supportsBrowserCaptcha(captcha *discordauth.Captcha) bool {
|
||||
return captcha != nil &&
|
||||
captcha.Service == discordauth.CaptchaServiceHCaptcha &&
|
||||
captcha.SiteKey != nil &&
|
||||
strings.TrimSpace(*captcha.SiteKey) != ""
|
||||
}
|
||||
|
||||
func (cs *captchaServer) startChallenge(captcha *discordauth.Captcha) (string, func(context.Context) (string, error), error) {
|
||||
if !supportsBrowserCaptcha(captcha) {
|
||||
return "", nil, fmt.Errorf("browser flow only supports hcaptcha challenges with a sitekey")
|
||||
}
|
||||
if err := cs.ensureStarted(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
challenge := &activeCaptcha{
|
||||
challenge: browserCaptchaChallenge{
|
||||
ID: uuid.NewString(),
|
||||
Service: captcha.Service,
|
||||
SiteKey: strings.TrimSpace(*captcha.SiteKey),
|
||||
Invisible: captcha.Invisible,
|
||||
},
|
||||
resultCh: make(chan captchaBrowserResult, 1),
|
||||
}
|
||||
if captcha.RqData != nil {
|
||||
challenge.challenge.RqData = *captcha.RqData
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
if cs.active != nil {
|
||||
cs.log.Warn().
|
||||
Str("replaced_challenge_id", cs.active.challenge.ID).
|
||||
Msg("Replacing active CAPTCHA challenge before it was resolved")
|
||||
}
|
||||
cs.active = challenge
|
||||
pageURL := cs.baseURL
|
||||
cs.mu.Unlock()
|
||||
|
||||
cs.log.Info().
|
||||
Str("challenge_id", challenge.challenge.ID).
|
||||
Str("captcha_service", string(challenge.challenge.Service)).
|
||||
Bool("captcha_invisible", challenge.challenge.Invisible).
|
||||
Bool("captcha_has_rqdata", challenge.challenge.RqData != "").
|
||||
Str("page_url", pageURL).
|
||||
Msg("Started local CAPTCHA challenge")
|
||||
|
||||
wait := func(ctx context.Context) (string, error) {
|
||||
defer cs.clearActiveChallenge(challenge.challenge.ID)
|
||||
|
||||
select {
|
||||
case result := <-challenge.resultCh:
|
||||
if result.err != nil {
|
||||
cs.log.Warn().
|
||||
Str("challenge_id", challenge.challenge.ID).
|
||||
Err(result.err).
|
||||
Msg("Local CAPTCHA challenge completed with error")
|
||||
return "", result.err
|
||||
}
|
||||
if result.token == "" {
|
||||
return "", fmt.Errorf("browser page returned an empty CAPTCHA token")
|
||||
}
|
||||
cs.log.Info().
|
||||
Str("challenge_id", challenge.challenge.ID).
|
||||
Int("token_length", len(result.token)).
|
||||
Msg("Local CAPTCHA challenge returned a token")
|
||||
return result.token, nil
|
||||
case <-ctx.Done():
|
||||
cs.log.Warn().
|
||||
Str("challenge_id", challenge.challenge.ID).
|
||||
Err(ctx.Err()).
|
||||
Msg("Stopped waiting for local CAPTCHA challenge")
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return pageURL, wait, nil
|
||||
}
|
||||
|
||||
func (cs *captchaServer) ensureStarted() error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if cs.server != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on 127.0.0.1: %w", err)
|
||||
}
|
||||
|
||||
addr := ln.Addr().(*net.TCPAddr)
|
||||
server := &http.Server{
|
||||
Handler: cs.handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
cs.ln = ln
|
||||
cs.server = server
|
||||
cs.baseURL = fmt.Sprintf("http://localhost:%d/", addr.Port)
|
||||
|
||||
cs.log.Info().
|
||||
Str("listen_addr", ln.Addr().String()).
|
||||
Str("page_url", cs.baseURL).
|
||||
Msg("Started local CAPTCHA server")
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
cs.log.Error().Err(err).Msg("Local CAPTCHA server stopped unexpectedly")
|
||||
cs.failActiveChallenge(fmt.Errorf("captcha server stopped unexpectedly: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *captchaServer) Close(ctx context.Context) error {
|
||||
cs.mu.Lock()
|
||||
server := cs.server
|
||||
cs.server = nil
|
||||
cs.ln = nil
|
||||
cs.baseURL = ""
|
||||
cs.active = nil
|
||||
cs.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cs.log.Info().Msg("Shutting down local CAPTCHA server")
|
||||
return server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (cs *captchaServer) clearActiveChallenge(id string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if cs.active != nil && cs.active.challenge.ID == id {
|
||||
cs.active = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *captchaServer) failActiveChallenge(err error) {
|
||||
cs.log.Error().Err(err).Msg("Failing active CAPTCHA challenge")
|
||||
cs.mu.Lock()
|
||||
active := cs.active
|
||||
cs.active = nil
|
||||
cs.mu.Unlock()
|
||||
|
||||
if active == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case active.resultCh <- captchaBrowserResult{err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *captchaServer) resolveActiveChallenge(id string, result captchaBrowserResult) error {
|
||||
cs.mu.Lock()
|
||||
active := cs.active
|
||||
if active == nil {
|
||||
cs.mu.Unlock()
|
||||
cs.log.Warn().
|
||||
Str("challenge_id", id).
|
||||
Msg("Attempted to resolve CAPTCHA challenge, but none is active")
|
||||
return fmt.Errorf("no active captcha challenge")
|
||||
}
|
||||
if active.challenge.ID != id {
|
||||
cs.mu.Unlock()
|
||||
cs.log.Warn().
|
||||
Str("challenge_id", id).
|
||||
Str("active_challenge_id", active.challenge.ID).
|
||||
Msg("Attempted to resolve a stale CAPTCHA challenge")
|
||||
return fmt.Errorf("captcha challenge is no longer current")
|
||||
}
|
||||
cs.active = nil
|
||||
cs.mu.Unlock()
|
||||
|
||||
select {
|
||||
case active.resultCh <- result:
|
||||
return nil
|
||||
default:
|
||||
cs.log.Warn().
|
||||
Str("challenge_id", id).
|
||||
Msg("CAPTCHA challenge was already resolved")
|
||||
return fmt.Errorf("captcha challenge already resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *captchaServer) currentChallenge() *browserCaptchaChallenge {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if cs.active == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
challenge := cs.active.challenge
|
||||
return &challenge
|
||||
}
|
||||
|
||||
func (cs *captchaServer) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
log := cs.requestLogger(r)
|
||||
if r.Method != http.MethodGet {
|
||||
log.Warn().Msg("Rejected CAPTCHA page request with unsupported method")
|
||||
writeCaptchaMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(captchaPageHTML))
|
||||
log.Info().Msg("Served CAPTCHA page")
|
||||
}
|
||||
|
||||
func (cs *captchaServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
log := cs.requestLogger(r)
|
||||
if r.Method != http.MethodGet {
|
||||
log.Warn().Msg("Rejected CAPTCHA challenge request with unsupported method")
|
||||
writeCaptchaMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
|
||||
challenge := cs.currentChallenge()
|
||||
if challenge == nil {
|
||||
log.Warn().Msg("Requested CAPTCHA challenge, but none is active")
|
||||
writeCaptchaJSON(w, http.StatusNotFound, captchaErrorResponse{Error: "no active captcha challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("challenge_id", challenge.ID).
|
||||
Str("captcha_service", string(challenge.Service)).
|
||||
Bool("captcha_invisible", challenge.Invisible).
|
||||
Bool("captcha_has_rqdata", challenge.RqData != "").
|
||||
Msg("Served active CAPTCHA challenge")
|
||||
writeCaptchaJSON(w, http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
func (cs *captchaServer) handleSolve(w http.ResponseWriter, r *http.Request) {
|
||||
log := cs.requestLogger(r)
|
||||
if r.Method != http.MethodPost {
|
||||
log.Warn().Msg("Rejected CAPTCHA solve request with unsupported method")
|
||||
writeCaptchaMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
|
||||
var req captchaSolveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Warn().Err(err).Msg("Rejected CAPTCHA solve request with invalid JSON body")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ID) == "" {
|
||||
log.Warn().Msg("Rejected CAPTCHA solve request without challenge id")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
|
||||
return
|
||||
}
|
||||
req.Token = strings.TrimSpace(req.Token)
|
||||
if req.Token == "" {
|
||||
log.Warn().
|
||||
Str("challenge_id", req.ID).
|
||||
Msg("Rejected CAPTCHA solve request with empty token")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing captcha token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{token: req.Token}); err != nil {
|
||||
log.Warn().
|
||||
Str("challenge_id", req.ID).
|
||||
Err(err).
|
||||
Msg("Rejected CAPTCHA solve request")
|
||||
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("challenge_id", req.ID).
|
||||
Int("token_length", len(req.Token)).
|
||||
Msg("Accepted CAPTCHA token from browser page")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (cs *captchaServer) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
log := cs.requestLogger(r)
|
||||
if r.Method != http.MethodPost {
|
||||
log.Warn().Msg("Rejected CAPTCHA cancel request with unsupported method")
|
||||
writeCaptchaMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
|
||||
var req captchaCancelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Warn().Err(err).Msg("Rejected CAPTCHA cancel request with invalid JSON body")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ID) == "" {
|
||||
log.Warn().Msg("Rejected CAPTCHA cancel request without challenge id")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: errCaptchaBrowserCanceled}); err != nil {
|
||||
log.Warn().
|
||||
Str("challenge_id", req.ID).
|
||||
Err(err).
|
||||
Msg("Rejected CAPTCHA cancel request")
|
||||
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("challenge_id", req.ID).
|
||||
Msg("Browser page canceled CAPTCHA flow")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (cs *captchaServer) handleError(w http.ResponseWriter, r *http.Request) {
|
||||
log := cs.requestLogger(r)
|
||||
if r.Method != http.MethodPost {
|
||||
log.Warn().Msg("Rejected CAPTCHA error report with unsupported method")
|
||||
writeCaptchaMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
|
||||
var req captchaErrorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Warn().Err(err).Msg("Rejected CAPTCHA error report with invalid JSON body")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ID) == "" {
|
||||
log.Warn().Msg("Rejected CAPTCHA error report without challenge id")
|
||||
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(req.Error)
|
||||
if message == "" {
|
||||
message = "browser page reported an unknown error"
|
||||
}
|
||||
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: fmt.Errorf("%s", message)}); err != nil {
|
||||
log.Warn().
|
||||
Str("challenge_id", req.ID).
|
||||
Err(err).
|
||||
Msg("Rejected CAPTCHA browser error report")
|
||||
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Str("challenge_id", req.ID).
|
||||
Str("browser_error", message).
|
||||
Msg("Browser page reported CAPTCHA error")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (cs *captchaServer) requestLogger(r *http.Request) zerolog.Logger {
|
||||
return cs.log.With().
|
||||
Str("http_method", r.Method).
|
||||
Str("http_path", r.URL.Path).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Logger()
|
||||
}
|
||||
|
||||
func writeCaptchaJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeCaptchaMethodNotAllowed(w http.ResponseWriter, allowed string) {
|
||||
w.Header().Set("Allow", allowed)
|
||||
writeCaptchaJSON(w, http.StatusMethodNotAllowed, captchaErrorResponse{Error: "method not allowed"})
|
||||
}
|
||||
225
cmd/authtester/main_captcha.html
Normal file
225
cmd/authtester/main_captcha.html
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mautrix-discord hCaptcha</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
#status {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background-color: light-dark(#ddd, #333);
|
||||
|
||||
&.error {
|
||||
background-color: hsla(0 62.7% 52.9% / 0.2);
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
#captcha {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>mautrix-discord hCaptcha</h1>
|
||||
<main>
|
||||
<div id="status">Loading challenge state...</div>
|
||||
<div id="captcha"></div>
|
||||
<button id="cancel" type="button">
|
||||
Cancel & Use Terminal Fallback
|
||||
</button>
|
||||
</main>
|
||||
</main>
|
||||
<script>
|
||||
let challenge = null;
|
||||
let widgetID = null;
|
||||
const statusEl = document.getElementById("status");
|
||||
const cancelButton = document.getElementById("cancel");
|
||||
|
||||
function setStatus(message, isError) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.classList.toggle("error", Boolean(isError));
|
||||
}
|
||||
|
||||
async function parseError(response) {
|
||||
let message = "Request failed";
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body && body.error) {
|
||||
message = body.error;
|
||||
}
|
||||
} catch (err) {}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function postJSON(path, payload) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
}
|
||||
|
||||
async function reportError(message) {
|
||||
setStatus(message, true);
|
||||
if (!challenge) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postJSON("/api/error", {id: challenge.id, error: message});
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async function cancelChallenge() {
|
||||
if (!challenge) {
|
||||
setStatus("No active challenge to cancel.", true);
|
||||
return;
|
||||
}
|
||||
cancelButton.disabled = true;
|
||||
setStatus(
|
||||
"Canceling browser flow and switching back to terminal fallback...",
|
||||
);
|
||||
try {
|
||||
await postJSON("/api/cancel", {id: challenge.id});
|
||||
setStatus("Canceled! Check your terminal for manual entry.");
|
||||
} catch (err) {
|
||||
setStatus("Failed to cancel: " + err.message, true);
|
||||
cancelButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitToken(token) {
|
||||
setStatus("Submitting CAPTCHA token...");
|
||||
cancelButton.disabled = true;
|
||||
await postJSON("/api/solve", {id: challenge.id, token: token});
|
||||
setStatus("CAPTCHA token submitted! Check your terminal.");
|
||||
}
|
||||
|
||||
function loadHCaptchaScript() {
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://js.hcaptcha.com/1/api.js?render=explicit&onload=onHCaptchaLoad&recaptchacompat=off&host=discord.com";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onerror = function () {
|
||||
reportError("Failed to load hCaptcha API script.");
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function applyRqData() {
|
||||
if (!challenge || !challenge.rqdata) {
|
||||
return;
|
||||
}
|
||||
hcaptcha.setData(widgetID, {rqdata: challenge.rqdata});
|
||||
}
|
||||
|
||||
function renderHCaptcha() {
|
||||
widgetID = hcaptcha.render("captcha", {
|
||||
sitekey: challenge.site_key,
|
||||
size: challenge.invisible ? "invisible" : "normal",
|
||||
callback: function (token) {
|
||||
submitToken(token).catch(function (err) {
|
||||
reportError("Failed to submit token: " + err.message);
|
||||
});
|
||||
},
|
||||
"open-callback": function () {
|
||||
setStatus("CAPTCHA challenge opened. Complete it in this page.");
|
||||
},
|
||||
"close-callback": function () {
|
||||
setStatus(
|
||||
"CAPTCHA challenge was closed. Re-open it here or cancel to use terminal fallback.",
|
||||
);
|
||||
},
|
||||
"expired-callback": function () {
|
||||
reportError("hCaptcha token expired before submission.");
|
||||
},
|
||||
"chalexpired-callback": function () {
|
||||
reportError("hCaptcha challenge expired.");
|
||||
},
|
||||
"error-callback": function (message) {
|
||||
reportError("hCaptcha error: " + message);
|
||||
},
|
||||
});
|
||||
applyRqData();
|
||||
if (challenge.invisible) {
|
||||
setStatus("Launching invisible challenge...");
|
||||
hcaptcha.execute(widgetID);
|
||||
} else {
|
||||
setStatus(
|
||||
"Solve the CAPTCHA below. The token will submit automatically.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChallenge() {
|
||||
const response = await fetch("/api/challenge", {cache: "no-store"});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
challenge = await response.json();
|
||||
if (challenge.service !== "hcaptcha") {
|
||||
throw new Error(
|
||||
"Only hCaptcha challenges are supported by this test page.",
|
||||
);
|
||||
}
|
||||
if (!challenge.site_key) {
|
||||
throw new Error("Challenge is missing captcha_sitekey.");
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
cancelButton.addEventListener("click", function () {
|
||||
cancelChallenge().catch(function (err) {
|
||||
setStatus("Failed to cancel: " + err.message, true);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await loadChallenge();
|
||||
setStatus("Challenge state loaded. Loading hCaptcha...");
|
||||
loadHCaptchaScript();
|
||||
} catch (err) {
|
||||
reportError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
window.onHCaptchaLoad = function () {
|
||||
try {
|
||||
renderHCaptcha();
|
||||
} catch (err) {
|
||||
reportError("Failed to initialize hCaptcha: " + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
37
cmd/mautrix-discord/legacymigrate.go
Normal file
37
cmd/mautrix-discord/legacymigrate.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
const legacyMigrateRenameTables = `
|
||||
ALTER TABLE portal RENAME TO portal_old;
|
||||
ALTER TABLE puppet RENAME TO puppet_old;
|
||||
ALTER TABLE "user" RENAME TO user_old;
|
||||
ALTER TABLE message RENAME TO message_old;
|
||||
ALTER TABLE reaction RENAME TO reaction_old;
|
||||
ALTER TABLE user_portal RENAME TO user_portal_old;
|
||||
ALTER TABLE guild RENAME TO guild_old;
|
||||
ALTER TABLE role RENAME TO role_old;
|
||||
ALTER TABLE thread RENAME TO thread_old;
|
||||
ALTER TABLE discord_file RENAME TO discord_file_old;
|
||||
`
|
||||
|
||||
//go:embed legacymigrate.sql
|
||||
var legacyMigrateCopyData string
|
||||
382
cmd/mautrix-discord/legacymigrate.sql
Normal file
382
cmd/mautrix-discord/legacymigrate.sql
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
INSERT INTO "user" (bridge_id, mxid, management_room, access_token)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
mxid,
|
||||
management_room,
|
||||
NULL -- access_token
|
||||
FROM user_old;
|
||||
|
||||
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
uo.mxid, -- user_mxid
|
||||
uo.dcid, -- id
|
||||
COALESCE(uo.dcid, ''), -- remote_name
|
||||
NULL, -- remote_profile
|
||||
uo.space_room,
|
||||
-- only: postgres for next 13 lines
|
||||
jsonb_build_object(
|
||||
'token', uo.discord_token,
|
||||
'heartbeat_session', COALESCE(uo.heartbeat_session, '{}'::jsonb),
|
||||
'bridged_guild_ids', COALESCE((
|
||||
SELECT jsonb_object_agg(bg.guild_id, true)
|
||||
FROM (
|
||||
SELECT DISTINCT up.discord_id AS guild_id
|
||||
FROM user_portal_old AS up
|
||||
JOIN guild_old AS g ON g.dcid=up.discord_id
|
||||
WHERE up.user_mxid=uo.mxid AND up.type='guild' AND g.bridging_mode > 0
|
||||
) AS bg
|
||||
), '{}'::jsonb)
|
||||
)
|
||||
-- only: sqlite for next 16 lines (lines commented)
|
||||
-- json_object(
|
||||
-- 'token', uo.discord_token,
|
||||
-- 'heartbeat_session', CASE
|
||||
-- WHEN uo.heartbeat_session IS NULL OR uo.heartbeat_session='' THEN json('{}')
|
||||
-- ELSE json(uo.heartbeat_session)
|
||||
-- END,
|
||||
-- 'bridged_guild_ids', COALESCE((
|
||||
-- SELECT json_group_object(bg.guild_id, json('true'))
|
||||
-- FROM (
|
||||
-- SELECT DISTINCT up.discord_id AS guild_id
|
||||
-- FROM user_portal_old AS up
|
||||
-- JOIN guild_old AS g ON g.dcid=up.discord_id
|
||||
-- WHERE up.user_mxid=uo.mxid AND up.type='guild' AND g.bridging_mode > 0
|
||||
-- ) AS bg
|
||||
-- ), json('{}'))
|
||||
-- )
|
||||
FROM user_old AS uo
|
||||
WHERE uo.dcid IS NOT NULL AND uo.dcid <> '';
|
||||
|
||||
INSERT INTO ghost (
|
||||
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
id,
|
||||
name,
|
||||
avatar, -- avatar_id
|
||||
'', -- avatar_hash
|
||||
avatar_url, -- avatar_mxc
|
||||
name_set,
|
||||
avatar_set,
|
||||
contact_info_set,
|
||||
is_bot,
|
||||
-- only: postgres
|
||||
'[]'::jsonb, -- identifiers
|
||||
-- only: sqlite (line commented)
|
||||
-- '[]', -- identifiers
|
||||
-- only: postgres
|
||||
'{}'::jsonb -- metadata
|
||||
-- only: sqlite (line commented)
|
||||
-- '{}' -- metadata
|
||||
FROM puppet_old;
|
||||
|
||||
INSERT INTO ghost (
|
||||
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
missing.sender_id, -- id
|
||||
missing.sender_id, -- name
|
||||
'', -- avatar_id
|
||||
'', -- avatar_hash
|
||||
'', -- avatar_mxc
|
||||
false, -- name_set
|
||||
false, -- avatar_set
|
||||
false, -- contact_info_set
|
||||
false, -- is_bot
|
||||
-- only: postgres
|
||||
'[]'::jsonb, -- identifiers
|
||||
-- only: sqlite (line commented)
|
||||
-- '[]', -- identifiers
|
||||
-- only: postgres
|
||||
'{}'::jsonb -- metadata
|
||||
-- only: sqlite (line commented)
|
||||
-- '{}' -- metadata
|
||||
FROM (
|
||||
SELECT DISTINCT dc_sender AS sender_id FROM message_old
|
||||
UNION
|
||||
SELECT DISTINCT dc_sender AS sender_id FROM reaction_old
|
||||
) AS missing
|
||||
WHERE missing.sender_id <> '' AND NOT EXISTS(
|
||||
SELECT 1 FROM ghost WHERE bridge_id='' AND id=missing.sender_id
|
||||
);
|
||||
|
||||
INSERT INTO portal (
|
||||
bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_bridge_id, relay_login_id, other_user_id,
|
||||
name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, name_is_custom, in_space, room_type,
|
||||
metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
'*' || dcid, -- id
|
||||
'', -- receiver
|
||||
mxid,
|
||||
NULL, -- parent_id
|
||||
'', -- parent_receiver
|
||||
NULL, -- relay_bridge_id
|
||||
NULL, -- relay_login_id
|
||||
NULL, -- other_user_id
|
||||
name,
|
||||
'', -- topic
|
||||
avatar, -- avatar_id
|
||||
'', -- avatar_hash
|
||||
avatar_url, -- avatar_mxc
|
||||
name_set,
|
||||
avatar_set,
|
||||
true, -- topic_set
|
||||
true, -- name_is_custom
|
||||
false, -- in_space
|
||||
'space', -- room_type
|
||||
-- only: postgres
|
||||
'{}'::jsonb -- metadata
|
||||
-- only: sqlite (line commented)
|
||||
-- '{}' -- metadata
|
||||
FROM guild_old;
|
||||
|
||||
INSERT INTO portal (
|
||||
bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_bridge_id, relay_login_id, other_user_id,
|
||||
name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, name_is_custom, in_space, room_type,
|
||||
metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
p.dcid, -- id
|
||||
p.receiver, -- receiver
|
||||
p.mxid,
|
||||
CASE
|
||||
WHEN p.dc_parent_id <> '' THEN p.dc_parent_id
|
||||
WHEN p.dc_guild_id <> '' THEN '*' || p.dc_guild_id
|
||||
ELSE NULL
|
||||
END, -- parent_id
|
||||
CASE
|
||||
WHEN p.dc_parent_id <> '' THEN p.dc_parent_receiver
|
||||
WHEN p.dc_guild_id <> '' THEN ''
|
||||
ELSE ''
|
||||
END, -- parent_receiver
|
||||
NULL, -- relay_bridge_id
|
||||
NULL, -- relay_login_id
|
||||
NULLIF(p.other_user_id, ''), -- other_user_id
|
||||
p.name,
|
||||
p.topic,
|
||||
p.avatar, -- avatar_id
|
||||
'', -- avatar_hash
|
||||
p.avatar_url, -- avatar_mxc
|
||||
p.name_set,
|
||||
p.avatar_set,
|
||||
p.topic_set,
|
||||
NOT (p.type=1), -- name_is_custom
|
||||
p.in_space <> '', -- in_space
|
||||
CASE
|
||||
WHEN p.type=1 THEN 'dm'
|
||||
WHEN p.type=3 THEN 'group_dm'
|
||||
WHEN p.type=4 THEN 'space'
|
||||
ELSE ''
|
||||
END, -- room_type
|
||||
-- only: postgres for next 4 lines
|
||||
jsonb_build_object(
|
||||
'guild_id', COALESCE(p.dc_guild_id, ''),
|
||||
'channel_type', p.type
|
||||
)
|
||||
-- only: sqlite for next 4 lines (lines commented)
|
||||
-- json_object(
|
||||
-- 'guild_id', COALESCE(p.dc_guild_id, ''),
|
||||
-- 'channel_type', p.type
|
||||
-- )
|
||||
FROM portal_old AS p;
|
||||
|
||||
INSERT INTO message (
|
||||
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, timestamp, edit_count, double_puppeted,
|
||||
thread_root_id, reply_to_id, reply_to_part_id, send_txn_id, metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
m.dcid, -- id
|
||||
m.dc_attachment_id, -- part_id
|
||||
m.mxid,
|
||||
m.dc_chan_id, -- room_id
|
||||
m.dc_chan_receiver, -- room_receiver
|
||||
m.dc_sender, -- sender_id
|
||||
m.sender_mxid,
|
||||
m.timestamp * 1000000, -- timestamp (ms -> ns)
|
||||
CASE WHEN m.dc_edit_timestamp > 0 THEN 1 ELSE 0 END, -- edit_count
|
||||
false, -- double_puppeted
|
||||
CASE WHEN m.dc_thread_id <> '' THEN COALESCE(NULLIF(t.root_msg_dcid, ''), m.dc_thread_id) END, -- thread_root_id
|
||||
NULL, -- reply_to_id
|
||||
NULL, -- reply_to_part_id
|
||||
NULL, -- send_txn_id
|
||||
-- only: postgres
|
||||
'{}'::jsonb -- metadata
|
||||
-- only: sqlite (line commented)
|
||||
-- '{}' -- metadata
|
||||
FROM message_old AS m
|
||||
LEFT JOIN thread_old AS t ON t.dcid=m.dc_thread_id AND (t.receiver=m.dc_chan_receiver OR t.receiver='')
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM portal
|
||||
WHERE bridge_id='' AND id=m.dc_chan_id AND receiver=m.dc_chan_receiver
|
||||
);
|
||||
|
||||
INSERT INTO reaction (
|
||||
bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, room_id, room_receiver, mxid, timestamp, emoji, metadata
|
||||
)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
r.dc_msg_id, -- message_id
|
||||
r.dc_first_attachment_id, -- message_part_id
|
||||
r.dc_sender, -- sender_id
|
||||
'', -- sender_mxid
|
||||
r.dc_emoji_name, -- emoji_id
|
||||
m.room_id,
|
||||
m.room_receiver,
|
||||
r.mxid,
|
||||
m.timestamp,
|
||||
r.dc_emoji_name, -- emoji
|
||||
-- only: postgres
|
||||
'{}'::jsonb -- metadata
|
||||
-- only: sqlite (line commented)
|
||||
-- '{}' -- metadata
|
||||
FROM reaction_old AS r
|
||||
JOIN message AS m ON m.bridge_id='' AND m.id=r.dc_msg_id AND m.part_id=r.dc_first_attachment_id AND m.room_id=r.dc_chan_id AND m.room_receiver=r.dc_chan_receiver
|
||||
WHERE r.dc_sender <> '';
|
||||
|
||||
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read)
|
||||
SELECT
|
||||
'', -- bridge_id
|
||||
up.user_mxid,
|
||||
u.dcid, -- login_id
|
||||
CASE WHEN up.type='guild' THEN '*' || up.discord_id ELSE up.discord_id END, -- portal_id
|
||||
CASE WHEN up.type='guild' THEN '' ELSE COALESCE(
|
||||
(SELECT p.receiver FROM portal_old AS p WHERE p.dcid=up.discord_id AND p.receiver=u.dcid LIMIT 1),
|
||||
(SELECT p.receiver FROM portal_old AS p WHERE p.dcid=up.discord_id AND p.receiver='' LIMIT 1),
|
||||
''
|
||||
) END, -- portal_receiver
|
||||
up.in_space, -- in_space
|
||||
false, -- preferred
|
||||
CASE WHEN up.timestamp > 0 THEN up.timestamp * 1000000 END -- last_read
|
||||
FROM user_portal_old AS up
|
||||
JOIN user_old AS u ON u.mxid=up.user_mxid
|
||||
WHERE u.dcid IS NOT NULL AND u.dcid <> '' AND EXISTS(
|
||||
SELECT 1
|
||||
FROM portal
|
||||
WHERE bridge_id='' AND id=(CASE WHEN up.type='guild' THEN '*' || up.discord_id ELSE up.discord_id END)
|
||||
AND receiver=(CASE WHEN up.type='guild' THEN '' ELSE COALESCE(
|
||||
(SELECT p.receiver FROM portal_old AS p WHERE p.dcid=up.discord_id AND p.receiver=u.dcid LIMIT 1),
|
||||
(SELECT p.receiver FROM portal_old AS p WHERE p.dcid=up.discord_id AND p.receiver='' LIMIT 1),
|
||||
''
|
||||
) END)
|
||||
)
|
||||
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO NOTHING;
|
||||
|
||||
-- migrate thread_old -> discord_thread (receiver already known)
|
||||
INSERT INTO discord_thread (user_login_id, parent_channel_id, thread_channel_id, root_message_id)
|
||||
SELECT
|
||||
t.receiver AS user_login_id,
|
||||
t.parent_chan_id,
|
||||
t.dcid AS thread_channel_id,
|
||||
t.root_msg_dcid AS root_message_id
|
||||
FROM thread_old AS t
|
||||
WHERE t.receiver <> '' AND t.root_msg_dcid <> ''
|
||||
ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
|
||||
SET parent_channel_id=excluded.parent_channel_id, root_message_id=excluded.root_message_id;
|
||||
|
||||
-- migrate thread_old -> discord_thread (receiver missing; derive from guild
|
||||
-- membership)
|
||||
INSERT INTO discord_thread (user_login_id, parent_channel_id, thread_channel_id, root_message_id)
|
||||
SELECT DISTINCT
|
||||
u.dcid AS user_login_id,
|
||||
t.parent_chan_id,
|
||||
t.dcid AS thread_channel_id,
|
||||
t.root_msg_dcid AS root_message_id
|
||||
FROM thread_old AS t
|
||||
JOIN portal_old AS parent ON parent.dcid=t.parent_chan_id AND parent.receiver=''
|
||||
JOIN user_portal_old AS up ON up.type='guild' AND up.discord_id=parent.dc_guild_id
|
||||
JOIN user_old AS u ON u.mxid=up.user_mxid
|
||||
WHERE t.receiver='' AND t.root_msg_dcid <> '' AND u.dcid <> ''
|
||||
ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
|
||||
SET parent_channel_id=excluded.parent_channel_id, root_message_id=excluded.root_message_id;
|
||||
|
||||
-- migrate message_old -> discord_thread (thread reference; receiver known)
|
||||
INSERT INTO discord_thread (user_login_id, parent_channel_id, thread_channel_id, root_message_id)
|
||||
SELECT DISTINCT
|
||||
m.dc_chan_receiver AS user_login_id,
|
||||
m.dc_chan_id AS parent_channel_id,
|
||||
m.dc_thread_id AS thread_channel_id,
|
||||
COALESCE(NULLIF(t.root_msg_dcid, ''), m.dc_thread_id) AS root_message_id
|
||||
FROM message_old AS m
|
||||
LEFT JOIN thread_old AS t ON t.dcid=m.dc_thread_id AND (t.receiver=m.dc_chan_receiver OR t.receiver='')
|
||||
WHERE m.dc_chan_receiver <> '' AND m.dc_thread_id <> '' AND COALESCE(NULLIF(t.root_msg_dcid, ''), m.dc_thread_id) <> ''
|
||||
ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
|
||||
SET parent_channel_id=excluded.parent_channel_id, root_message_id=excluded.root_message_id;
|
||||
|
||||
-- migrate message_old -> discord_thread (thread reference; eceiverr missing)
|
||||
INSERT INTO discord_thread (user_login_id, parent_channel_id, thread_channel_id, root_message_id)
|
||||
SELECT DISTINCT
|
||||
u.dcid AS user_login_id,
|
||||
m.dc_chan_id AS parent_channel_id,
|
||||
m.dc_thread_id AS thread_channel_id,
|
||||
COALESCE(NULLIF(t.root_msg_dcid, ''), m.dc_thread_id) AS root_message_id
|
||||
FROM message_old AS m
|
||||
JOIN portal_old AS parent ON parent.dcid=m.dc_chan_id AND parent.receiver=''
|
||||
JOIN user_portal_old AS up ON up.type='guild' AND up.discord_id=parent.dc_guild_id
|
||||
JOIN user_old AS u ON u.mxid=up.user_mxid
|
||||
LEFT JOIN thread_old AS t ON t.dcid=m.dc_thread_id AND t.receiver=''
|
||||
WHERE m.dc_chan_receiver='' AND m.dc_thread_id <> '' AND COALESCE(NULLIF(t.root_msg_dcid, ''), m.dc_thread_id) <> '' AND u.dcid <> ''
|
||||
ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
|
||||
SET parent_channel_id=excluded.parent_channel_id, root_message_id=excluded.root_message_id;
|
||||
|
||||
INSERT INTO role (discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions) SELECT
|
||||
r.dc_guild_id AS discord_guild_id,
|
||||
r.dcid AS discord_id,
|
||||
r.name,
|
||||
r.icon,
|
||||
r.mentionable,
|
||||
r.managed,
|
||||
r.hoist,
|
||||
r.color,
|
||||
r.position,
|
||||
r.permissions
|
||||
FROM role_old r;
|
||||
|
||||
INSERT INTO custom_emoji (discord_id, name, animated, mxc)
|
||||
SELECT
|
||||
picked.id AS discord_id,
|
||||
picked.emoji_name AS name,
|
||||
CASE
|
||||
WHEN picked.mime_type='image/gif' OR lower(picked.url) LIKE '%.gif%' THEN true
|
||||
ELSE false
|
||||
END AS animated,
|
||||
picked.mxc
|
||||
FROM (
|
||||
SELECT
|
||||
df.id,
|
||||
df.emoji_name,
|
||||
df.mxc,
|
||||
df.mime_type,
|
||||
df.url,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY df.id
|
||||
ORDER BY df.timestamp DESC, df.emoji_name DESC, df.mxc DESC
|
||||
) AS rn
|
||||
FROM discord_file_old AS df
|
||||
WHERE df.id IS NOT NULL AND df.id <> ''
|
||||
AND df.emoji_name IS NOT NULL AND df.emoji_name <> ''
|
||||
AND df.mxc IS NOT NULL AND df.mxc <> ''
|
||||
) AS picked
|
||||
WHERE picked.rn=1
|
||||
ON CONFLICT (discord_id) DO UPDATE
|
||||
SET name=excluded.name, animated=excluded.animated, mxc=excluded.mxc;
|
||||
|
||||
DROP TABLE thread_old;
|
||||
DROP TABLE role_old;
|
||||
DROP TABLE user_portal_old;
|
||||
DROP TABLE reaction_old;
|
||||
DROP TABLE message_old;
|
||||
DROP TABLE user_old;
|
||||
DROP TABLE puppet_old;
|
||||
DROP TABLE portal_old;
|
||||
DROP TABLE guild_old;
|
||||
DROP TABLE discord_file_old;
|
||||
58
cmd/mautrix-discord/main.go
Normal file
58
cmd/mautrix-discord/main.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/connector"
|
||||
"go.mau.fi/mautrix-discord/pkg/connector/discorddb"
|
||||
)
|
||||
|
||||
var (
|
||||
Tag = "unknown"
|
||||
Commit = "unknown"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
var c = &connector.DiscordConnector{}
|
||||
var m = mxmain.BridgeMain{
|
||||
Name: "mautrix-discord",
|
||||
Description: "A Matrix-Discord puppeting bridge",
|
||||
URL: "https://github.com/mautrix/discord",
|
||||
Version: "26.03",
|
||||
SemCalVer: true,
|
||||
Connector: c,
|
||||
}
|
||||
|
||||
func main() {
|
||||
m.PostInit = func() {
|
||||
m.CheckLegacyDB(24, "v0.7.6", "v26.03",
|
||||
m.LegacyMigrateWithAnotherUpgrader(
|
||||
legacyMigrateRenameTables,
|
||||
legacyMigrateCopyData,
|
||||
26,
|
||||
discorddb.UpgradeTable(),
|
||||
"discord_version",
|
||||
2,
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
m.InitVersion(Tag, Commit, BuildTime)
|
||||
m.Run()
|
||||
}
|
||||
901
commands.go
901
commands.go
|
|
@ -1,901 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/bridge/commands"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
"go.mau.fi/mautrix-discord/remoteauth"
|
||||
)
|
||||
|
||||
type WrappedCommandEvent struct {
|
||||
*commands.Event
|
||||
Bridge *DiscordBridge
|
||||
User *User
|
||||
Portal *Portal
|
||||
}
|
||||
|
||||
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
|
||||
|
||||
func (br *DiscordBridge) RegisterCommands() {
|
||||
proc := br.CommandProcessor.(*commands.Processor)
|
||||
proc.AddHandlers(
|
||||
cmdLoginToken,
|
||||
cmdLoginQR,
|
||||
cmdLogout,
|
||||
cmdPing,
|
||||
cmdReconnect,
|
||||
cmdDisconnect,
|
||||
cmdBridge,
|
||||
cmdUnbridge,
|
||||
cmdDeletePortal,
|
||||
cmdCreatePortal,
|
||||
cmdSetRelay,
|
||||
cmdUnsetRelay,
|
||||
cmdGuilds,
|
||||
cmdRejoinSpace,
|
||||
cmdDeleteAllPortals,
|
||||
cmdExec,
|
||||
cmdCommands,
|
||||
)
|
||||
}
|
||||
|
||||
func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) {
|
||||
return func(ce *commands.Event) {
|
||||
user := ce.User.(*User)
|
||||
var portal *Portal
|
||||
if ce.Portal != nil {
|
||||
portal = ce.Portal.(*Portal)
|
||||
}
|
||||
br := ce.Bridge.Child.(*DiscordBridge)
|
||||
handler(&WrappedCommandEvent{ce, br, user, portal})
|
||||
}
|
||||
}
|
||||
|
||||
var cmdLoginToken = &commands.FullHandler{
|
||||
Func: wrapCommand(fnLoginToken),
|
||||
Name: "login-token",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Link the bridge to your Discord account by extracting the access token manually.",
|
||||
Args: "<user/bot/oauth> <_token_>",
|
||||
},
|
||||
}
|
||||
|
||||
const discordTokenEpoch = 1293840000
|
||||
|
||||
func decodeToken(token string) (userID int64, err error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
err = fmt.Errorf("invalid number of parts in token")
|
||||
return
|
||||
}
|
||||
var userIDStr []byte
|
||||
userIDStr, err = base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
err = fmt.Errorf("invalid base64 in user ID part: %w", err)
|
||||
return
|
||||
}
|
||||
_, err = base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
err = fmt.Errorf("invalid base64 in random part: %w", err)
|
||||
return
|
||||
}
|
||||
_, err = base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
err = fmt.Errorf("invalid base64 in checksum part: %w", err)
|
||||
return
|
||||
}
|
||||
userID, err = strconv.ParseInt(string(userIDStr), 10, 64)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("invalid number in decoded user ID part: %w", err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func fnLoginToken(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) != 2 {
|
||||
ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
|
||||
return
|
||||
}
|
||||
ce.MarkRead()
|
||||
defer ce.Redact()
|
||||
if ce.User.IsLoggedIn() {
|
||||
ce.Reply("You're already logged in")
|
||||
return
|
||||
}
|
||||
token := ce.Args[1]
|
||||
userID, err := decodeToken(token)
|
||||
if err != nil {
|
||||
ce.Reply("Invalid token")
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(ce.Args[0]) {
|
||||
case "user":
|
||||
// Token is used as-is
|
||||
case "bot":
|
||||
token = "Bot " + token
|
||||
case "oauth":
|
||||
token = "Bearer " + token
|
||||
default:
|
||||
ce.Reply("Token type must be `user`, `bot` or `oauth`")
|
||||
return
|
||||
}
|
||||
ce.Reply("Connecting to Discord as user ID %d", userID)
|
||||
if err = ce.User.Login(token); err != nil {
|
||||
ce.Reply("Error connecting to Discord: %v", err)
|
||||
return
|
||||
}
|
||||
ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username)
|
||||
}
|
||||
|
||||
var cmdLoginQR = &commands.FullHandler{
|
||||
Func: wrapCommand(fnLoginQR),
|
||||
Name: "login-qr",
|
||||
Aliases: []string{"login"},
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Link the bridge to your Discord account by scanning a QR code.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnLoginQR(ce *WrappedCommandEvent) {
|
||||
if ce.User.IsLoggedIn() {
|
||||
ce.Reply("You're already logged in")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := remoteauth.New()
|
||||
if err != nil {
|
||||
ce.Reply("Failed to prepare login: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
qrChan := make(chan string)
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
var qrCodeEvent id.EventID
|
||||
|
||||
go func() {
|
||||
code := <-qrChan
|
||||
resp := sendQRCode(ce, code)
|
||||
qrCodeEvent = resp
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err = client.Dial(ctx, qrChan, doneChan); err != nil {
|
||||
close(qrChan)
|
||||
close(doneChan)
|
||||
ce.Reply("Error connecting to login websocket: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
<-doneChan
|
||||
|
||||
if qrCodeEvent != "" {
|
||||
_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)
|
||||
}
|
||||
|
||||
user, err := client.Result()
|
||||
if err != nil || len(user.Token) == 0 {
|
||||
if restErr := (&discordgo.RESTError{}); errors.As(err, &restErr) &&
|
||||
restErr.Response.StatusCode == http.StatusBadRequest &&
|
||||
bytes.Contains(restErr.ResponseBody, []byte("captcha-required")) {
|
||||
ce.Reply("Error logging in: %v\n\nCAPTCHAs are currently not supported - use token login instead", err)
|
||||
} else {
|
||||
ce.Reply("Error logging in: %v", err)
|
||||
}
|
||||
return
|
||||
} else if err = ce.User.Login(user.Token); err != nil {
|
||||
ce.Reply("Error connecting after login: %v", err)
|
||||
return
|
||||
}
|
||||
ce.User.Lock()
|
||||
ce.User.DiscordID = user.UserID
|
||||
ce.User.Update()
|
||||
ce.User.Unlock()
|
||||
ce.Reply("Successfully logged in as @%s", user.Username)
|
||||
}
|
||||
|
||||
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
|
||||
url, ok := uploadQRCode(ce, code)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
Body: code,
|
||||
FileName: "qr.png",
|
||||
URL: url.CUString(),
|
||||
}
|
||||
|
||||
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
|
||||
if err != nil {
|
||||
ce.Log.Errorfln("Failed to send QR code: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return resp.EventID
|
||||
}
|
||||
|
||||
func uploadQRCode(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
|
||||
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
|
||||
if err != nil {
|
||||
ce.Log.Errorln("Failed to encode QR code:", err)
|
||||
ce.Reply("Failed to encode QR code: %v", err)
|
||||
return id.ContentURI{}, false
|
||||
}
|
||||
|
||||
resp, err := ce.Bot.UploadBytes(qrCode, "image/png")
|
||||
if err != nil {
|
||||
ce.Log.Errorln("Failed to upload QR code:", err)
|
||||
ce.Reply("Failed to upload QR code: %v", err)
|
||||
return id.ContentURI{}, false
|
||||
}
|
||||
|
||||
return resp.ContentURI, true
|
||||
}
|
||||
|
||||
var cmdLogout = &commands.FullHandler{
|
||||
Func: wrapCommand(fnLogout),
|
||||
Name: "logout",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Forget the stored Discord auth token.",
|
||||
},
|
||||
}
|
||||
|
||||
func fnLogout(ce *WrappedCommandEvent) {
|
||||
wasLoggedIn := ce.User.DiscordID != ""
|
||||
ce.User.Logout(false)
|
||||
if wasLoggedIn {
|
||||
ce.Reply("Logged out successfully.")
|
||||
} else {
|
||||
ce.Reply("You weren't logged in, but data was re-cleared just to be safe.")
|
||||
}
|
||||
}
|
||||
|
||||
var cmdPing = &commands.FullHandler{
|
||||
Func: wrapCommand(fnPing),
|
||||
Name: "ping",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Check your connection to Discord",
|
||||
},
|
||||
}
|
||||
|
||||
func fnPing(ce *WrappedCommandEvent) {
|
||||
if ce.User.Session == nil {
|
||||
if ce.User.DiscordToken == "" {
|
||||
ce.Reply("You're not logged in")
|
||||
} else {
|
||||
ce.Reply("You have a Discord token stored, but are not connected for some reason 🤔")
|
||||
}
|
||||
} else if ce.User.wasDisconnected {
|
||||
ce.Reply("You're logged in, but the Discord connection seems to be dead 💥")
|
||||
} else {
|
||||
ce.Reply("You're logged in as @%s (`%s`)", ce.User.Session.State.User.Username, ce.User.DiscordID)
|
||||
}
|
||||
}
|
||||
|
||||
var cmdDisconnect = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDisconnect),
|
||||
Name: "disconnect",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Disconnect from Discord (without logging out)",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
func fnDisconnect(ce *WrappedCommandEvent) {
|
||||
if !ce.User.Connected() {
|
||||
ce.Reply("You're already not connected")
|
||||
} else if err := ce.User.Disconnect(); err != nil {
|
||||
ce.Reply("Error while disconnecting: %v", err)
|
||||
} else {
|
||||
ce.Reply("Successfully disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
var cmdReconnect = &commands.FullHandler{
|
||||
Func: wrapCommand(fnReconnect),
|
||||
Name: "reconnect",
|
||||
Aliases: []string{"connect"},
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAuth,
|
||||
Description: "Reconnect to Discord after disconnecting",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
func fnReconnect(ce *WrappedCommandEvent) {
|
||||
if ce.User.Connected() {
|
||||
ce.Reply("You're already connected")
|
||||
} else if err := ce.User.Connect(); err != nil {
|
||||
ce.Reply("Error while reconnecting: %v", err)
|
||||
} else {
|
||||
ce.Reply("Successfully reconnected")
|
||||
}
|
||||
}
|
||||
|
||||
var cmdRejoinSpace = &commands.FullHandler{
|
||||
Func: wrapCommand(fnRejoinSpace),
|
||||
Name: "rejoin-space",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Ask the bridge for an invite to a space you left",
|
||||
Args: "<_guild ID_/main/dms>",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
func fnRejoinSpace(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) == 0 {
|
||||
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
|
||||
return
|
||||
}
|
||||
user := ce.User
|
||||
if ce.Args[0] == "main" {
|
||||
user.ensureInvited(nil, user.GetSpaceRoom(), false, true)
|
||||
ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
|
||||
} else if ce.Args[0] == "dms" {
|
||||
user.ensureInvited(nil, user.GetDMSpaceRoom(), false, true)
|
||||
ce.Reply("Invited you to your DM space ([link](%s))", user.GetDMSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
|
||||
} else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
|
||||
ce.Reply("Rejoining guild spaces is not yet implemented")
|
||||
} else {
|
||||
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var roomModerator = event.Type{Type: "fi.mau.discord.admin", Class: event.StateEventType}
|
||||
|
||||
var cmdSetRelay = &commands.FullHandler{
|
||||
Func: wrapCommand(fnSetRelay),
|
||||
Name: "set-relay",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Create or set a relay webhook for a portal",
|
||||
Args: "[room ID] <--url URL> OR <--create [name]>",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
RequiresEventLevel: roomModerator,
|
||||
}
|
||||
|
||||
const webhookURLFormat = "https://discord.com/api/webhooks/%d/%s"
|
||||
|
||||
const selectRelayHelp = "Usage: `$cmdprefix [room ID] <--url URL> OR <--create [name]>`"
|
||||
|
||||
func fnSetRelay(ce *WrappedCommandEvent) {
|
||||
portal := ce.Portal
|
||||
if len(ce.Args) > 0 && strings.HasPrefix(ce.Args[0], "!") {
|
||||
portal = ce.Bridge.GetPortalByMXID(id.RoomID(ce.Args[0]))
|
||||
if portal == nil {
|
||||
ce.Reply("Portal with room ID %s not found", ce.Args[0])
|
||||
return
|
||||
}
|
||||
if ce.User.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
|
||||
levels, err := portal.MainIntent().PowerLevels(ce.RoomID)
|
||||
if err != nil {
|
||||
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
|
||||
ce.Reply("Failed to get room power levels to see if you're allowed to use that command")
|
||||
return
|
||||
} else if levels.GetUserLevel(ce.User.GetMXID()) < levels.GetEventLevel(roomModerator) {
|
||||
ce.Reply("You don't have admin rights in that room")
|
||||
return
|
||||
}
|
||||
}
|
||||
ce.Args = ce.Args[1:]
|
||||
} else if portal == nil {
|
||||
ce.Reply("You must either run the command in a portal, or specify an internal room ID as the first parameter")
|
||||
return
|
||||
}
|
||||
log := ce.ZLog.With().Str("channel_id", portal.Key.ChannelID).Logger()
|
||||
if portal.GuildID == "" {
|
||||
ce.Reply("Only guild channels can have relays")
|
||||
return
|
||||
} else if portal.RelayWebhookID != "" {
|
||||
webhookMeta, err := relayClient.WebhookWithToken(portal.RelayWebhookID, portal.RelayWebhookSecret)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get existing webhook info")
|
||||
ce.Reply("This channel has a relay webhook set, but getting its info failed: %v", err)
|
||||
return
|
||||
}
|
||||
ce.Reply("This channel already has a relay webhook %s (%s)", webhookMeta.Name, webhookMeta.ID)
|
||||
return
|
||||
} else if len(ce.Args) == 0 {
|
||||
ce.Reply(selectRelayHelp)
|
||||
return
|
||||
}
|
||||
createType := strings.ToLower(strings.TrimLeft(ce.Args[0], "-"))
|
||||
var webhookMeta *discordgo.Webhook
|
||||
switch createType {
|
||||
case "url":
|
||||
if len(ce.Args) < 2 {
|
||||
ce.Reply("Usage: `$cmdprefix [room ID] --url <URL>")
|
||||
return
|
||||
}
|
||||
ce.Redact()
|
||||
var webhookID int64
|
||||
var webhookSecret string
|
||||
_, err := fmt.Sscanf(ce.Args[1], webhookURLFormat, &webhookID, &webhookSecret)
|
||||
if err != nil {
|
||||
log.Warn().Str("webhook_url", ce.Args[1]).Err(err).Msg("Failed to parse provided webhook URL")
|
||||
ce.Reply("Invalid webhook URL")
|
||||
return
|
||||
}
|
||||
webhookMeta, err = relayClient.WebhookWithToken(strconv.FormatInt(webhookID, 10), webhookSecret)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get webhook info")
|
||||
ce.Reply("Failed to get webhook info: %v", err)
|
||||
return
|
||||
}
|
||||
case "create":
|
||||
perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID, portal.RefererOptIfUser(ce.User.Session, "")...)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to check user permissions")
|
||||
ce.Reply("Failed to check if you have permission to create webhooks")
|
||||
return
|
||||
} else if perms&discordgo.PermissionManageWebhooks == 0 {
|
||||
log.Debug().Int64("perms", perms).Msg("User doesn't have permissions to manage webhooks in channel")
|
||||
ce.Reply("You don't have permission to manage webhooks in that channel")
|
||||
return
|
||||
}
|
||||
name := "mautrix"
|
||||
if len(ce.Args) > 1 {
|
||||
name = strings.Join(ce.Args[1:], " ")
|
||||
}
|
||||
log.Debug().Str("webhook_name", name).Msg("Creating webhook")
|
||||
webhookMeta, err = ce.User.Session.WebhookCreate(portal.Key.ChannelID, name, "", portal.RefererOptIfUser(ce.User.Session, "")...)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create webhook")
|
||||
ce.Reply("Failed to create webhook: %v", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ce.Reply(selectRelayHelp)
|
||||
return
|
||||
}
|
||||
if portal.Key.ChannelID != webhookMeta.ChannelID {
|
||||
log.Debug().
|
||||
Str("portal_channel_id", portal.Key.ChannelID).
|
||||
Str("webhook_channel_id", webhookMeta.ChannelID).
|
||||
Msg("Provided webhook is for wrong channel")
|
||||
ce.Reply("That webhook is not for the right channel (expected %s, webhook is for %s)", portal.Key.ChannelID, webhookMeta.ChannelID)
|
||||
return
|
||||
}
|
||||
log.Debug().Str("webhook_id", webhookMeta.ID).Msg("Setting portal relay webhook")
|
||||
portal.RelayWebhookID = webhookMeta.ID
|
||||
portal.RelayWebhookSecret = webhookMeta.Token
|
||||
portal.Update()
|
||||
ce.Reply("Saved webhook %s (%s) as portal relay webhook", webhookMeta.Name, portal.RelayWebhookID)
|
||||
}
|
||||
|
||||
var cmdUnsetRelay = &commands.FullHandler{
|
||||
Func: wrapCommand(fnUnsetRelay),
|
||||
Name: "unset-relay",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Disable the relay webhook and optionally delete it on Discord",
|
||||
Args: "[--delete]",
|
||||
},
|
||||
RequiresPortal: true,
|
||||
RequiresEventLevel: roomModerator,
|
||||
}
|
||||
|
||||
func fnUnsetRelay(ce *WrappedCommandEvent) {
|
||||
if ce.Portal.RelayWebhookID == "" {
|
||||
ce.Reply("This portal doesn't have a relay webhook")
|
||||
return
|
||||
}
|
||||
if len(ce.Args) > 0 && ce.Args[0] == "--delete" {
|
||||
err := relayClient.WebhookDeleteWithToken(ce.Portal.RelayWebhookID, ce.Portal.RelayWebhookSecret)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to delete webhook: %v", err)
|
||||
return
|
||||
} else {
|
||||
ce.Reply("Successfully deleted webhook")
|
||||
}
|
||||
} else {
|
||||
ce.Reply("Relay webhook disabled")
|
||||
}
|
||||
ce.Portal.RelayWebhookID = ""
|
||||
ce.Portal.RelayWebhookSecret = ""
|
||||
ce.Portal.Update()
|
||||
}
|
||||
|
||||
var cmdGuilds = &commands.FullHandler{
|
||||
Func: wrapCommand(fnGuilds),
|
||||
Name: "guilds",
|
||||
Aliases: []string{"servers", "guild", "server"},
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Guild bridging management",
|
||||
Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [...]`"
|
||||
|
||||
const fullGuildsHelp = smallGuildsHelp + `
|
||||
|
||||
* **help** - View this help message.
|
||||
* **status** - View the list of guilds and their bridging status.
|
||||
* **bridge <_guild ID_> [--entire]** - Enable bridging for a guild. The --entire flag auto-creates portals for all channels.
|
||||
* **bridging-mode <_guild ID_> <_mode_>** - Set the mode for bridging messages and new channels in a guild.
|
||||
* **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.`
|
||||
|
||||
func fnGuilds(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) == 0 {
|
||||
ce.Reply(fullGuildsHelp)
|
||||
return
|
||||
}
|
||||
subcommand := strings.ToLower(ce.Args[0])
|
||||
ce.Args = ce.Args[1:]
|
||||
switch subcommand {
|
||||
case "status", "list":
|
||||
fnListGuilds(ce)
|
||||
case "bridge":
|
||||
fnBridgeGuild(ce)
|
||||
case "unbridge", "delete":
|
||||
fnUnbridgeGuild(ce)
|
||||
case "bridging-mode", "mode":
|
||||
fnGuildBridgingMode(ce)
|
||||
case "help":
|
||||
ce.Reply(fullGuildsHelp)
|
||||
default:
|
||||
ce.Reply("Unknown subcommand `%s`\n\n"+smallGuildsHelp, subcommand)
|
||||
}
|
||||
}
|
||||
|
||||
func fnListGuilds(ce *WrappedCommandEvent) {
|
||||
var items []string
|
||||
for _, userGuild := range ce.User.GetPortals() {
|
||||
guild := ce.Bridge.GetGuildByID(userGuild.DiscordID, false)
|
||||
if guild == nil {
|
||||
continue
|
||||
}
|
||||
var avatarHTML string
|
||||
if !guild.AvatarURL.IsEmpty() {
|
||||
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String())
|
||||
}
|
||||
items = append(items, fmt.Sprintf("<li>%s%s (<code>%s</code>) - %s</li>", avatarHTML, html.EscapeString(guild.Name), guild.ID, guild.BridgingMode.Description()))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
ce.Reply("No guilds found")
|
||||
} else {
|
||||
ce.ReplyAdvanced(fmt.Sprintf("<p>List of guilds:</p><ul>%s</ul>", strings.Join(items, "")), false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func fnBridgeGuild(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) == 0 || len(ce.Args) > 2 {
|
||||
ce.Reply("**Usage**: `$cmdprefix guilds bridge <guild ID> [--entire]")
|
||||
} else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
|
||||
ce.Reply("Error bridging guild: %v", err)
|
||||
} else {
|
||||
ce.Reply("Successfully bridged guild")
|
||||
}
|
||||
}
|
||||
|
||||
func fnUnbridgeGuild(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) != 1 {
|
||||
ce.Reply("**Usage**: `$cmdprefix guilds unbridge <guild ID>")
|
||||
} else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
|
||||
ce.Reply("Error unbridging guild: %v", err)
|
||||
} else {
|
||||
ce.Reply("Successfully unbridged guild")
|
||||
}
|
||||
}
|
||||
|
||||
const availableModes = "Available modes:\n" +
|
||||
"* `nothing` to never bridge any messages (default when unbridged)\n" +
|
||||
"* `if-portal-exists` to bridge messages in existing portals, but drop messages in unbridged channels\n" +
|
||||
"* `create-on-message` to bridge all messages and create portals if necessary on incoming messages (default after bridging)\n" +
|
||||
"* `everything` to bridge all messages and create portals proactively on bridge startup (default if bridged with `--entire`)\n"
|
||||
|
||||
func fnGuildBridgingMode(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) == 0 || len(ce.Args) > 2 {
|
||||
ce.Reply("**Usage**: `$cmdprefix guilds bridging-mode <guild ID> [mode]`\n\n" + availableModes)
|
||||
return
|
||||
}
|
||||
guild := ce.Bridge.GetGuildByID(ce.Args[0], false)
|
||||
if guild == nil {
|
||||
ce.Reply("Guild not found")
|
||||
return
|
||||
}
|
||||
if len(ce.Args) == 1 {
|
||||
ce.Reply("%s (%s) is currently set to %s (`%s`)\n\n%s", guild.PlainName, guild.ID, guild.BridgingMode.Description(), guild.BridgingMode.String(), availableModes)
|
||||
return
|
||||
}
|
||||
mode := database.ParseGuildBridgingMode(ce.Args[1])
|
||||
if mode == database.GuildBridgeInvalid {
|
||||
ce.Reply("Invalid guild bridging mode `%s`", ce.Args[1])
|
||||
return
|
||||
}
|
||||
guild.BridgingMode = mode
|
||||
guild.Update()
|
||||
ce.Reply("Set guild bridging mode to %s", mode.Description())
|
||||
}
|
||||
|
||||
var cmdBridge = &commands.FullHandler{
|
||||
Func: wrapCommand(fnBridge),
|
||||
Name: "bridge",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Bridge this room to a specific Discord channel",
|
||||
Args: "[--replace[=delete]] <_channel ID_>",
|
||||
},
|
||||
RequiresEventLevel: roomModerator,
|
||||
}
|
||||
|
||||
func isNumber(str string) bool {
|
||||
for _, chr := range str {
|
||||
if chr < '0' || chr > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fnBridge(ce *WrappedCommandEvent) {
|
||||
if ce.Portal != nil {
|
||||
ce.Reply("This is already a portal room. Unbridge with `$cmdprefix unbridge` first if you want to link it to a different channel.")
|
||||
return
|
||||
}
|
||||
var channelID string
|
||||
var unbridgeOld, deleteOld bool
|
||||
fail := true
|
||||
for _, arg := range ce.Args {
|
||||
arg = strings.ToLower(arg)
|
||||
if arg == "--replace" {
|
||||
unbridgeOld = true
|
||||
} else if arg == "--replace=delete" {
|
||||
unbridgeOld = true
|
||||
deleteOld = true
|
||||
} else if channelID == "" && isNumber(arg) {
|
||||
channelID = arg
|
||||
fail = false
|
||||
} else {
|
||||
fail = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if fail {
|
||||
ce.Reply("**Usage**: `$cmdprefix bridge [--replace[=delete]] <channel ID>`")
|
||||
return
|
||||
}
|
||||
portal := ce.User.GetExistingPortalByID(channelID)
|
||||
if portal == nil {
|
||||
ce.Reply("Channel not found")
|
||||
return
|
||||
}
|
||||
portal.roomCreateLock.Lock()
|
||||
defer portal.roomCreateLock.Unlock()
|
||||
if portal.MXID != "" {
|
||||
hasUnbridgePermission := ce.User.PermissionLevel >= bridgeconfig.PermissionLevelAdmin
|
||||
if !hasUnbridgePermission {
|
||||
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
|
||||
if errors.Is(err, mautrix.MNotFound) {
|
||||
ce.ZLog.Debug().Err(err).Msg("Got M_NOT_FOUND trying to get power levels to check if user can unbridge it, assuming the room is gone")
|
||||
hasUnbridgePermission = true
|
||||
} else if err != nil {
|
||||
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
|
||||
ce.Reply("Failed to get power levels in old room to see if you're allowed to unbridge it")
|
||||
return
|
||||
} else {
|
||||
hasUnbridgePermission = levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(roomModerator)
|
||||
}
|
||||
}
|
||||
if !unbridgeOld || !hasUnbridgePermission {
|
||||
extraHelp := "Rerun the command with `--replace` or `--replace=delete` to unbridge the old room."
|
||||
if !hasUnbridgePermission {
|
||||
extraHelp = "Additionally, you do not have the permissions to unbridge the old room."
|
||||
}
|
||||
ce.Reply("That channel is already bridged to [%s](https://matrix.to/#/%s). %s", portal.Name, portal.MXID, extraHelp)
|
||||
return
|
||||
}
|
||||
ce.ZLog.Debug().
|
||||
Str("old_room_id", portal.MXID.String()).
|
||||
Bool("delete", deleteOld).
|
||||
Msg("Unbridging old room")
|
||||
portal.removeFromSpace()
|
||||
portal.cleanup(!deleteOld)
|
||||
portal.RemoveMXID()
|
||||
ce.ZLog.Info().
|
||||
Str("old_room_id", portal.MXID.String()).
|
||||
Bool("delete", deleteOld).
|
||||
Msg("Unbridged old room to make space for new bridge")
|
||||
}
|
||||
if portal.Guild != nil && portal.Guild.BridgingMode < database.GuildBridgeIfPortalExists {
|
||||
ce.ZLog.Debug().Str("guild_id", portal.Guild.ID).Msg("Bumping bridging mode of portal guild to if-portal-exists")
|
||||
portal.Guild.BridgingMode = database.GuildBridgeIfPortalExists
|
||||
portal.Guild.Update()
|
||||
}
|
||||
ce.ZLog.Debug().Str("channel_id", portal.Key.ChannelID).Msg("Bridging room")
|
||||
portal.MXID = ce.RoomID
|
||||
portal.bridge.portalsLock.Lock()
|
||||
portal.bridge.portalsByMXID[portal.MXID] = portal
|
||||
portal.bridge.portalsLock.Unlock()
|
||||
portal.updateRoomName()
|
||||
portal.updateRoomAvatar()
|
||||
portal.updateRoomTopic()
|
||||
portal.updateSpace(ce.User)
|
||||
portal.UpdateBridgeInfo()
|
||||
state, err := portal.MainIntent().State(portal.MXID)
|
||||
if err != nil {
|
||||
ce.ZLog.Error().Err(err).Msg("Failed to update state cache for room")
|
||||
} else {
|
||||
encryptionEvent, isEncrypted := state[event.StateEncryption][""]
|
||||
portal.Encrypted = isEncrypted && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1
|
||||
}
|
||||
portal.Update()
|
||||
ce.Reply("Room successfully bridged")
|
||||
ce.ZLog.Info().
|
||||
Str("channel_id", portal.Key.ChannelID).
|
||||
Bool("encrypted", portal.Encrypted).
|
||||
Msg("Manual bridging complete")
|
||||
}
|
||||
|
||||
var cmdUnbridge = &commands.FullHandler{
|
||||
Func: wrapCommand(fnUnbridge),
|
||||
Name: "unbridge",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Unbridge this room from the linked Discord channel",
|
||||
},
|
||||
RequiresPortal: true,
|
||||
RequiresEventLevel: roomModerator,
|
||||
}
|
||||
|
||||
var cmdCreatePortal = &commands.FullHandler{
|
||||
Func: wrapCommand(fnCreatePortal),
|
||||
Name: "create-portal",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Create a portal for a specific channel",
|
||||
Args: "<_channel ID_>",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
func fnCreatePortal(ce *WrappedCommandEvent) {
|
||||
meta, err := ce.User.Session.Channel(ce.Args[0])
|
||||
if err != nil {
|
||||
ce.Reply("Failed to get channel info: %v", err)
|
||||
return
|
||||
} else if meta == nil {
|
||||
ce.Reply("Channel not found")
|
||||
return
|
||||
} else if !ce.User.channelIsBridgeable(meta) {
|
||||
ce.Reply("That channel can't be bridged")
|
||||
return
|
||||
}
|
||||
portal := ce.User.GetPortalByMeta(meta)
|
||||
if portal.Guild != nil && portal.Guild.BridgingMode == database.GuildBridgeNothing {
|
||||
ce.Reply("That guild is set to not bridge any messages. Bridge the guild with `$cmdprefix guilds bridge %s` first", portal.Guild.ID)
|
||||
return
|
||||
} else if portal.MXID != "" {
|
||||
ce.Reply("That channel is already bridged: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
|
||||
return
|
||||
}
|
||||
err = portal.CreateMatrixRoom(ce.User, meta)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to create portal: %v", err)
|
||||
} else {
|
||||
ce.Reply("Portal created: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
|
||||
}
|
||||
}
|
||||
|
||||
var cmdDeletePortal = &commands.FullHandler{
|
||||
Func: wrapCommand(fnUnbridge),
|
||||
Name: "delete-portal",
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionPortalManagement,
|
||||
Description: "Unbridge this room and kick all Matrix users",
|
||||
},
|
||||
RequiresPortal: true,
|
||||
RequiresEventLevel: roomModerator,
|
||||
}
|
||||
|
||||
func fnUnbridge(ce *WrappedCommandEvent) {
|
||||
ce.Portal.roomCreateLock.Lock()
|
||||
defer ce.Portal.roomCreateLock.Unlock()
|
||||
ce.Portal.removeFromSpace()
|
||||
ce.Portal.cleanup(ce.Command == "unbridge")
|
||||
ce.Portal.RemoveMXID()
|
||||
}
|
||||
|
||||
var cmdDeleteAllPortals = &commands.FullHandler{
|
||||
Func: wrapCommand(fnDeleteAllPortals),
|
||||
Name: "delete-all-portals",
|
||||
Help: commands.HelpMeta{
|
||||
Section: commands.HelpSectionAdmin,
|
||||
Description: "Delete all portals.",
|
||||
},
|
||||
RequiresAdmin: true,
|
||||
}
|
||||
|
||||
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
|
||||
portals := ce.Bridge.GetAllPortals()
|
||||
guilds := ce.Bridge.GetAllGuilds()
|
||||
if len(portals) == 0 && len(guilds) == 0 {
|
||||
ce.Reply("Didn't find any portals")
|
||||
return
|
||||
}
|
||||
|
||||
leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
|
||||
if len(mxid) > 0 {
|
||||
_, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
|
||||
Reason: "Deleting portal",
|
||||
UserID: ce.User.MXID,
|
||||
})
|
||||
}
|
||||
}
|
||||
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
|
||||
if customPuppet != nil && customPuppet.CustomIntent() != nil {
|
||||
intent := customPuppet.CustomIntent()
|
||||
leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
|
||||
if len(mxid) > 0 {
|
||||
_, _ = intent.LeaveRoom(mxid)
|
||||
_, _ = intent.ForgetRoom(mxid)
|
||||
}
|
||||
}
|
||||
}
|
||||
ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
|
||||
for _, portal := range portals {
|
||||
portal.Delete()
|
||||
leave(portal.MXID, portal.MainIntent())
|
||||
}
|
||||
for _, guild := range guilds {
|
||||
guild.Delete()
|
||||
leave(guild.MXID, ce.Bot)
|
||||
}
|
||||
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. You'll have to restart the bridge or relogin before rooms can be bridged again.")
|
||||
|
||||
go func() {
|
||||
for _, portal := range portals {
|
||||
portal.cleanup(false)
|
||||
}
|
||||
ce.Reply("Finished background cleanup of deleted portal rooms.")
|
||||
}()
|
||||
}
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2023 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/google/shlex"
|
||||
|
||||
"maunium.net/go/mautrix/bridge/commands"
|
||||
)
|
||||
|
||||
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
|
||||
|
||||
var cmdCommands = &commands.FullHandler{
|
||||
Func: wrapCommand(fnCommands),
|
||||
Name: "commands",
|
||||
Aliases: []string{"cmds", "cs"},
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionDiscordBots,
|
||||
Description: "View parameters of bot interaction commands on Discord",
|
||||
Args: "search <_query_> OR help <_command_>",
|
||||
},
|
||||
RequiresPortal: true,
|
||||
RequiresLogin: true,
|
||||
}
|
||||
|
||||
var cmdExec = &commands.FullHandler{
|
||||
Func: wrapCommand(fnExec),
|
||||
Name: "exec",
|
||||
Aliases: []string{"command", "cmd", "c", "exec", "e"},
|
||||
Help: commands.HelpMeta{
|
||||
Section: HelpSectionDiscordBots,
|
||||
Description: "Run bot interaction commands on Discord",
|
||||
Args: "<_command_> [_arg=value ..._]",
|
||||
},
|
||||
RequiresLogin: true,
|
||||
RequiresPortal: true,
|
||||
}
|
||||
|
||||
func (portal *Portal) getCommand(user *User, command string) (*discordgo.ApplicationCommand, error) {
|
||||
portal.commandsLock.Lock()
|
||||
defer portal.commandsLock.Unlock()
|
||||
cmd, ok := portal.commands[command]
|
||||
if !ok {
|
||||
results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command, portal.RefererOpt(""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Name == command {
|
||||
portal.commands[result.Name] = result
|
||||
cmd = result
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func getCommandOptionTypeName(optType discordgo.ApplicationCommandOptionType) string {
|
||||
switch optType {
|
||||
case discordgo.ApplicationCommandOptionSubCommand:
|
||||
return "subcommand"
|
||||
case discordgo.ApplicationCommandOptionSubCommandGroup:
|
||||
return "subcommand group (unsupported)"
|
||||
case discordgo.ApplicationCommandOptionString:
|
||||
return "string"
|
||||
case discordgo.ApplicationCommandOptionInteger:
|
||||
return "integer"
|
||||
case discordgo.ApplicationCommandOptionBoolean:
|
||||
return "boolean"
|
||||
case discordgo.ApplicationCommandOptionUser:
|
||||
return "user (unsupported)"
|
||||
case discordgo.ApplicationCommandOptionChannel:
|
||||
return "channel (unsupported)"
|
||||
case discordgo.ApplicationCommandOptionRole:
|
||||
return "role (unsupported)"
|
||||
case discordgo.ApplicationCommandOptionMentionable:
|
||||
return "mentionable (unsupported)"
|
||||
case discordgo.ApplicationCommandOptionNumber:
|
||||
return "number"
|
||||
case discordgo.ApplicationCommandOptionAttachment:
|
||||
return "attachment (unsupported)"
|
||||
default:
|
||||
return fmt.Sprintf("unknown type %d", optType)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCommandOptionValue(optType discordgo.ApplicationCommandOptionType, value string) (any, error) {
|
||||
switch optType {
|
||||
case discordgo.ApplicationCommandOptionSubCommandGroup:
|
||||
return nil, fmt.Errorf("subcommand groups aren't supported")
|
||||
case discordgo.ApplicationCommandOptionString:
|
||||
return value, nil
|
||||
case discordgo.ApplicationCommandOptionInteger:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
case discordgo.ApplicationCommandOptionBoolean:
|
||||
return strconv.ParseBool(value)
|
||||
case discordgo.ApplicationCommandOptionUser:
|
||||
return nil, fmt.Errorf("user options aren't supported")
|
||||
case discordgo.ApplicationCommandOptionChannel:
|
||||
return nil, fmt.Errorf("channel options aren't supported")
|
||||
case discordgo.ApplicationCommandOptionRole:
|
||||
return nil, fmt.Errorf("role options aren't supported")
|
||||
case discordgo.ApplicationCommandOptionMentionable:
|
||||
return nil, fmt.Errorf("mentionable options aren't supported")
|
||||
case discordgo.ApplicationCommandOptionNumber:
|
||||
return strconv.ParseFloat(value, 64)
|
||||
case discordgo.ApplicationCommandOptionAttachment:
|
||||
return nil, fmt.Errorf("attachment options aren't supported")
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown option type %d", optType)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(text, with string) string {
|
||||
split := strings.Split(text, "\n")
|
||||
for i, part := range split {
|
||||
split[i] = with + part
|
||||
}
|
||||
return strings.Join(split, "\n")
|
||||
}
|
||||
|
||||
func formatOption(opt *discordgo.ApplicationCommandOption) string {
|
||||
argText := fmt.Sprintf("* `%s`: %s", opt.Name, getCommandOptionTypeName(opt.Type))
|
||||
if strings.ToLower(opt.Description) != opt.Name {
|
||||
argText += fmt.Sprintf(" - %s", opt.Description)
|
||||
}
|
||||
if opt.Required {
|
||||
argText += " (required)"
|
||||
}
|
||||
if len(opt.Options) > 0 {
|
||||
subopts := make([]string, len(opt.Options))
|
||||
for i, subopt := range opt.Options {
|
||||
subopts[i] = indent(formatOption(subopt), " ")
|
||||
}
|
||||
argText += "\n" + strings.Join(subopts, "\n")
|
||||
}
|
||||
return argText
|
||||
}
|
||||
|
||||
func formatCommand(cmd *discordgo.ApplicationCommand) string {
|
||||
baseText := fmt.Sprintf("$cmdprefix exec %s", cmd.Name)
|
||||
if len(cmd.Options) > 0 {
|
||||
args := make([]string, len(cmd.Options))
|
||||
argPlaceholder := "[arg=value ...]"
|
||||
for i, opt := range cmd.Options {
|
||||
args[i] = formatOption(opt)
|
||||
if opt.Required {
|
||||
argPlaceholder = "<arg=value ...>"
|
||||
}
|
||||
}
|
||||
baseText = fmt.Sprintf("`%s %s` - %s\n%s", baseText, argPlaceholder, cmd.Description, strings.Join(args, "\n"))
|
||||
} else {
|
||||
baseText = fmt.Sprintf("`%s` - %s", baseText, cmd.Description)
|
||||
}
|
||||
return baseText
|
||||
}
|
||||
|
||||
func parseCommandOptions(opts []*discordgo.ApplicationCommandOption, subcommands []string, namedArgs map[string]string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
|
||||
subcommandDone := false
|
||||
for _, opt := range opts {
|
||||
optRes := &discordgo.ApplicationCommandOptionInput{
|
||||
Type: opt.Type,
|
||||
Name: opt.Name,
|
||||
}
|
||||
if opt.Type == discordgo.ApplicationCommandOptionSubCommand {
|
||||
if !subcommandDone && len(subcommands) > 0 && subcommands[0] == opt.Name {
|
||||
subcommandDone = true
|
||||
optRes.Options, err = parseCommandOptions(opt.Options, subcommands[1:], namedArgs)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error parsing subcommand %s: %v", opt.Name, err)
|
||||
break
|
||||
}
|
||||
subcommands = subcommands[1:]
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else if argVal, ok := namedArgs[opt.Name]; ok {
|
||||
optRes.Value, err = parseCommandOptionValue(opt.Type, argVal)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error parsing parameter %s: %v", opt.Name, err)
|
||||
break
|
||||
}
|
||||
} else if opt.Required {
|
||||
switch opt.Type {
|
||||
case discordgo.ApplicationCommandOptionSubCommandGroup, discordgo.ApplicationCommandOptionUser,
|
||||
discordgo.ApplicationCommandOptionChannel, discordgo.ApplicationCommandOptionRole,
|
||||
discordgo.ApplicationCommandOptionMentionable, discordgo.ApplicationCommandOptionAttachment:
|
||||
err = fmt.Errorf("missing required parameter %s (which is not supported by the bridge)", opt.Name)
|
||||
default:
|
||||
err = fmt.Errorf("missing required parameter %s", opt.Name)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
res = append(res, optRes)
|
||||
}
|
||||
if len(subcommands) > 0 {
|
||||
err = fmt.Errorf("unparsed subcommands left over (did you forget quoting for parameters with spaces?)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func executeCommand(cmd *discordgo.ApplicationCommand, args []string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
|
||||
namedArgs := map[string]string{}
|
||||
n := 0
|
||||
for _, arg := range args {
|
||||
name, value, isNamed := strings.Cut(arg, "=")
|
||||
if isNamed {
|
||||
namedArgs[name] = value
|
||||
} else {
|
||||
args[n] = arg
|
||||
n++
|
||||
}
|
||||
}
|
||||
return parseCommandOptions(cmd.Options, args[:n], namedArgs)
|
||||
}
|
||||
|
||||
func fnCommands(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) < 2 {
|
||||
ce.Reply("**Usage**: `$cmdprefix commands search <_query_>` OR `$cmdprefix commands help <_command_>`")
|
||||
return
|
||||
}
|
||||
subcmd := strings.ToLower(ce.Args[0])
|
||||
if subcmd == "search" {
|
||||
results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1], ce.Portal.RefererOpt(""))
|
||||
if err != nil {
|
||||
ce.Reply("Error searching for commands: %v", err)
|
||||
return
|
||||
}
|
||||
formatted := make([]string, len(results))
|
||||
ce.Portal.commandsLock.Lock()
|
||||
for i, result := range results {
|
||||
ce.Portal.commands[result.Name] = result
|
||||
formatted[i] = indent(formatCommand(result), " ")
|
||||
formatted[i] = "*" + formatted[i][1:]
|
||||
}
|
||||
ce.Portal.commandsLock.Unlock()
|
||||
ce.Reply("Found results:\n" + strings.Join(formatted, "\n"))
|
||||
} else if subcmd == "help" {
|
||||
command := strings.ToLower(ce.Args[1])
|
||||
cmd, err := ce.Portal.getCommand(ce.User, command)
|
||||
if err != nil {
|
||||
ce.Reply("Error searching for commands: %v", err)
|
||||
} else if cmd == nil {
|
||||
ce.Reply("Command %q not found", command)
|
||||
} else {
|
||||
ce.Reply(formatCommand(cmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fnExec(ce *WrappedCommandEvent) {
|
||||
if len(ce.Args) == 0 {
|
||||
ce.Reply("**Usage**: `$cmdprefix exec <command> [arg=value ...]`")
|
||||
return
|
||||
}
|
||||
args, err := shlex.Split(ce.RawArgs)
|
||||
if err != nil {
|
||||
ce.Reply("Error parsing args with shlex: %v", err)
|
||||
return
|
||||
}
|
||||
command := strings.ToLower(args[0])
|
||||
cmd, err := ce.Portal.getCommand(ce.User, command)
|
||||
if err != nil {
|
||||
ce.Reply("Error searching for commands: %v", err)
|
||||
} else if cmd == nil {
|
||||
ce.Reply("Command %q not found", command)
|
||||
} else if options, err := executeCommand(cmd, args[1:]); err != nil {
|
||||
ce.Reply("Error parsing arguments: %v\n\n**Usage:** "+formatCommand(cmd), err)
|
||||
} else {
|
||||
nonce := generateNonce()
|
||||
ce.User.pendingInteractionsLock.Lock()
|
||||
ce.User.pendingInteractions[nonce] = ce
|
||||
ce.User.pendingInteractionsLock.Unlock()
|
||||
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce, ce.Portal.RefererOpt(""))
|
||||
if err != nil {
|
||||
ce.Reply("Error sending interaction: %v", err)
|
||||
ce.User.pendingInteractionsLock.Lock()
|
||||
delete(ce.User.pendingInteractions, nonce)
|
||||
ce.User.pendingInteractionsLock.Unlock()
|
||||
} else {
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second)
|
||||
ce.User.pendingInteractionsLock.Lock()
|
||||
if _, stillWaiting := ce.User.pendingInteractions[nonce]; stillWaiting {
|
||||
delete(ce.User.pendingInteractions, nonce)
|
||||
ce.Reply("Timed out waiting for interaction success")
|
||||
}
|
||||
ce.User.pendingInteractionsLock.Unlock()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
239
config/bridge.go
239
config/bridge.go
|
|
@ -1,239 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
)
|
||||
|
||||
type BridgeConfig struct {
|
||||
UsernameTemplate string `yaml:"username_template"`
|
||||
DisplaynameTemplate string `yaml:"displayname_template"`
|
||||
ChannelNameTemplate string `yaml:"channel_name_template"`
|
||||
GuildNameTemplate string `yaml:"guild_name_template"`
|
||||
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
|
||||
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
|
||||
|
||||
PortalMessageBuffer int `yaml:"portal_message_buffer"`
|
||||
|
||||
PublicAddress string `yaml:"public_address"`
|
||||
AvatarProxyKey string `yaml:"avatar_proxy_key"`
|
||||
|
||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
||||
RestrictedRooms bool `yaml:"restricted_rooms"`
|
||||
AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"`
|
||||
EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"`
|
||||
MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"`
|
||||
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
|
||||
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
|
||||
CustomEmojiReactions bool `yaml:"custom_emoji_reactions"`
|
||||
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
|
||||
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
|
||||
FederateRooms bool `yaml:"federate_rooms"`
|
||||
PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
|
||||
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
|
||||
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
|
||||
|
||||
Proxy string `yaml:"proxy"`
|
||||
|
||||
CacheMedia string `yaml:"cache_media"`
|
||||
DirectMedia DirectMedia `yaml:"direct_media"`
|
||||
|
||||
AnimatedSticker struct {
|
||||
Target string `yaml:"target"`
|
||||
Args struct {
|
||||
Width int `yaml:"width"`
|
||||
Height int `yaml:"height"`
|
||||
FPS int `yaml:"fps"`
|
||||
} `yaml:"args"`
|
||||
} `yaml:"animated_sticker"`
|
||||
|
||||
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
|
||||
|
||||
CommandPrefix string `yaml:"command_prefix"`
|
||||
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
|
||||
|
||||
Backfill struct {
|
||||
Limits struct {
|
||||
Initial BackfillLimitPart `yaml:"initial"`
|
||||
Missed BackfillLimitPart `yaml:"missed"`
|
||||
} `yaml:"forward_limits"`
|
||||
MaxGuildMembers int `yaml:"max_guild_members"`
|
||||
} `yaml:"backfill"`
|
||||
|
||||
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
|
||||
|
||||
Provisioning struct {
|
||||
Prefix string `yaml:"prefix"`
|
||||
SharedSecret string `yaml:"shared_secret"`
|
||||
DebugEndpoints bool `yaml:"debug_endpoints"`
|
||||
} `yaml:"provisioning"`
|
||||
|
||||
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
|
||||
|
||||
usernameTemplate *template.Template `yaml:"-"`
|
||||
displaynameTemplate *template.Template `yaml:"-"`
|
||||
channelNameTemplate *template.Template `yaml:"-"`
|
||||
guildNameTemplate *template.Template `yaml:"-"`
|
||||
}
|
||||
|
||||
type DirectMedia struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ServerName string `yaml:"server_name"`
|
||||
WellKnownResponse string `yaml:"well_known_response"`
|
||||
AllowProxy bool `yaml:"allow_proxy"`
|
||||
ServerKey string `yaml:"server_key"`
|
||||
}
|
||||
|
||||
type BackfillLimitPart struct {
|
||||
DM int `yaml:"dm"`
|
||||
Channel int `yaml:"channel"`
|
||||
Thread int `yaml:"thread"`
|
||||
}
|
||||
|
||||
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
|
||||
return bc.ResendBridgeInfo
|
||||
}
|
||||
|
||||
func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
|
||||
return bc.MessageStatusEvents
|
||||
}
|
||||
|
||||
func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
|
||||
return bc.MessageErrorNotices
|
||||
}
|
||||
|
||||
func boolToInt(val bool) int {
|
||||
if val {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (bc *BridgeConfig) Validate() error {
|
||||
_, hasWildcard := bc.Permissions["*"]
|
||||
_, hasExampleDomain := bc.Permissions["example.com"]
|
||||
_, hasExampleUser := bc.Permissions["@admin:example.com"]
|
||||
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
|
||||
if len(bc.Permissions) <= exampleLen {
|
||||
return errors.New("bridge.permissions not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type umBridgeConfig BridgeConfig
|
||||
|
||||
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
err := unmarshal((*umBridgeConfig)(bc))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
|
||||
return fmt.Errorf("username template is missing user ID placeholder")
|
||||
}
|
||||
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
|
||||
|
||||
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
|
||||
return bc.DoublePuppetConfig
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
|
||||
return bc.Encryption
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetCommandPrefix() string {
|
||||
return bc.CommandPrefix
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
|
||||
return bc.ManagementRoomText
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatUsername(userID string) string {
|
||||
var buffer strings.Builder
|
||||
_ = bc.usernameTemplate.Execute(&buffer, userID)
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
type DisplaynameParams struct {
|
||||
*discordgo.User
|
||||
Webhook bool
|
||||
Application bool
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string {
|
||||
var buffer strings.Builder
|
||||
_ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
|
||||
User: user,
|
||||
Webhook: webhook,
|
||||
Application: application,
|
||||
})
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
type ChannelNameParams struct {
|
||||
Name string
|
||||
ParentName string
|
||||
GuildName string
|
||||
NSFW bool
|
||||
Type discordgo.ChannelType
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string {
|
||||
var buffer strings.Builder
|
||||
_ = bc.channelNameTemplate.Execute(&buffer, params)
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
type GuildNameParams struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string {
|
||||
var buffer strings.Builder
|
||||
_ = bc.guildNameTemplate.Execute(&buffer, params)
|
||||
return buffer.String()
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2023 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
up "go.mau.fi/util/configupgrade"
|
||||
"go.mau.fi/util/random"
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/federation"
|
||||
)
|
||||
|
||||
func DoUpgrade(helper *up.Helper) {
|
||||
bridgeconfig.Upgrader.DoUpgrade(helper)
|
||||
|
||||
helper.Copy(up.Str, "bridge", "username_template")
|
||||
helper.Copy(up.Str, "bridge", "displayname_template")
|
||||
helper.Copy(up.Str, "bridge", "channel_name_template")
|
||||
helper.Copy(up.Str, "bridge", "guild_name_template")
|
||||
if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
|
||||
updatedPrivateChatPortalMeta := "default"
|
||||
if legacyPrivateChatPortalMeta == "true" {
|
||||
updatedPrivateChatPortalMeta = "always"
|
||||
}
|
||||
helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
|
||||
} else {
|
||||
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
|
||||
}
|
||||
helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "public_address")
|
||||
if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" {
|
||||
helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key")
|
||||
} else {
|
||||
helper.Copy(up.Str, "bridge", "avatar_proxy_key")
|
||||
}
|
||||
helper.Copy(up.Int, "bridge", "portal_message_buffer")
|
||||
helper.Copy(up.Bool, "bridge", "delivery_receipts")
|
||||
helper.Copy(up.Bool, "bridge", "message_status_events")
|
||||
helper.Copy(up.Bool, "bridge", "message_error_notices")
|
||||
helper.Copy(up.Bool, "bridge", "restricted_rooms")
|
||||
helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open")
|
||||
helper.Copy(up.Bool, "bridge", "embed_fields_as_tables")
|
||||
helper.Copy(up.Bool, "bridge", "mute_channels_on_create")
|
||||
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
|
||||
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
|
||||
helper.Copy(up.Bool, "bridge", "custom_emoji_reactions")
|
||||
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
|
||||
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
|
||||
helper.Copy(up.Bool, "bridge", "federate_rooms")
|
||||
helper.Copy(up.Bool, "bridge", "prefix_webhook_messages")
|
||||
helper.Copy(up.Bool, "bridge", "enable_webhook_avatars")
|
||||
helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "proxy")
|
||||
helper.Copy(up.Str, "bridge", "cache_media")
|
||||
helper.Copy(up.Bool, "bridge", "direct_media", "enabled")
|
||||
helper.Copy(up.Str, "bridge", "direct_media", "server_name")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "direct_media", "well_known_response")
|
||||
helper.Copy(up.Bool, "bridge", "direct_media", "allow_proxy")
|
||||
if serverKey, ok := helper.Get(up.Str, "bridge", "direct_media", "server_key"); !ok || serverKey == "generate" {
|
||||
serverKey = federation.GenerateSigningKey().SynapseString()
|
||||
helper.Set(up.Str, serverKey, "bridge", "direct_media", "server_key")
|
||||
} else {
|
||||
helper.Copy(up.Str, "bridge", "direct_media", "server_key")
|
||||
}
|
||||
helper.Copy(up.Str, "bridge", "animated_sticker", "target")
|
||||
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
|
||||
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
|
||||
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps")
|
||||
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
|
||||
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
|
||||
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
|
||||
helper.Copy(up.Str, "bridge", "command_prefix")
|
||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
|
||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
|
||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
|
||||
helper.Copy(up.Bool, "bridge", "backfill", "enabled")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread")
|
||||
helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "allow")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "default")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "require")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "msc4190")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
|
||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
|
||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
|
||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
|
||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
|
||||
|
||||
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
|
||||
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
|
||||
sharedSecret := random.String(64)
|
||||
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
|
||||
} else {
|
||||
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
|
||||
}
|
||||
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
|
||||
|
||||
helper.Copy(up.Map, "bridge", "permissions")
|
||||
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
|
||||
//helper.Copy(up.Bool, "bridge", "relay", "admin_only")
|
||||
//helper.Copy(up.Map, "bridge", "relay", "message_formats")
|
||||
}
|
||||
|
||||
var SpacedBlocks = [][]string{
|
||||
{"homeserver", "software"},
|
||||
{"appservice"},
|
||||
{"appservice", "hostname"},
|
||||
{"appservice", "database"},
|
||||
{"appservice", "id"},
|
||||
{"appservice", "as_token"},
|
||||
{"bridge"},
|
||||
{"bridge", "command_prefix"},
|
||||
{"bridge", "management_room_text"},
|
||||
{"bridge", "encryption"},
|
||||
{"bridge", "provisioning"},
|
||||
{"bridge", "permissions"},
|
||||
//{"bridge", "relay"},
|
||||
{"logging"},
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
|
||||
puppet.CustomMXID = mxid
|
||||
puppet.AccessToken = accessToken
|
||||
puppet.Update()
|
||||
err := puppet.StartCustomMXID(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO leave rooms with default puppet
|
||||
return nil
|
||||
}
|
||||
|
||||
func (puppet *Puppet) ClearCustomMXID() {
|
||||
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
|
||||
puppet.bridge.puppetsLock.Lock()
|
||||
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
|
||||
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
|
||||
}
|
||||
puppet.bridge.puppetsLock.Unlock()
|
||||
puppet.CustomMXID = ""
|
||||
puppet.AccessToken = ""
|
||||
puppet.customIntent = nil
|
||||
puppet.customUser = nil
|
||||
if save {
|
||||
puppet.Update()
|
||||
}
|
||||
}
|
||||
|
||||
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
|
||||
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
|
||||
if err != nil {
|
||||
puppet.ClearCustomMXID()
|
||||
return err
|
||||
}
|
||||
puppet.bridge.puppetsLock.Lock()
|
||||
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
|
||||
puppet.bridge.puppetsLock.Unlock()
|
||||
if puppet.AccessToken != newAccessToken {
|
||||
puppet.AccessToken = newAccessToken
|
||||
puppet.Update()
|
||||
}
|
||||
puppet.customIntent = newIntent
|
||||
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) tryAutomaticDoublePuppeting() {
|
||||
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
|
||||
return
|
||||
}
|
||||
user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
|
||||
puppet := user.bridge.GetPuppetByID(user.DiscordID)
|
||||
if len(puppet.CustomMXID) > 0 {
|
||||
user.log.Debug().Msg("User already has double-puppeting enabled")
|
||||
// Custom puppet already enabled
|
||||
return
|
||||
}
|
||||
puppet.CustomMXID = user.MXID
|
||||
err := puppet.StartCustomMXID(true)
|
||||
if err != nil {
|
||||
user.log.Warn().Err(err).Msg("Failed to login with shared secret")
|
||||
} else {
|
||||
// TODO leave rooms with default puppet
|
||||
user.log.Debug().Msg("Successfully automatically enabled custom puppet")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/maulogger/v2"
|
||||
|
||||
"go.mau.fi/mautrix-discord/database/upgrades"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
*dbutil.Database
|
||||
|
||||
User *UserQuery
|
||||
Portal *PortalQuery
|
||||
Puppet *PuppetQuery
|
||||
Message *MessageQuery
|
||||
Thread *ThreadQuery
|
||||
Reaction *ReactionQuery
|
||||
Guild *GuildQuery
|
||||
Role *RoleQuery
|
||||
File *FileQuery
|
||||
}
|
||||
|
||||
func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
|
||||
db := &Database{Database: baseDB}
|
||||
db.UpgradeTable = upgrades.Table
|
||||
db.User = &UserQuery{
|
||||
db: db,
|
||||
log: log.Sub("User"),
|
||||
}
|
||||
db.Portal = &PortalQuery{
|
||||
db: db,
|
||||
log: log.Sub("Portal"),
|
||||
}
|
||||
db.Puppet = &PuppetQuery{
|
||||
db: db,
|
||||
log: log.Sub("Puppet"),
|
||||
}
|
||||
db.Message = &MessageQuery{
|
||||
db: db,
|
||||
log: log.Sub("Message"),
|
||||
}
|
||||
db.Thread = &ThreadQuery{
|
||||
db: db,
|
||||
log: log.Sub("Thread"),
|
||||
}
|
||||
db.Reaction = &ReactionQuery{
|
||||
db: db,
|
||||
log: log.Sub("Reaction"),
|
||||
}
|
||||
db.Guild = &GuildQuery{
|
||||
db: db,
|
||||
log: log.Sub("Guild"),
|
||||
}
|
||||
db.Role = &RoleQuery{
|
||||
db: db,
|
||||
log: log.Sub("Role"),
|
||||
}
|
||||
db.File = &FileQuery{
|
||||
db: db,
|
||||
log: log.Sub("File"),
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func strPtr[T ~string](val T) *string {
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
valStr := string(val)
|
||||
return &valStr
|
||||
}
|
||||
138
database/file.go
138
database/file.go
|
|
@ -1,138 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type FileQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// language=postgresql
|
||||
const (
|
||||
fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
|
||||
fileInsert = `
|
||||
INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
)
|
||||
|
||||
func (fq *FileQuery) New() *File {
|
||||
return &File{
|
||||
db: fq.db,
|
||||
log: fq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (fq *FileQuery) Get(url string, encrypted bool) *File {
|
||||
query := fileSelect + " WHERE url=$1 AND encrypted=$2"
|
||||
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
|
||||
}
|
||||
|
||||
func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
|
||||
query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1"
|
||||
return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
|
||||
}
|
||||
|
||||
type File struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
URL string
|
||||
Encrypted bool
|
||||
MXC id.ContentURI
|
||||
|
||||
ID string
|
||||
EmojiName string
|
||||
|
||||
Size int
|
||||
Width int
|
||||
Height int
|
||||
MimeType string
|
||||
|
||||
DecryptionInfo *attachment.EncryptedFile
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func (f *File) Scan(row dbutil.Scannable) *File {
|
||||
var fileID, emojiName, decryptionInfo sql.NullString
|
||||
var width, height sql.NullInt32
|
||||
var timestamp int64
|
||||
var mxc string
|
||||
err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
f.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.ID = fileID.String
|
||||
f.EmojiName = emojiName.String
|
||||
f.Timestamp = time.UnixMilli(timestamp).UTC()
|
||||
f.Width = int(width.Int32)
|
||||
f.Height = int(height.Int32)
|
||||
f.MXC, err = id.ParseContentURI(mxc)
|
||||
if err != nil {
|
||||
f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err)
|
||||
panic(err)
|
||||
}
|
||||
if decryptionInfo.Valid {
|
||||
err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo)
|
||||
if err != nil {
|
||||
f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func positiveIntToNullInt32(val int) (ptr sql.NullInt32) {
|
||||
if val > 0 {
|
||||
ptr.Valid = true
|
||||
ptr.Int32 = int32(val)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (f *File) Insert(txn dbutil.Execable) {
|
||||
if txn == nil {
|
||||
txn = f.db
|
||||
}
|
||||
var decryptionInfoStr sql.NullString
|
||||
if f.DecryptionInfo != nil {
|
||||
decryptionInfo, err := json.Marshal(f.DecryptionInfo)
|
||||
if err != nil {
|
||||
f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err)
|
||||
panic(err)
|
||||
}
|
||||
decryptionInfoStr.Valid = true
|
||||
decryptionInfoStr.String = string(decryptionInfo)
|
||||
}
|
||||
_, err := txn.Exec(fileInsert,
|
||||
f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
|
||||
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
|
||||
decryptionInfoStr, f.Timestamp.UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *File) Delete() {
|
||||
_, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted)
|
||||
if err != nil {
|
||||
f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type GuildBridgingMode int
|
||||
|
||||
const (
|
||||
// GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists.
|
||||
GuildBridgeNothing GuildBridgingMode = iota
|
||||
// GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals.
|
||||
GuildBridgeIfPortalExists
|
||||
// GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received.
|
||||
GuildBridgeCreateOnMessage
|
||||
// GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications.
|
||||
GuildBridgeEverything
|
||||
|
||||
GuildBridgeInvalid GuildBridgingMode = -1
|
||||
)
|
||||
|
||||
func ParseGuildBridgingMode(str string) GuildBridgingMode {
|
||||
str = strings.ToLower(str)
|
||||
str = strings.ReplaceAll(str, "-", "")
|
||||
str = strings.ReplaceAll(str, "_", "")
|
||||
switch str {
|
||||
case "nothing", "0":
|
||||
return GuildBridgeNothing
|
||||
case "ifportalexists", "1":
|
||||
return GuildBridgeIfPortalExists
|
||||
case "createonmessage", "2":
|
||||
return GuildBridgeCreateOnMessage
|
||||
case "everything", "3":
|
||||
return GuildBridgeEverything
|
||||
default:
|
||||
return GuildBridgeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (gbm GuildBridgingMode) String() string {
|
||||
switch gbm {
|
||||
case GuildBridgeNothing:
|
||||
return "nothing"
|
||||
case GuildBridgeIfPortalExists:
|
||||
return "if-portal-exists"
|
||||
case GuildBridgeCreateOnMessage:
|
||||
return "create-on-message"
|
||||
case GuildBridgeEverything:
|
||||
return "everything"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (gbm GuildBridgingMode) Description() string {
|
||||
switch gbm {
|
||||
case GuildBridgeNothing:
|
||||
return "never bridge messages"
|
||||
case GuildBridgeIfPortalExists:
|
||||
return "bridge messages in existing portals"
|
||||
case GuildBridgeCreateOnMessage:
|
||||
return "bridge all messages and create portals on first message"
|
||||
case GuildBridgeEverything:
|
||||
return "bridge all messages and create portals proactively"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type GuildQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
|
||||
)
|
||||
|
||||
func (gq *GuildQuery) New() *Guild {
|
||||
return &Guild{
|
||||
db: gq.db,
|
||||
log: gq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (gq *GuildQuery) GetByID(dcid string) *Guild {
|
||||
query := guildSelect + " WHERE dcid=$1"
|
||||
return gq.New().Scan(gq.db.QueryRow(query, dcid))
|
||||
}
|
||||
|
||||
func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild {
|
||||
query := guildSelect + " WHERE mxid=$1"
|
||||
return gq.New().Scan(gq.db.QueryRow(query, mxid))
|
||||
}
|
||||
|
||||
func (gq *GuildQuery) GetAll() []*Guild {
|
||||
rows, err := gq.db.Query(guildSelect)
|
||||
if err != nil {
|
||||
gq.log.Errorln("Failed to query guilds:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var guilds []*Guild
|
||||
for rows.Next() {
|
||||
guild := gq.New().Scan(rows)
|
||||
if guild != nil {
|
||||
guilds = append(guilds, guild)
|
||||
}
|
||||
}
|
||||
|
||||
return guilds
|
||||
}
|
||||
|
||||
type Guild struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
ID string
|
||||
MXID id.RoomID
|
||||
PlainName string
|
||||
Name string
|
||||
NameSet bool
|
||||
Avatar string
|
||||
AvatarURL id.ContentURI
|
||||
AvatarSet bool
|
||||
|
||||
BridgingMode GuildBridgingMode
|
||||
}
|
||||
|
||||
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
|
||||
var mxid sql.NullString
|
||||
var avatarURL string
|
||||
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
g.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything {
|
||||
panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID))
|
||||
}
|
||||
g.MXID = id.RoomID(mxid.String)
|
||||
g.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Guild) mxidPtr() *id.RoomID {
|
||||
if g.MXID != "" {
|
||||
return &g.MXID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guild) Insert() {
|
||||
query := `
|
||||
INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
_, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode)
|
||||
if err != nil {
|
||||
g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guild) Update() {
|
||||
query := `
|
||||
UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8
|
||||
WHERE dcid=$9
|
||||
`
|
||||
_, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID)
|
||||
if err != nil {
|
||||
g.log.Warnfln("Failed to update %s: %v", g.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guild) Delete() {
|
||||
_, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID)
|
||||
if err != nil {
|
||||
g.log.Warnfln("Failed to delete %s: %v", g.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type MessageQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
messageSelect = "SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid FROM message"
|
||||
)
|
||||
|
||||
func (mq *MessageQuery) New() *Message {
|
||||
return &Message{
|
||||
db: mq.db,
|
||||
log: mq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
|
||||
if err != nil {
|
||||
mq.log.Warnfln("Failed to query many messages: %v", err)
|
||||
panic(err)
|
||||
} else if rows == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []*Message
|
||||
for rows.Next() {
|
||||
messages = append(messages, mq.New().Scan(rows))
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC"
|
||||
return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC LIMIT 1"
|
||||
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id DESC LIMIT 1"
|
||||
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND timestamp<=$4 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
|
||||
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli()))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
|
||||
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetLast(key PortalKey) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 ORDER BY timestamp DESC LIMIT 1"
|
||||
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver))
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) DeleteAll(key PortalKey) {
|
||||
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
|
||||
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
|
||||
if err != nil {
|
||||
mq.log.Warnfln("Failed to delete messages of %s: %v", key, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
|
||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3"
|
||||
|
||||
row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return mq.New().Scan(row)
|
||||
}
|
||||
|
||||
func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)"
|
||||
if mq.db.Dialect == dbutil.SQLite {
|
||||
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
|
||||
}
|
||||
params := make([]interface{}, 2+len(msgs)*8)
|
||||
placeholders := make([]string, len(msgs))
|
||||
params[0] = key.ChannelID
|
||||
params[1] = key.Receiver
|
||||
for i, msg := range msgs {
|
||||
baseIndex := 2 + i*8
|
||||
params[baseIndex] = msg.DiscordID
|
||||
params[baseIndex+1] = msg.AttachmentID
|
||||
params[baseIndex+2] = msg.SenderID
|
||||
params[baseIndex+3] = msg.Timestamp.UnixMilli()
|
||||
params[baseIndex+4] = msg.editTimestampVal()
|
||||
params[baseIndex+5] = msg.ThreadID
|
||||
params[baseIndex+6] = msg.MXID
|
||||
params[baseIndex+7] = msg.SenderMXID.String()
|
||||
placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8)
|
||||
}
|
||||
_, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
|
||||
if err != nil {
|
||||
mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
DiscordID string
|
||||
AttachmentID string
|
||||
Channel PortalKey
|
||||
SenderID string
|
||||
Timestamp time.Time
|
||||
EditTimestamp time.Time
|
||||
ThreadID string
|
||||
|
||||
MXID id.EventID
|
||||
SenderMXID id.UserID
|
||||
}
|
||||
|
||||
func (m *Message) DiscordProtoChannelID() string {
|
||||
if m.ThreadID != "" {
|
||||
return m.ThreadID
|
||||
} else {
|
||||
return m.Channel.ChannelID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) Scan(row dbutil.Scannable) *Message {
|
||||
var ts, editTS int64
|
||||
|
||||
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &editTS, &m.ThreadID, &m.MXID, &m.SenderMXID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
m.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if ts != 0 {
|
||||
m.Timestamp = time.UnixMilli(ts).UTC()
|
||||
}
|
||||
if editTS != 0 {
|
||||
m.EditTimestamp = time.Unix(0, editTS).UTC()
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
const messageInsertQuery = `
|
||||
INSERT INTO message (
|
||||
dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
|
||||
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1)
|
||||
|
||||
type MessagePart struct {
|
||||
AttachmentID string
|
||||
MXID id.EventID
|
||||
}
|
||||
|
||||
func (m *Message) editTimestampVal() int64 {
|
||||
if m.EditTimestamp.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return m.EditTimestamp.UnixNano()
|
||||
}
|
||||
|
||||
func (m *Message) MassInsertParts(msgs []MessagePart) {
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)"
|
||||
if m.db.Dialect == dbutil.SQLite {
|
||||
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
|
||||
}
|
||||
params := make([]interface{}, 8+len(msgs)*2)
|
||||
placeholders := make([]string, len(msgs))
|
||||
params[0] = m.DiscordID
|
||||
params[1] = m.Channel.ChannelID
|
||||
params[2] = m.Channel.Receiver
|
||||
params[3] = m.SenderID
|
||||
params[4] = m.Timestamp.UnixMilli()
|
||||
params[5] = m.editTimestampVal()
|
||||
params[6] = m.ThreadID
|
||||
params[7] = m.SenderMXID.String()
|
||||
for i, msg := range msgs {
|
||||
params[8+i*2] = msg.AttachmentID
|
||||
params[8+i*2+1] = msg.MXID
|
||||
placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
|
||||
}
|
||||
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
|
||||
if err != nil {
|
||||
m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) Insert() {
|
||||
_, err := m.db.Exec(messageInsertQuery,
|
||||
m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
|
||||
m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
|
||||
|
||||
if err != nil {
|
||||
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
const editUpdateQuery = `
|
||||
UPDATE message
|
||||
SET dc_edit_timestamp=$1
|
||||
WHERE dcid=$2 AND dc_attachment_id=$3 AND dc_chan_id=$4 AND dc_chan_receiver=$5 AND dc_edit_timestamp<$1
|
||||
`
|
||||
|
||||
func (m *Message) UpdateEditTimestamp(ts time.Time) {
|
||||
_, err := m.db.Exec(editUpdateQuery, ts.UnixNano(), m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver)
|
||||
if err != nil {
|
||||
m.log.Warnfln("Failed to update edit timestamp of %s@%s: %v", m.DiscordID, m.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) Delete() {
|
||||
query := "DELETE FROM message WHERE dcid=$1 AND dc_chan_id=$2 AND dc_chan_receiver=$3 AND dc_attachment_id=$4"
|
||||
_, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)
|
||||
if err != nil {
|
||||
m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// language=postgresql
|
||||
const (
|
||||
portalSelect = `
|
||||
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
||||
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
|
||||
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
|
||||
FROM portal
|
||||
`
|
||||
)
|
||||
|
||||
type PortalKey struct {
|
||||
ChannelID string
|
||||
Receiver string
|
||||
}
|
||||
|
||||
func NewPortalKey(channelID, receiver string) PortalKey {
|
||||
return PortalKey{
|
||||
ChannelID: channelID,
|
||||
Receiver: receiver,
|
||||
}
|
||||
}
|
||||
|
||||
func (key PortalKey) String() string {
|
||||
if key.Receiver == "" {
|
||||
return key.ChannelID
|
||||
}
|
||||
return key.ChannelID + "-" + key.Receiver
|
||||
}
|
||||
|
||||
type PortalQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) New() *Portal {
|
||||
return &Portal{
|
||||
db: pq.db,
|
||||
log: pq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetAll() []*Portal {
|
||||
return pq.getAll(portalSelect)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal {
|
||||
return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetByID(key PortalKey) *Portal {
|
||||
return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
|
||||
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal {
|
||||
return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
|
||||
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal {
|
||||
query := portalSelect + " portal WHERE receiver=$1 AND type=$2;"
|
||||
|
||||
return pq.getAll(query, receiver, discordgo.ChannelTypeDM)
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {
|
||||
rows, err := pq.db.Query(query, args...)
|
||||
if err != nil || rows == nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var portals []*Portal
|
||||
for rows.Next() {
|
||||
portals = append(portals, pq.New().Scan(rows))
|
||||
}
|
||||
|
||||
return portals
|
||||
}
|
||||
|
||||
func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
|
||||
return pq.New().Scan(pq.db.QueryRow(query, args...))
|
||||
}
|
||||
|
||||
type Portal struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
Key PortalKey
|
||||
Type discordgo.ChannelType
|
||||
OtherUserID string
|
||||
ParentID string
|
||||
GuildID string
|
||||
|
||||
MXID id.RoomID
|
||||
|
||||
PlainName string
|
||||
Name string
|
||||
NameSet bool
|
||||
FriendNick bool
|
||||
Topic string
|
||||
TopicSet bool
|
||||
Avatar string
|
||||
AvatarURL id.ContentURI
|
||||
AvatarSet bool
|
||||
Encrypted bool
|
||||
InSpace id.RoomID
|
||||
|
||||
FirstEventID id.EventID
|
||||
|
||||
RelayWebhookID string
|
||||
RelayWebhookSecret string
|
||||
}
|
||||
|
||||
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
|
||||
var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
|
||||
var chanType int32
|
||||
var avatarURL string
|
||||
|
||||
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
|
||||
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
|
||||
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
|
||||
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
p.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
p.MXID = id.RoomID(mxid.String)
|
||||
p.OtherUserID = otherUserID.String
|
||||
p.GuildID = guildID.String
|
||||
p.ParentID = parentID.String
|
||||
p.Type = discordgo.ChannelType(chanType)
|
||||
p.FirstEventID = id.EventID(firstEventID.String)
|
||||
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
||||
p.RelayWebhookID = relayWebhookID.String
|
||||
p.RelayWebhookSecret = relayWebhookSecret.String
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Portal) Insert() {
|
||||
query := `
|
||||
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
||||
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
|
||||
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
`
|
||||
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
|
||||
strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
|
||||
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
|
||||
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
|
||||
|
||||
if err != nil {
|
||||
p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Portal) Update() {
|
||||
query := `
|
||||
UPDATE portal
|
||||
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
|
||||
plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
|
||||
avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
|
||||
relay_webhook_id=$18, relay_webhook_secret=$19
|
||||
WHERE dcid=$20 AND receiver=$21
|
||||
`
|
||||
_, err := p.db.Exec(query,
|
||||
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
|
||||
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
|
||||
p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.Encrypted, p.InSpace, p.FirstEventID.String(),
|
||||
strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
|
||||
p.Key.ChannelID, p.Key.Receiver)
|
||||
|
||||
if err != nil {
|
||||
p.log.Warnfln("Failed to update %s: %v", p.Key, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Portal) Delete() {
|
||||
query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2"
|
||||
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver)
|
||||
if err != nil {
|
||||
p.log.Warnfln("Failed to delete %s: %v", p.Key, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
|
||||
" contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" +
|
||||
" FROM puppet "
|
||||
)
|
||||
|
||||
type PuppetQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) New() *Puppet {
|
||||
return &Puppet{
|
||||
db: pq.db,
|
||||
log: pq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) Get(id string) *Puppet {
|
||||
return pq.get(puppetSelect+" WHERE id=$1", id)
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
|
||||
return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid)
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet {
|
||||
return pq.New().Scan(pq.db.QueryRow(query, args...))
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) GetAll() []*Puppet {
|
||||
return pq.getAll(puppetSelect)
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet {
|
||||
return pq.getAll(puppetSelect + " WHERE custom_mxid<>''")
|
||||
}
|
||||
|
||||
func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet {
|
||||
rows, err := pq.db.Query(query, args...)
|
||||
if err != nil || rows == nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var puppets []*Puppet
|
||||
for rows.Next() {
|
||||
puppets = append(puppets, pq.New().Scan(rows))
|
||||
}
|
||||
|
||||
return puppets
|
||||
}
|
||||
|
||||
type Puppet struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
ID string
|
||||
Name string
|
||||
NameSet bool
|
||||
Avatar string
|
||||
AvatarURL id.ContentURI
|
||||
AvatarSet bool
|
||||
|
||||
ContactInfoSet bool
|
||||
|
||||
GlobalName string
|
||||
Username string
|
||||
Discriminator string
|
||||
IsBot bool
|
||||
IsWebhook bool
|
||||
IsApplication bool
|
||||
|
||||
CustomMXID id.UserID
|
||||
AccessToken string
|
||||
NextBatch string
|
||||
}
|
||||
|
||||
func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
|
||||
var avatarURL string
|
||||
var customMXID, accessToken, nextBatch sql.NullString
|
||||
|
||||
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet,
|
||||
&p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch)
|
||||
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
p.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
||||
p.CustomMXID = id.UserID(customMXID.String)
|
||||
p.AccessToken = accessToken.String
|
||||
p.NextBatch = nextBatch.String
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Puppet) Insert() {
|
||||
query := `
|
||||
INSERT INTO puppet (
|
||||
id, name, name_set, avatar, avatar_url, avatar_set, contact_info_set,
|
||||
global_name, username, discriminator, is_bot, is_webhook, is_application,
|
||||
custom_mxid, access_token, next_batch
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
`
|
||||
_, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
|
||||
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
|
||||
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch))
|
||||
|
||||
if err != nil {
|
||||
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puppet) Update() {
|
||||
query := `
|
||||
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6,
|
||||
global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12,
|
||||
custom_mxid=$13, access_token=$14, next_batch=$15
|
||||
WHERE id=$16
|
||||
`
|
||||
_, err := p.db.Exec(
|
||||
query,
|
||||
p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
|
||||
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
|
||||
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch),
|
||||
p.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
p.log.Warnfln("Failed to update %s: %v", p.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type ReactionQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction"
|
||||
)
|
||||
|
||||
func (rq *ReactionQuery) New() *Reaction {
|
||||
return &Reaction{
|
||||
db: rq.db,
|
||||
log: rq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction {
|
||||
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3"
|
||||
|
||||
return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID)
|
||||
}
|
||||
|
||||
func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction {
|
||||
rows, err := rq.db.Query(query, args...)
|
||||
if err != nil || rows == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var reactions []*Reaction
|
||||
for rows.Next() {
|
||||
reactions = append(reactions, rq.New().Scan(rows))
|
||||
}
|
||||
|
||||
return reactions
|
||||
}
|
||||
|
||||
func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction {
|
||||
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3 AND dc_sender=$4 AND dc_emoji_name=$5"
|
||||
|
||||
return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName)
|
||||
}
|
||||
|
||||
func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction {
|
||||
query := reactionSelect + " WHERE mxid=$1"
|
||||
|
||||
return rq.get(query, mxid)
|
||||
}
|
||||
|
||||
func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction {
|
||||
row := rq.db.QueryRow(query, args...)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return rq.New().Scan(row)
|
||||
}
|
||||
|
||||
type Reaction struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
Channel PortalKey
|
||||
MessageID string
|
||||
Sender string
|
||||
EmojiName string
|
||||
ThreadID string
|
||||
|
||||
MXID id.EventID
|
||||
|
||||
FirstAttachmentID string
|
||||
}
|
||||
|
||||
func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
|
||||
err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
r.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Reaction) DiscordProtoChannelID() string {
|
||||
if r.ThreadID != "" {
|
||||
return r.ThreadID
|
||||
} else {
|
||||
return r.Channel.ChannelID
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reaction) Insert() {
|
||||
query := `
|
||||
INSERT INTO reaction (dc_msg_id, dc_first_attachment_id, dc_sender, dc_emoji_name, dc_chan_id, dc_chan_receiver, dc_thread_id, mxid)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
_, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID)
|
||||
if err != nil {
|
||||
r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reaction) Delete() {
|
||||
query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3"
|
||||
_, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName)
|
||||
if err != nil {
|
||||
r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
112
database/role.go
112
database/role.go
|
|
@ -1,112 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
)
|
||||
|
||||
type RoleQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// language=postgresql
|
||||
const (
|
||||
roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role"
|
||||
roleUpsert = `
|
||||
INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (dc_guild_id, dcid) DO UPDATE
|
||||
SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed,
|
||||
hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions
|
||||
`
|
||||
roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2"
|
||||
)
|
||||
|
||||
func (rq *RoleQuery) New() *Role {
|
||||
return &Role{
|
||||
db: rq.db,
|
||||
log: rq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) GetByID(guildID, dcid string) *Role {
|
||||
query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2"
|
||||
return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid))
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) DeleteByID(guildID, dcid string) {
|
||||
_, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid)
|
||||
if err != nil {
|
||||
rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) GetAll(guildID string) []*Role {
|
||||
rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID)
|
||||
if err != nil {
|
||||
rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var roles []*Role
|
||||
for rows.Next() {
|
||||
role := rq.New().Scan(rows)
|
||||
if role != nil {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
GuildID string
|
||||
|
||||
discordgo.Role
|
||||
}
|
||||
|
||||
func (r *Role) Scan(row dbutil.Scannable) *Role {
|
||||
var icon sql.NullString
|
||||
err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
r.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
r.Icon = icon.String
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Role) Upsert(txn dbutil.Execable) {
|
||||
if txn == nil {
|
||||
txn = r.db
|
||||
}
|
||||
_, err := txn.Exec(roleUpsert, r.GuildID, r.ID, r.Name, strPtr(r.Icon), r.Mentionable, r.Managed, r.Hoist, r.Color, r.Position, r.Permissions)
|
||||
if err != nil {
|
||||
r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Role) Delete(txn dbutil.Execable) {
|
||||
if txn == nil {
|
||||
txn = r.db
|
||||
}
|
||||
_, err := txn.Exec(roleDelete, r.GuildID, r.Icon)
|
||||
if err != nil {
|
||||
r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type ThreadQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread"
|
||||
)
|
||||
|
||||
func (tq *ThreadQuery) New() *Thread {
|
||||
return &Thread{
|
||||
db: tq.db,
|
||||
log: tq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread {
|
||||
query := threadSelect + " WHERE dcid=$1"
|
||||
|
||||
row := tq.db.QueryRow(query, discordID)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tq.New().Scan(row)
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread {
|
||||
query := threadSelect + " WHERE root_msg_mxid=$1"
|
||||
|
||||
row := tq.db.QueryRow(query, mxid)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tq.New().Scan(row)
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread {
|
||||
query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1"
|
||||
|
||||
row := tq.db.QueryRow(query, mxid)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tq.New().Scan(row)
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
ID string
|
||||
ParentID string
|
||||
|
||||
RootDiscordID string
|
||||
RootMXID id.EventID
|
||||
|
||||
CreationNoticeMXID id.EventID
|
||||
}
|
||||
|
||||
func (t *Thread) Scan(row dbutil.Scannable) *Thread {
|
||||
err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Thread) Insert() {
|
||||
query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)"
|
||||
_, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID)
|
||||
if err != nil {
|
||||
t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Thread) Update() {
|
||||
query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1"
|
||||
_, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID)
|
||||
if err != nil {
|
||||
t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Thread) Delete() {
|
||||
query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2"
|
||||
_, err := t.db.Exec(query, t.ID, t.ParentID)
|
||||
if err != nil {
|
||||
t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
-- v0 -> v23 (compatible with v19+): Latest revision
|
||||
|
||||
CREATE TABLE guild (
|
||||
dcid TEXT PRIMARY KEY,
|
||||
mxid TEXT UNIQUE,
|
||||
plain_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL,
|
||||
|
||||
bridging_mode INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE portal (
|
||||
dcid TEXT,
|
||||
receiver TEXT,
|
||||
other_user_id TEXT,
|
||||
type INTEGER NOT NULL,
|
||||
|
||||
dc_guild_id TEXT,
|
||||
dc_parent_id TEXT,
|
||||
-- This is not accessed by the bridge, it's only used for the portal parent foreign key.
|
||||
-- Only guild channels have parents, but only DMs have a receiver field.
|
||||
dc_parent_receiver TEXT NOT NULL DEFAULT '',
|
||||
|
||||
mxid TEXT UNIQUE,
|
||||
plain_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL,
|
||||
friend_nick BOOLEAN NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
topic_set BOOLEAN NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL,
|
||||
encrypted BOOLEAN NOT NULL,
|
||||
in_space TEXT NOT NULL,
|
||||
|
||||
first_event_id TEXT NOT NULL,
|
||||
|
||||
relay_webhook_id TEXT,
|
||||
relay_webhook_secret TEXT,
|
||||
|
||||
PRIMARY KEY (dcid, receiver),
|
||||
CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE,
|
||||
CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE thread (
|
||||
dcid TEXT PRIMARY KEY,
|
||||
parent_chan_id TEXT NOT NULL,
|
||||
root_msg_dcid TEXT NOT NULL,
|
||||
root_msg_mxid TEXT NOT NULL,
|
||||
creation_notice_mxid TEXT NOT NULL,
|
||||
-- This is also not accessed by the bridge.
|
||||
receiver TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE puppet (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||
avatar TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
global_name TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
discriminator TEXT NOT NULL DEFAULT '',
|
||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||
is_webhook BOOLEAN NOT NULL DEFAULT false,
|
||||
is_application BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
custom_mxid TEXT,
|
||||
access_token TEXT,
|
||||
next_batch TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "user" (
|
||||
mxid TEXT PRIMARY KEY,
|
||||
dcid TEXT UNIQUE,
|
||||
|
||||
discord_token TEXT,
|
||||
management_room TEXT,
|
||||
space_room TEXT,
|
||||
dm_space_room TEXT,
|
||||
|
||||
read_state_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE user_portal (
|
||||
discord_id TEXT,
|
||||
user_mxid TEXT,
|
||||
type TEXT NOT NULL,
|
||||
in_space BOOLEAN NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (discord_id, user_mxid),
|
||||
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE message (
|
||||
dcid TEXT,
|
||||
dc_attachment_id TEXT,
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_sender TEXT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
dc_edit_timestamp BIGINT NOT NULL,
|
||||
dc_thread_id TEXT NOT NULL,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
sender_mxid TEXT NOT NULL DEFAULT '',
|
||||
|
||||
PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
|
||||
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE reaction (
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_msg_id TEXT,
|
||||
dc_sender TEXT,
|
||||
dc_emoji_name TEXT,
|
||||
dc_thread_id TEXT NOT NULL,
|
||||
|
||||
dc_first_attachment_id TEXT NOT NULL,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
|
||||
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE role (
|
||||
dc_guild_id TEXT,
|
||||
dcid TEXT,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
|
||||
mentionable BOOLEAN NOT NULL,
|
||||
managed BOOLEAN NOT NULL,
|
||||
hoist BOOLEAN NOT NULL,
|
||||
|
||||
color INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
permissions BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (dc_guild_id, dcid),
|
||||
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE discord_file (
|
||||
url TEXT,
|
||||
encrypted BOOLEAN,
|
||||
mxc TEXT NOT NULL,
|
||||
|
||||
id TEXT,
|
||||
emoji_name TEXT,
|
||||
|
||||
size BIGINT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
mime_type TEXT NOT NULL,
|
||||
decryption_info jsonb,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (url, encrypted)
|
||||
);
|
||||
|
||||
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
-- v2: Rename columns in message-related tables
|
||||
|
||||
ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id;
|
||||
ALTER TABLE portal RENAME COLUMN channel_id TO dcid;
|
||||
|
||||
ALTER TABLE "user" RENAME COLUMN id TO dcid;
|
||||
|
||||
ALTER TABLE puppet DROP COLUMN enable_presence;
|
||||
ALTER TABLE puppet DROP COLUMN enable_receipts;
|
||||
|
||||
DROP TABLE message;
|
||||
DROP TABLE reaction;
|
||||
DROP TABLE attachment;
|
||||
|
||||
CREATE TABLE message (
|
||||
dcid TEXT,
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_sender TEXT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver),
|
||||
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE reaction (
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_msg_id TEXT,
|
||||
dc_sender TEXT,
|
||||
dc_emoji_name TEXT,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
|
||||
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE attachment (
|
||||
dcid TEXT,
|
||||
dc_msg_id TEXT,
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver),
|
||||
CONSTRAINT attachment_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
UPDATE portal SET receiver='' WHERE type<>1;
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
-- v3: Store portal parent metadata for spaces
|
||||
DROP TABLE guild;
|
||||
|
||||
CREATE TABLE guild (
|
||||
dcid TEXT PRIMARY KEY,
|
||||
mxid TEXT UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
name_set BOOLEAN NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
avatar_set BOOLEAN NOT NULL,
|
||||
|
||||
auto_bridge_channels BOOLEAN NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_portal (
|
||||
discord_id TEXT,
|
||||
user_mxid TEXT,
|
||||
type TEXT NOT NULL,
|
||||
in_space BOOLEAN NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (discord_id, user_mxid),
|
||||
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE portal ADD COLUMN dc_guild_id TEXT;
|
||||
ALTER TABLE portal ADD COLUMN dc_parent_id TEXT;
|
||||
ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE;
|
||||
ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE;
|
||||
DELETE FROM portal WHERE type IS NULL;
|
||||
-- only: postgres
|
||||
ALTER TABLE portal ALTER COLUMN type SET NOT NULL;
|
||||
|
||||
ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
|
||||
-- only: postgres for next 5 lines
|
||||
ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT;
|
||||
ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT;
|
||||
ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT;
|
||||
ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT;
|
||||
ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT;
|
||||
|
||||
ALTER TABLE puppet RENAME COLUMN display_name TO name;
|
||||
ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
|
||||
-- only: postgres for next 2 lines
|
||||
ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT;
|
||||
ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT;
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN space_room TEXT;
|
||||
ALTER TABLE "user" ADD COLUMN dm_space_room TEXT;
|
||||
ALTER TABLE "user" RENAME COLUMN token TO discord_token;
|
||||
|
||||
UPDATE message SET timestamp=timestamp*1000;
|
||||
|
||||
CREATE TABLE thread (
|
||||
dcid TEXT PRIMARY KEY,
|
||||
parent_chan_id TEXT NOT NULL,
|
||||
root_msg_dcid TEXT NOT NULL,
|
||||
root_msg_mxid TEXT NOT NULL,
|
||||
-- This is also not accessed by the bridge.
|
||||
receiver TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE message ADD COLUMN dc_thread_id TEXT;
|
||||
ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT;
|
||||
ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
-- v4: Fix storing attachments
|
||||
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
|
||||
ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey;
|
||||
ALTER TABLE message DROP CONSTRAINT message_pkey;
|
||||
ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT;
|
||||
ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT;
|
||||
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
|
||||
INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
|
||||
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
|
||||
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
|
||||
DROP TABLE attachment;
|
||||
|
||||
ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT;
|
||||
ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0;
|
||||
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
|
||||
FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
|
||||
REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
-- v4: Fix storing attachments
|
||||
CREATE TABLE new_message (
|
||||
dcid TEXT,
|
||||
dc_attachment_id TEXT,
|
||||
dc_edit_index INTEGER,
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_sender TEXT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
dc_thread_id TEXT,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
|
||||
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
|
||||
SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message;
|
||||
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
|
||||
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
|
||||
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
|
||||
DROP TABLE attachment;
|
||||
DROP TABLE message;
|
||||
ALTER TABLE new_message RENAME TO message;
|
||||
|
||||
CREATE TABLE new_reaction (
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_msg_id TEXT,
|
||||
dc_sender TEXT,
|
||||
dc_emoji_name TEXT,
|
||||
dc_thread_id TEXT,
|
||||
|
||||
dc_first_attachment_id TEXT NOT NULL,
|
||||
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
|
||||
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO new_reaction (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
|
||||
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction;
|
||||
DROP TABLE reaction;
|
||||
ALTER TABLE new_reaction RENAME TO reaction;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- v5: Fix foreign key broken in v4
|
||||
-- only: postgres
|
||||
|
||||
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
|
||||
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
|
||||
FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
|
||||
REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver)
|
||||
ON DELETE CASCADE;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- v6: Store user read state version
|
||||
ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
-- v7: Store role info
|
||||
CREATE TABLE role (
|
||||
dc_guild_id TEXT,
|
||||
dcid TEXT,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
|
||||
mentionable BOOLEAN NOT NULL,
|
||||
managed BOOLEAN NOT NULL,
|
||||
hoist BOOLEAN NOT NULL,
|
||||
|
||||
color INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
permissions BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (dc_guild_id, dcid),
|
||||
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
|
||||
);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-- v8: Store plain name of channels and guilds
|
||||
ALTER TABLE guild ADD COLUMN plain_name TEXT;
|
||||
ALTER TABLE portal ADD COLUMN plain_name TEXT;
|
||||
UPDATE guild SET plain_name=name;
|
||||
UPDATE portal SET plain_name=name;
|
||||
UPDATE portal SET plain_name='' WHERE type=1;
|
||||
-- only: postgres for next 2 lines
|
||||
ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL;
|
||||
ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-- v9: Store more info for proper thread support
|
||||
ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT '';
|
||||
UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL;
|
||||
UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL;
|
||||
|
||||
-- only: postgres for next 3 lines
|
||||
ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT;
|
||||
ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL;
|
||||
ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- v10: Remove double puppet ghosts added while there was a bug in the bridge
|
||||
DELETE FROM puppet WHERE id='';
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
-- v11: Cache files copied from Discord to Matrix
|
||||
CREATE TABLE discord_file (
|
||||
url TEXT,
|
||||
encrypted BOOLEAN,
|
||||
|
||||
id TEXT,
|
||||
mxc TEXT NOT NULL,
|
||||
|
||||
size BIGINT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
|
||||
decryption_info jsonb,
|
||||
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (url, encrypted)
|
||||
);
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
-- v12: Cache mime type for reuploaded files
|
||||
ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT '';
|
||||
-- only: postgres
|
||||
ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
-- v13: Merge tables used for cached custom emojis and attachments
|
||||
ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc);
|
||||
ALTER TABLE discord_file ADD COLUMN emoji_name TEXT;
|
||||
DROP TABLE emoji;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
-- v13: Merge tables used for cached custom emojis and attachments
|
||||
CREATE TABLE new_discord_file (
|
||||
url TEXT,
|
||||
encrypted BOOLEAN,
|
||||
mxc TEXT NOT NULL UNIQUE,
|
||||
|
||||
id TEXT,
|
||||
emoji_name TEXT,
|
||||
|
||||
size BIGINT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
mime_type TEXT NOT NULL,
|
||||
decryption_info jsonb,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (url, encrypted)
|
||||
);
|
||||
|
||||
INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
|
||||
SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
|
||||
|
||||
DROP TABLE discord_file;
|
||||
ALTER TABLE new_discord_file RENAME TO discord_file;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
-- v14: Add more modes of bridging guilds
|
||||
ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE guild SET bridging_mode=2 WHERE mxid<>'';
|
||||
UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true;
|
||||
ALTER TABLE guild DROP COLUMN auto_bridge_channels;
|
||||
-- only: postgres
|
||||
ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- v15: Store relay webhook URL for portals
|
||||
ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT;
|
||||
ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- v16: Store whether custom contact info has been set for the puppet
|
||||
|
||||
ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- v17: Store whether DM portal name is a friend nickname
|
||||
ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
-- v18 (compatible with v15+): Store additional metadata for ghosts
|
||||
ALTER TABLE puppet ADD COLUMN username TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE puppet ADD COLUMN discriminator TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE puppet ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
-- v19: Replace dc_edit_index with dc_edit_timestamp
|
||||
-- transaction: off
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
|
||||
ALTER TABLE message DROP CONSTRAINT message_pkey;
|
||||
ALTER TABLE message DROP COLUMN dc_edit_index;
|
||||
ALTER TABLE reaction DROP COLUMN _dc_first_edit_index;
|
||||
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver);
|
||||
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE message ADD COLUMN dc_edit_timestamp BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE message ALTER COLUMN dc_edit_timestamp DROP DEFAULT;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
-- v19: Replace dc_edit_index with dc_edit_timestamp
|
||||
-- transaction: off
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE message_new (
|
||||
dcid TEXT,
|
||||
dc_attachment_id TEXT,
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_sender TEXT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
dc_edit_timestamp BIGINT NOT NULL,
|
||||
dc_thread_id TEXT NOT NULL,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
|
||||
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid)
|
||||
SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message;
|
||||
DROP TABLE message;
|
||||
ALTER TABLE message_new RENAME TO message;
|
||||
|
||||
CREATE TABLE reaction_new (
|
||||
dc_chan_id TEXT,
|
||||
dc_chan_receiver TEXT,
|
||||
dc_msg_id TEXT,
|
||||
dc_sender TEXT,
|
||||
dc_emoji_name TEXT,
|
||||
dc_thread_id TEXT NOT NULL,
|
||||
|
||||
dc_first_attachment_id TEXT NOT NULL,
|
||||
|
||||
mxid TEXT NOT NULL UNIQUE,
|
||||
|
||||
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
|
||||
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO reaction_new (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
|
||||
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, COALESCE(dc_thread_id, ''), dc_first_attachment_id, mxid FROM reaction;
|
||||
DROP TABLE reaction;
|
||||
ALTER TABLE reaction_new RENAME TO reaction;
|
||||
|
||||
PRAGMA foreign_key_check;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- v20 (compatible with v19+): Store message sender Matrix user ID
|
||||
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- v21 (compatible with v19+): Store global displayname and is webhook status for puppets
|
||||
ALTER TABLE puppet ADD COLUMN global_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE puppet ADD COLUMN is_webhook BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
-- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache
|
||||
CREATE TABLE new_discord_file (
|
||||
url TEXT,
|
||||
encrypted BOOLEAN,
|
||||
mxc TEXT NOT NULL,
|
||||
|
||||
id TEXT,
|
||||
emoji_name TEXT,
|
||||
|
||||
size BIGINT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
mime_type TEXT NOT NULL,
|
||||
decryption_info jsonb,
|
||||
timestamp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (url, encrypted)
|
||||
);
|
||||
|
||||
INSERT INTO new_discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
|
||||
SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
|
||||
|
||||
DROP TABLE discord_file;
|
||||
ALTER TABLE new_discord_file RENAME TO discord_file;
|
||||
|
||||
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- v23 (compatible with v19+): Store is application status for puppets
|
||||
ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false;
|
||||
101
database/user.go
101
database/user.go
|
|
@ -1,101 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type UserQuery struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (uq *UserQuery) New() *User {
|
||||
return &User{
|
||||
db: uq.db,
|
||||
log: uq.log,
|
||||
}
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
|
||||
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1`
|
||||
return uq.New().Scan(uq.db.QueryRow(query, userID))
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetByID(id string) *User {
|
||||
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1`
|
||||
return uq.New().Scan(uq.db.QueryRow(query, id))
|
||||
}
|
||||
|
||||
func (uq *UserQuery) GetAllWithToken() []*User {
|
||||
query := `
|
||||
SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version
|
||||
FROM "user" WHERE discord_token IS NOT NULL
|
||||
`
|
||||
rows, err := uq.db.Query(query)
|
||||
if err != nil || rows == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var users []*User
|
||||
for rows.Next() {
|
||||
user := uq.New().Scan(rows)
|
||||
if user != nil {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
type User struct {
|
||||
db *Database
|
||||
log log.Logger
|
||||
|
||||
MXID id.UserID
|
||||
DiscordID string
|
||||
DiscordToken string
|
||||
ManagementRoom id.RoomID
|
||||
SpaceRoom id.RoomID
|
||||
DMSpaceRoom id.RoomID
|
||||
|
||||
ReadStateVersion int
|
||||
}
|
||||
|
||||
func (u *User) Scan(row dbutil.Scannable) *User {
|
||||
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
|
||||
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
u.log.Errorln("Database scan failed:", err)
|
||||
panic(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
u.DiscordID = discordID.String
|
||||
u.DiscordToken = discordToken.String
|
||||
u.ManagementRoom = id.RoomID(managementRoom.String)
|
||||
u.SpaceRoom = id.RoomID(spaceRoom.String)
|
||||
u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String)
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *User) Insert() {
|
||||
query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)`
|
||||
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion)
|
||||
if err != nil {
|
||||
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) Update() {
|
||||
query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7`
|
||||
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID)
|
||||
if err != nil {
|
||||
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
UserPortalTypeDM = "dm"
|
||||
UserPortalTypeGuild = "guild"
|
||||
UserPortalTypeThread = "thread"
|
||||
)
|
||||
|
||||
type UserPortal struct {
|
||||
DiscordID string
|
||||
Type string
|
||||
Timestamp time.Time
|
||||
InSpace bool
|
||||
}
|
||||
|
||||
func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
|
||||
var ts int64
|
||||
err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace)
|
||||
if err != nil {
|
||||
l.Errorln("Error scanning user portal:", err)
|
||||
panic(err)
|
||||
}
|
||||
up.Timestamp = time.UnixMilli(ts).UTC()
|
||||
return &up
|
||||
}
|
||||
|
||||
func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
|
||||
var ups []UserPortal
|
||||
for rows.Next() {
|
||||
up := UserPortal{}.Scan(u.log, rows)
|
||||
if up != nil {
|
||||
ups = append(ups, *up)
|
||||
}
|
||||
}
|
||||
return ups
|
||||
}
|
||||
|
||||
func (db *Database) GetUsersInPortal(channelID string) []id.UserID {
|
||||
rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID)
|
||||
if err != nil {
|
||||
db.Portal.log.Errorln("Failed to get users in portal:", err)
|
||||
}
|
||||
var users []id.UserID
|
||||
for rows.Next() {
|
||||
var mxid id.UserID
|
||||
err = rows.Scan(&mxid)
|
||||
if err != nil {
|
||||
db.Portal.log.Errorln("Failed to scan user in portal:", err)
|
||||
} else {
|
||||
users = append(users, mxid)
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (u *User) GetPortals() []UserPortal {
|
||||
rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
|
||||
if err != nil {
|
||||
u.log.Errorln("Failed to get portals:", err)
|
||||
panic(err)
|
||||
}
|
||||
return u.scanUserPortals(rows)
|
||||
}
|
||||
|
||||
func (u *User) IsInSpace(discordID string) (isIn bool) {
|
||||
query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
|
||||
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
|
||||
panic(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) IsInPortal(discordID string) (isIn bool) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)`
|
||||
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
|
||||
panic(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) MarkInPortal(portal UserPortal) {
|
||||
query := `
|
||||
INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (discord_id, user_mxid) DO UPDATE
|
||||
SET timestamp=excluded.timestamp, in_space=excluded.in_space
|
||||
`
|
||||
_, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace)
|
||||
if err != nil {
|
||||
u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) MarkNotInPortal(discordID string) {
|
||||
query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
|
||||
_, err := u.db.Exec(query, u.MXID, discordID)
|
||||
if err != nil {
|
||||
u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) {
|
||||
query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2`
|
||||
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers)
|
||||
if err != nil {
|
||||
u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err)
|
||||
panic(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal {
|
||||
query := `
|
||||
DELETE FROM user_portal
|
||||
WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild')
|
||||
RETURNING discord_id, type, timestamp, in_space
|
||||
`
|
||||
rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli())
|
||||
if err != nil {
|
||||
u.log.Errorln("Failed to prune user guild list:", err)
|
||||
panic(err)
|
||||
}
|
||||
return u.scanUserPortals(rows)
|
||||
}
|
||||
662
directmedia.go
662
directmedia.go
|
|
@ -1,662 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2024 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/federation"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/config"
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
)
|
||||
|
||||
type DirectMediaAPI struct {
|
||||
bridge *DiscordBridge
|
||||
ks *federation.KeyServer
|
||||
cfg config.DirectMedia
|
||||
log zerolog.Logger
|
||||
proxy http.Client
|
||||
|
||||
signatureKey [32]byte
|
||||
|
||||
attachmentCache map[AttachmentCacheKey]AttachmentCacheValue
|
||||
attachmentCacheLock sync.Mutex
|
||||
}
|
||||
|
||||
type AttachmentCacheKey struct {
|
||||
ChannelID uint64
|
||||
AttachmentID uint64
|
||||
}
|
||||
|
||||
type AttachmentCacheValue struct {
|
||||
URL string
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI {
|
||||
if !br.Config.Bridge.DirectMedia.Enabled {
|
||||
return nil
|
||||
}
|
||||
dma := &DirectMediaAPI{
|
||||
bridge: br,
|
||||
cfg: br.Config.Bridge.DirectMedia,
|
||||
log: br.ZLog.With().Str("component", "direct media").Logger(),
|
||||
proxy: http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ForceAttemptHTTP2: false,
|
||||
},
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
attachmentCache: make(map[AttachmentCacheKey]AttachmentCacheValue),
|
||||
}
|
||||
r := br.AS.Router
|
||||
|
||||
parsed, err := federation.ParseSynapseKey(dma.cfg.ServerKey)
|
||||
if err != nil {
|
||||
dma.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to parse server key")
|
||||
os.Exit(11)
|
||||
return nil
|
||||
}
|
||||
dma.signatureKey = sha256.Sum256(parsed.Priv.Seed())
|
||||
dma.ks = &federation.KeyServer{
|
||||
KeyProvider: &federation.StaticServerKey{
|
||||
ServerName: dma.cfg.ServerName,
|
||||
Key: parsed,
|
||||
},
|
||||
WellKnownTarget: dma.cfg.WellKnownResponse,
|
||||
Version: federation.ServerVersion{
|
||||
Name: br.Name,
|
||||
Version: br.Version,
|
||||
},
|
||||
}
|
||||
if dma.ks.WellKnownTarget == "" {
|
||||
dma.ks.WellKnownTarget = fmt.Sprintf("%s:443", dma.cfg.ServerName)
|
||||
}
|
||||
federationRouter := r.PathPrefix("/_matrix/federation").Subrouter()
|
||||
mediaRouter := r.PathPrefix("/_matrix/media").Subrouter()
|
||||
clientMediaRouter := r.PathPrefix("/_matrix/client/v1/media").Subrouter()
|
||||
var reqIDCounter atomic.Uint64
|
||||
middleware := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization")
|
||||
log := dma.log.With().
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Str("request_path", r.URL.Path).
|
||||
Uint64("req_id", reqIDCounter.Add(1)).
|
||||
Logger()
|
||||
next.ServeHTTP(w, r.WithContext(log.WithContext(r.Context())))
|
||||
})
|
||||
}
|
||||
mediaRouter.Use(middleware)
|
||||
federationRouter.Use(middleware)
|
||||
clientMediaRouter.Use(middleware)
|
||||
addRoutes := func(version string) {
|
||||
mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
mediaRouter.HandleFunc("/"+version+"/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
mediaRouter.HandleFunc("/"+version+"/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
|
||||
mediaRouter.HandleFunc("/"+version+"/upload", dma.UploadNotSupported).Methods(http.MethodPost)
|
||||
mediaRouter.HandleFunc("/"+version+"/create", dma.UploadNotSupported).Methods(http.MethodPost)
|
||||
mediaRouter.HandleFunc("/"+version+"/config", dma.UploadNotSupported).Methods(http.MethodGet)
|
||||
mediaRouter.HandleFunc("/"+version+"/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
|
||||
}
|
||||
clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
clientMediaRouter.HandleFunc("/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
clientMediaRouter.HandleFunc("/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
|
||||
clientMediaRouter.HandleFunc("/upload", dma.UploadNotSupported).Methods(http.MethodPost)
|
||||
clientMediaRouter.HandleFunc("/create", dma.UploadNotSupported).Methods(http.MethodPost)
|
||||
clientMediaRouter.HandleFunc("/config", dma.UploadNotSupported).Methods(http.MethodGet)
|
||||
clientMediaRouter.HandleFunc("/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
|
||||
addRoutes("v3")
|
||||
addRoutes("r0")
|
||||
addRoutes("v1")
|
||||
federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||
federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet)
|
||||
mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
|
||||
mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
|
||||
federationRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
|
||||
federationRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
|
||||
dma.ks.Register(r)
|
||||
|
||||
return dma
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) makeMXC(data MediaIDData) id.ContentURI {
|
||||
return id.ContentURI{
|
||||
Homeserver: dma.cfg.ServerName,
|
||||
FileID: data.Wrap().SignedString(dma.signatureKey),
|
||||
}
|
||||
}
|
||||
|
||||
func parseExpiryTS(addr string) time.Time {
|
||||
parsedURL, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
tsBytes, err := hex.DecodeString(parsedURL.Query().Get("ex"))
|
||||
if err != nil || len(tsBytes) != 4 {
|
||||
return time.Time{}
|
||||
}
|
||||
parsedTS := int64(binary.BigEndian.Uint32(tsBytes))
|
||||
if parsedTS > time.Now().Unix() && parsedTS < time.Now().Add(365*24*time.Hour).Unix() {
|
||||
return time.Unix(parsedTS, 0)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) addAttachmentToCache(channelID uint64, att *discordgo.MessageAttachment) time.Time {
|
||||
attachmentID, err := strconv.ParseUint(att.ID, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
expiry := parseExpiryTS(att.URL)
|
||||
if expiry.IsZero() {
|
||||
expiry = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
dma.attachmentCache[AttachmentCacheKey{
|
||||
ChannelID: channelID,
|
||||
AttachmentID: attachmentID,
|
||||
}] = AttachmentCacheValue{
|
||||
URL: att.URL,
|
||||
Expiry: expiry,
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) AttachmentMXC(channelID, messageID string, att *discordgo.MessageAttachment) (mxc id.ContentURI) {
|
||||
if dma == nil {
|
||||
return
|
||||
}
|
||||
channelIDInt, err := strconv.ParseUint(channelID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("channel_id", channelID).Msg("Got non-integer channel ID")
|
||||
return
|
||||
}
|
||||
messageIDInt, err := strconv.ParseUint(messageID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("message_id", messageID).Msg("Got non-integer message ID")
|
||||
return
|
||||
}
|
||||
attachmentIDInt, err := strconv.ParseUint(att.ID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("attachment_id", att.ID).Msg("Got non-integer attachment ID")
|
||||
return
|
||||
}
|
||||
dma.attachmentCacheLock.Lock()
|
||||
dma.addAttachmentToCache(channelIDInt, att)
|
||||
dma.attachmentCacheLock.Unlock()
|
||||
return dma.makeMXC(&AttachmentMediaData{
|
||||
ChannelID: channelIDInt,
|
||||
MessageID: messageIDInt,
|
||||
AttachmentID: attachmentIDInt,
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) EmojiMXC(emojiID, name string, animated bool) (mxc id.ContentURI) {
|
||||
if dma == nil {
|
||||
return
|
||||
}
|
||||
emojiIDInt, err := strconv.ParseUint(emojiID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("emoji_id", emojiID).Msg("Got non-integer emoji ID")
|
||||
return
|
||||
}
|
||||
return dma.makeMXC(&EmojiMediaData{
|
||||
EmojiMediaDataInner: EmojiMediaDataInner{
|
||||
EmojiID: emojiIDInt,
|
||||
Animated: animated,
|
||||
},
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) StickerMXC(stickerID string, format discordgo.StickerFormat) (mxc id.ContentURI) {
|
||||
if dma == nil {
|
||||
return
|
||||
}
|
||||
stickerIDInt, err := strconv.ParseUint(stickerID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("sticker_id", stickerID).Msg("Got non-integer sticker ID")
|
||||
return
|
||||
} else if format > 255 || format < 0 {
|
||||
dma.log.Warn().Int("format", int(format)).Msg("Got invalid sticker format")
|
||||
return
|
||||
}
|
||||
return dma.makeMXC(&StickerMediaData{
|
||||
StickerID: stickerIDInt,
|
||||
Format: byte(format),
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) AvatarMXC(guildID, userID, avatarID string) (mxc id.ContentURI) {
|
||||
if dma == nil {
|
||||
return
|
||||
}
|
||||
animated := strings.HasPrefix(avatarID, "a_")
|
||||
avatarIDBytes, err := hex.DecodeString(strings.TrimPrefix(avatarID, "a_"))
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("avatar_id", avatarID).Msg("Got non-hex avatar ID")
|
||||
return
|
||||
} else if len(avatarIDBytes) != 16 {
|
||||
dma.log.Warn().Str("avatar_id", avatarID).Msg("Got invalid avatar ID length")
|
||||
return
|
||||
}
|
||||
avatarIDArray := [16]byte(avatarIDBytes)
|
||||
userIDInt, err := strconv.ParseUint(userID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("user_id", userID).Msg("Got non-integer user ID")
|
||||
return
|
||||
}
|
||||
if guildID != "" {
|
||||
guildIDInt, err := strconv.ParseUint(guildID, 10, 64)
|
||||
if err != nil {
|
||||
dma.log.Warn().Str("guild_id", guildID).Msg("Got non-integer guild ID")
|
||||
return
|
||||
}
|
||||
return dma.makeMXC(&GuildMemberAvatarMediaData{
|
||||
GuildID: guildIDInt,
|
||||
UserID: userIDInt,
|
||||
AvatarID: avatarIDArray,
|
||||
Animated: animated,
|
||||
})
|
||||
} else {
|
||||
return dma.makeMXC(&UserAvatarMediaData{
|
||||
UserID: userIDInt,
|
||||
AvatarID: avatarIDArray,
|
||||
Animated: animated,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type RespError struct {
|
||||
Code string
|
||||
Message string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (re *RespError) Error() string {
|
||||
return re.Message
|
||||
}
|
||||
|
||||
var ErrNoUsersWithAccessFound = errors.New("no users found to fetch message")
|
||||
var ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
|
||||
func (dma *DirectMediaAPI) fetchNewAttachmentURL(ctx context.Context, meta *AttachmentMediaData) (string, time.Time, error) {
|
||||
var client *discordgo.Session
|
||||
channelIDStr := strconv.FormatUint(meta.ChannelID, 10)
|
||||
portal := dma.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: channelIDStr})
|
||||
var users []id.UserID
|
||||
if portal != nil && portal.GuildID != "" {
|
||||
users = dma.bridge.DB.GetUsersInPortal(portal.GuildID)
|
||||
} else {
|
||||
users = dma.bridge.DB.GetUsersInPortal(channelIDStr)
|
||||
}
|
||||
for _, userID := range users {
|
||||
user := dma.bridge.GetCachedUserByMXID(userID)
|
||||
if user == nil || user.Session == nil {
|
||||
continue
|
||||
}
|
||||
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channelIDStr)
|
||||
if err == nil && perms&discordgo.PermissionViewChannel == 0 {
|
||||
continue
|
||||
}
|
||||
if client == nil || err == nil {
|
||||
client = user.Session
|
||||
if !client.IsUser {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if client == nil {
|
||||
return "", time.Time{}, ErrNoUsersWithAccessFound
|
||||
}
|
||||
var msgs []*discordgo.Message
|
||||
var err error
|
||||
messageIDStr := strconv.FormatUint(meta.MessageID, 10)
|
||||
if client.IsUser {
|
||||
var refs []discordgo.RequestOption
|
||||
if portal != nil {
|
||||
refs = append(refs, discordgo.WithChannelReferer(portal.GuildID, channelIDStr))
|
||||
}
|
||||
msgs, err = client.ChannelMessages(channelIDStr, 5, "", "", messageIDStr, refs...)
|
||||
} else {
|
||||
var msg *discordgo.Message
|
||||
msg, err = client.ChannelMessage(channelIDStr, messageIDStr)
|
||||
msgs = []*discordgo.Message{msg}
|
||||
}
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to fetch message: %w", err)
|
||||
}
|
||||
attachmentIDStr := strconv.FormatUint(meta.AttachmentID, 10)
|
||||
var url string
|
||||
var expiry time.Time
|
||||
for _, item := range msgs {
|
||||
for _, att := range item.Attachments {
|
||||
thisExpiry := dma.addAttachmentToCache(meta.ChannelID, att)
|
||||
if att.ID == attachmentIDStr {
|
||||
url = att.URL
|
||||
expiry = thisExpiry
|
||||
}
|
||||
}
|
||||
}
|
||||
if url == "" {
|
||||
return "", time.Time{}, ErrAttachmentNotFound
|
||||
}
|
||||
return url, expiry, nil
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) GetEmojiInfo(contentURI id.ContentURI) *EmojiMediaData {
|
||||
if dma == nil || contentURI.IsEmpty() || contentURI.Homeserver != dma.cfg.ServerName {
|
||||
return nil
|
||||
}
|
||||
mediaID, err := ParseMediaID(contentURI.FileID, dma.signatureKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
emojiData, ok := mediaID.Data.(*EmojiMediaData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return emojiData
|
||||
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) getMediaURL(ctx context.Context, encodedMediaID string) (url string, expiry time.Time, err error) {
|
||||
var mediaID *MediaID
|
||||
mediaID, err = ParseMediaID(encodedMediaID, dma.signatureKey)
|
||||
if err != nil {
|
||||
err = &RespError{
|
||||
Code: mautrix.MNotFound.ErrCode,
|
||||
Message: err.Error(),
|
||||
Status: http.StatusNotFound,
|
||||
}
|
||||
return
|
||||
}
|
||||
switch mediaData := mediaID.Data.(type) {
|
||||
case *AttachmentMediaData:
|
||||
dma.attachmentCacheLock.Lock()
|
||||
defer dma.attachmentCacheLock.Unlock()
|
||||
cached, ok := dma.attachmentCache[mediaData.CacheKey()]
|
||||
if ok && time.Until(cached.Expiry) > 5*time.Minute {
|
||||
return cached.URL, cached.Expiry, nil
|
||||
}
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Uint64("channel_id", mediaData.ChannelID).
|
||||
Uint64("message_id", mediaData.MessageID).
|
||||
Uint64("attachment_id", mediaData.AttachmentID).
|
||||
Msg("Refreshing attachment URL")
|
||||
url, expiry, err = dma.fetchNewAttachmentURL(ctx, mediaData)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to refresh attachment URL")
|
||||
msg := "Failed to refresh attachment URL"
|
||||
if errors.Is(err, ErrNoUsersWithAccessFound) {
|
||||
msg = "No users found with access to the channel"
|
||||
} else if errors.Is(err, ErrAttachmentNotFound) {
|
||||
msg = "Attachment not found in message. Perhaps it was deleted?"
|
||||
}
|
||||
err = &RespError{
|
||||
Code: mautrix.MNotFound.ErrCode,
|
||||
Message: msg,
|
||||
Status: http.StatusNotFound,
|
||||
}
|
||||
} else {
|
||||
zerolog.Ctx(ctx).Debug().Time("expiry", expiry).Msg("Successfully refreshed attachment URL")
|
||||
}
|
||||
case *EmojiMediaData:
|
||||
if mediaData.Animated {
|
||||
url = discordgo.EndpointEmojiAnimated(strconv.FormatUint(mediaData.EmojiID, 10))
|
||||
} else {
|
||||
url = discordgo.EndpointEmoji(strconv.FormatUint(mediaData.EmojiID, 10))
|
||||
}
|
||||
case *StickerMediaData:
|
||||
url = discordgo.EndpointStickerImage(
|
||||
strconv.FormatUint(mediaData.StickerID, 10),
|
||||
discordgo.StickerFormat(mediaData.Format),
|
||||
)
|
||||
case *UserAvatarMediaData:
|
||||
if mediaData.Animated {
|
||||
url = discordgo.EndpointUserAvatarAnimated(
|
||||
strconv.FormatUint(mediaData.UserID, 10),
|
||||
fmt.Sprintf("a_%x", mediaData.AvatarID),
|
||||
)
|
||||
} else {
|
||||
url = discordgo.EndpointUserAvatar(
|
||||
strconv.FormatUint(mediaData.UserID, 10),
|
||||
fmt.Sprintf("%x", mediaData.AvatarID),
|
||||
)
|
||||
}
|
||||
case *GuildMemberAvatarMediaData:
|
||||
if mediaData.Animated {
|
||||
url = discordgo.EndpointGuildMemberAvatarAnimated(
|
||||
strconv.FormatUint(mediaData.GuildID, 10),
|
||||
strconv.FormatUint(mediaData.UserID, 10),
|
||||
fmt.Sprintf("a_%x", mediaData.AvatarID),
|
||||
)
|
||||
} else {
|
||||
url = discordgo.EndpointGuildMemberAvatar(
|
||||
strconv.FormatUint(mediaData.GuildID, 10),
|
||||
strconv.FormatUint(mediaData.UserID, 10),
|
||||
fmt.Sprintf("%x", mediaData.AvatarID),
|
||||
)
|
||||
}
|
||||
default:
|
||||
zerolog.Ctx(ctx).Error().Type("media_data_type", mediaData).Msg("Unrecognized media data struct")
|
||||
err = &RespError{
|
||||
Code: "M_UNKNOWN",
|
||||
Message: "Unrecognized media data struct",
|
||||
Status: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWriter, url, fileName string) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
log.Err(err).Str("url", url).Msg("Failed to create proxy request")
|
||||
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
|
||||
ErrCode: "M_UNKNOWN",
|
||||
Err: "Failed to create proxy request",
|
||||
})
|
||||
return
|
||||
}
|
||||
for key, val := range discordgo.DroidDownloadHeaders {
|
||||
req.Header.Set(key, val)
|
||||
}
|
||||
resp, err := dma.proxy.Do(req)
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
log.Err(err).Str("url", url).Msg("Failed to proxy download")
|
||||
jsonResponse(w, http.StatusServiceUnavailable, &mautrix.RespError{
|
||||
ErrCode: "M_UNKNOWN",
|
||||
Err: "Failed to proxy download",
|
||||
})
|
||||
return
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("Unexpected status code proxying download")
|
||||
jsonResponse(w, resp.StatusCode, &mautrix.RespError{
|
||||
ErrCode: "M_UNKNOWN",
|
||||
Err: "Unexpected status code proxying download",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header()["Content-Type"] = resp.Header["Content-Type"]
|
||||
w.Header()["Content-Length"] = resp.Header["Content-Length"]
|
||||
w.Header()["Last-Modified"] = resp.Header["Last-Modified"]
|
||||
w.Header()["Cache-Control"] = resp.Header["Cache-Control"]
|
||||
contentDisposition := "attachment"
|
||||
switch resp.Header.Get("Content-Type") {
|
||||
case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif",
|
||||
"image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime",
|
||||
"audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav",
|
||||
"audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf":
|
||||
contentDisposition = "inline"
|
||||
}
|
||||
if fileName != "" {
|
||||
contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{
|
||||
"filename": fileName,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to write proxy response")
|
||||
}
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
log := zerolog.Ctx(ctx)
|
||||
isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/")
|
||||
vars := mux.Vars(r)
|
||||
if !isNewFederation && vars["serverName"] != dma.cfg.ServerName {
|
||||
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
|
||||
ErrCode: mautrix.MNotFound.ErrCode,
|
||||
Err: fmt.Sprintf("This is a Discord media proxy for %q, other media downloads are not available here", dma.cfg.ServerName),
|
||||
})
|
||||
return
|
||||
}
|
||||
// TODO check destination header in X-Matrix auth when isNewFederation
|
||||
|
||||
url, expiresAt, err := dma.getMediaURL(ctx, vars["mediaID"])
|
||||
if err != nil {
|
||||
var respError *RespError
|
||||
if errors.As(err, &respError) {
|
||||
jsonResponse(w, respError.Status, &mautrix.RespError{
|
||||
ErrCode: respError.Code,
|
||||
Err: respError.Message,
|
||||
})
|
||||
} else {
|
||||
log.Err(err).Str("media_id", vars["mediaID"]).Msg("Failed to get media URL")
|
||||
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
|
||||
ErrCode: mautrix.MNotFound.ErrCode,
|
||||
Err: "Media not found",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if isNewFederation {
|
||||
mp := multipart.NewWriter(w)
|
||||
w.Header().Set("Content-Type", strings.Replace(mp.FormDataContentType(), "form-data", "mixed", 1))
|
||||
var metaPart io.Writer
|
||||
metaPart, err = mp.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {"application/json"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to create multipart metadata field")
|
||||
return
|
||||
}
|
||||
_, err = metaPart.Write([]byte(`{}`))
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to write multipart metadata field")
|
||||
return
|
||||
}
|
||||
_, err = mp.CreatePart(textproto.MIMEHeader{
|
||||
"Location": {url},
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to create multipart redirect field")
|
||||
return
|
||||
}
|
||||
err = mp.Close()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to close multipart writer")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Proxy if the config allows proxying and the request doesn't allow redirects.
|
||||
// In any other case, redirect to the Discord CDN.
|
||||
if dma.cfg.AllowProxy && r.URL.Query().Get("allow_redirect") != "true" {
|
||||
dma.proxyDownload(ctx, w, url, vars["fileName"])
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", url)
|
||||
expirySeconds := (time.Until(expiresAt) - 5*time.Minute).Seconds()
|
||||
if expiresAt.IsZero() {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else if expirySeconds > 0 {
|
||||
cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds))
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) UploadNotSupported(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
|
||||
ErrCode: mautrix.MUnrecognized.ErrCode,
|
||||
Err: "This bridge only supports proxying Discord media downloads and does not support media uploads.",
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
|
||||
ErrCode: mautrix.MUnrecognized.ErrCode,
|
||||
Err: "This bridge only supports proxying Discord media downloads and does not support URL previews.",
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) UnknownEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
|
||||
ErrCode: mautrix.MUnrecognized.ErrCode,
|
||||
Err: "Unrecognized endpoint",
|
||||
})
|
||||
}
|
||||
|
||||
func (dma *DirectMediaAPI) UnsupportedMethod(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{
|
||||
ErrCode: mautrix.MUnrecognized.ErrCode,
|
||||
Err: "Invalid method for endpoint",
|
||||
})
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2024 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const MediaIDPrefix = "\U0001F408DISCORD"
|
||||
const MediaIDVersion = 1
|
||||
|
||||
type MediaIDClass uint8
|
||||
|
||||
const (
|
||||
MediaIDClassAttachment MediaIDClass = 1
|
||||
MediaIDClassEmoji MediaIDClass = 2
|
||||
MediaIDClassSticker MediaIDClass = 3
|
||||
MediaIDClassUserAvatar MediaIDClass = 4
|
||||
MediaIDClassGuildMemberAvatar MediaIDClass = 5
|
||||
)
|
||||
|
||||
type MediaIDData interface {
|
||||
Write(to io.Writer)
|
||||
Read(from io.Reader) error
|
||||
Size() int
|
||||
Wrap() *MediaID
|
||||
}
|
||||
|
||||
type MediaID struct {
|
||||
Version uint8
|
||||
TypeClass MediaIDClass
|
||||
Data MediaIDData
|
||||
}
|
||||
|
||||
func ParseMediaID(id string, key [32]byte) (*MediaID, error) {
|
||||
data, err := base64.RawURLEncoding.DecodeString(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
hasher := hmac.New(sha256.New, key[:])
|
||||
checksum := data[len(data)-TruncatedHashLength:]
|
||||
data = data[:len(data)-TruncatedHashLength]
|
||||
hasher.Write(data)
|
||||
if !hmac.Equal(checksum, hasher.Sum(nil)[:TruncatedHashLength]) {
|
||||
return nil, ErrMediaIDChecksumMismatch
|
||||
}
|
||||
mid := &MediaID{}
|
||||
err = mid.Read(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse media ID: %w", err)
|
||||
}
|
||||
return mid, nil
|
||||
}
|
||||
|
||||
const TruncatedHashLength = 16
|
||||
|
||||
func (mid *MediaID) SignedString(key [32]byte) string {
|
||||
buf := bytes.NewBuffer(make([]byte, 0, mid.Size()))
|
||||
mid.Write(buf)
|
||||
hasher := hmac.New(sha256.New, key[:])
|
||||
hasher.Write(buf.Bytes())
|
||||
buf.Write(hasher.Sum(nil)[:TruncatedHashLength])
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
func (mid *MediaID) Write(to io.Writer) {
|
||||
_, _ = to.Write([]byte(MediaIDPrefix))
|
||||
_ = binary.Write(to, binary.BigEndian, mid.Version)
|
||||
_ = binary.Write(to, binary.BigEndian, mid.TypeClass)
|
||||
mid.Data.Write(to)
|
||||
}
|
||||
|
||||
func (mid *MediaID) Size() int {
|
||||
return len(MediaIDPrefix) + 2 + mid.Data.Size() + TruncatedHashLength
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidMediaID = errors.New("invalid media ID")
|
||||
ErrMediaIDChecksumMismatch = errors.New("invalid checksum in media ID")
|
||||
ErrUnsupportedMediaID = errors.New("unsupported media ID")
|
||||
)
|
||||
|
||||
func (mid *MediaID) Read(from io.Reader) error {
|
||||
prefix := make([]byte, len(MediaIDPrefix))
|
||||
_, err := io.ReadFull(from, prefix)
|
||||
if err != nil || !bytes.Equal(prefix, []byte(MediaIDPrefix)) {
|
||||
return fmt.Errorf("%w: prefix not found", ErrInvalidMediaID)
|
||||
}
|
||||
versionAndClass := make([]byte, 2)
|
||||
_, err = io.ReadFull(from, versionAndClass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: version and class not found", ErrInvalidMediaID)
|
||||
} else if versionAndClass[0] != MediaIDVersion {
|
||||
return fmt.Errorf("%w: unknown version %d", ErrUnsupportedMediaID, versionAndClass[0])
|
||||
}
|
||||
switch MediaIDClass(versionAndClass[1]) {
|
||||
case MediaIDClassAttachment:
|
||||
mid.Data = &AttachmentMediaData{}
|
||||
case MediaIDClassEmoji:
|
||||
mid.Data = &EmojiMediaData{}
|
||||
case MediaIDClassSticker:
|
||||
mid.Data = &StickerMediaData{}
|
||||
case MediaIDClassUserAvatar:
|
||||
mid.Data = &UserAvatarMediaData{}
|
||||
case MediaIDClassGuildMemberAvatar:
|
||||
mid.Data = &GuildMemberAvatarMediaData{}
|
||||
default:
|
||||
return fmt.Errorf("%w: unrecognized type class %d", ErrUnsupportedMediaID, versionAndClass[1])
|
||||
}
|
||||
err = mid.Data.Read(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse media ID data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AttachmentMediaData struct {
|
||||
ChannelID uint64
|
||||
MessageID uint64
|
||||
AttachmentID uint64
|
||||
}
|
||||
|
||||
func (amd *AttachmentMediaData) Write(to io.Writer) {
|
||||
_ = binary.Write(to, binary.BigEndian, amd)
|
||||
}
|
||||
|
||||
func (amd *AttachmentMediaData) Read(from io.Reader) (err error) {
|
||||
return binary.Read(from, binary.BigEndian, amd)
|
||||
}
|
||||
|
||||
func (amd *AttachmentMediaData) Size() int {
|
||||
return binary.Size(amd)
|
||||
}
|
||||
|
||||
func (amd *AttachmentMediaData) Wrap() *MediaID {
|
||||
return &MediaID{
|
||||
Version: MediaIDVersion,
|
||||
TypeClass: MediaIDClassAttachment,
|
||||
Data: amd,
|
||||
}
|
||||
}
|
||||
|
||||
func (amd *AttachmentMediaData) CacheKey() AttachmentCacheKey {
|
||||
return AttachmentCacheKey{
|
||||
ChannelID: amd.ChannelID,
|
||||
AttachmentID: amd.AttachmentID,
|
||||
}
|
||||
}
|
||||
|
||||
type StickerMediaData struct {
|
||||
StickerID uint64
|
||||
Format uint8
|
||||
}
|
||||
|
||||
func (smd *StickerMediaData) Write(to io.Writer) {
|
||||
_ = binary.Write(to, binary.BigEndian, smd)
|
||||
}
|
||||
|
||||
func (smd *StickerMediaData) Read(from io.Reader) error {
|
||||
return binary.Read(from, binary.BigEndian, smd)
|
||||
}
|
||||
|
||||
func (smd *StickerMediaData) Size() int {
|
||||
return binary.Size(smd)
|
||||
}
|
||||
|
||||
func (smd *StickerMediaData) Wrap() *MediaID {
|
||||
return &MediaID{
|
||||
Version: MediaIDVersion,
|
||||
TypeClass: MediaIDClassSticker,
|
||||
Data: smd,
|
||||
}
|
||||
}
|
||||
|
||||
type EmojiMediaDataInner struct {
|
||||
EmojiID uint64
|
||||
Animated bool
|
||||
}
|
||||
|
||||
type EmojiMediaData struct {
|
||||
EmojiMediaDataInner
|
||||
Name string
|
||||
}
|
||||
|
||||
func (emd *EmojiMediaData) Write(to io.Writer) {
|
||||
_ = binary.Write(to, binary.BigEndian, &emd.EmojiMediaDataInner)
|
||||
_, _ = to.Write([]byte(emd.Name))
|
||||
}
|
||||
|
||||
func (emd *EmojiMediaData) Read(from io.Reader) (err error) {
|
||||
err = binary.Read(from, binary.BigEndian, &emd.EmojiMediaDataInner)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name, err := io.ReadAll(from)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
emd.Name = string(name)
|
||||
return
|
||||
}
|
||||
|
||||
func (emd *EmojiMediaData) Size() int {
|
||||
return binary.Size(&emd.EmojiMediaDataInner) + len(emd.Name)
|
||||
}
|
||||
|
||||
func (emd *EmojiMediaData) Wrap() *MediaID {
|
||||
return &MediaID{
|
||||
Version: MediaIDVersion,
|
||||
TypeClass: MediaIDClassEmoji,
|
||||
Data: emd,
|
||||
}
|
||||
}
|
||||
|
||||
type UserAvatarMediaData struct {
|
||||
UserID uint64
|
||||
Animated bool
|
||||
AvatarID [16]byte
|
||||
}
|
||||
|
||||
func (uamd *UserAvatarMediaData) Write(to io.Writer) {
|
||||
_ = binary.Write(to, binary.BigEndian, uamd)
|
||||
}
|
||||
|
||||
func (uamd *UserAvatarMediaData) Read(from io.Reader) error {
|
||||
return binary.Read(from, binary.BigEndian, uamd)
|
||||
}
|
||||
|
||||
func (uamd *UserAvatarMediaData) Size() int {
|
||||
return binary.Size(uamd)
|
||||
}
|
||||
|
||||
func (uamd *UserAvatarMediaData) Wrap() *MediaID {
|
||||
return &MediaID{
|
||||
Version: MediaIDVersion,
|
||||
TypeClass: MediaIDClassUserAvatar,
|
||||
Data: uamd,
|
||||
}
|
||||
}
|
||||
|
||||
type GuildMemberAvatarMediaData struct {
|
||||
GuildID uint64
|
||||
UserID uint64
|
||||
Animated bool
|
||||
AvatarID [16]byte
|
||||
}
|
||||
|
||||
func (guamd *GuildMemberAvatarMediaData) Write(to io.Writer) {
|
||||
_ = binary.Write(to, binary.BigEndian, guamd)
|
||||
}
|
||||
|
||||
func (guamd *GuildMemberAvatarMediaData) Read(from io.Reader) error {
|
||||
return binary.Read(from, binary.BigEndian, guamd)
|
||||
}
|
||||
|
||||
func (guamd *GuildMemberAvatarMediaData) Size() int {
|
||||
return binary.Size(guamd)
|
||||
}
|
||||
|
||||
func (guamd *GuildMemberAvatarMediaData) Wrap() *MediaID {
|
||||
return &MediaID{
|
||||
Version: MediaIDVersion,
|
||||
TypeClass: MediaIDClassGuildMemberAvatar,
|
||||
Data: guamd,
|
||||
}
|
||||
}
|
||||
52
discord.go
52
discord.go
|
|
@ -1,52 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
|
||||
switch channel.Type {
|
||||
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
|
||||
// allowed
|
||||
case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
|
||||
// DMs are always bridgeable, no need for permission checks
|
||||
return true
|
||||
default:
|
||||
// everything else is not allowed
|
||||
return false
|
||||
}
|
||||
|
||||
log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger()
|
||||
|
||||
member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
|
||||
if errors.Is(err, discordgo.ErrStateNotFound) {
|
||||
log.Debug().Msg("Fetching own membership in guild to check roles")
|
||||
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
|
||||
} else {
|
||||
err = user.Session.State.MemberAdd(member)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
|
||||
}
|
||||
err = user.Session.State.ChannelAdd(channel)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to add channel to cache")
|
||||
}
|
||||
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
|
||||
return true
|
||||
}
|
||||
log.Debug().
|
||||
Int64("permissions", perms).
|
||||
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
|
||||
Msg("Computed permissions in channel")
|
||||
return perms&discordgo.PermissionViewChannel > 0
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ function fixperms {
|
|||
}
|
||||
|
||||
if [[ ! -f /data/config.yaml ]]; then
|
||||
cp /opt/mautrix-discord/example-config.yaml /data/config.yaml
|
||||
/usr/bin/mautrix-discord -c /data/config.yaml -e
|
||||
echo "Didn't find a config file."
|
||||
echo "Copied default config file to /data/config.yaml"
|
||||
echo "Modify that config file to your liking."
|
||||
|
|
|
|||
|
|
@ -1,381 +0,0 @@
|
|||
# Homeserver details.
|
||||
homeserver:
|
||||
# The address that this appservice can use to connect to the homeserver.
|
||||
address: https://matrix.example.com
|
||||
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
|
||||
domain: example.com
|
||||
|
||||
# What software is the homeserver running?
|
||||
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
|
||||
software: standard
|
||||
# The URL to push real-time bridge status to.
|
||||
# If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
|
||||
# The bridge will use the appservice as_token to authorize requests.
|
||||
status_endpoint: null
|
||||
# Endpoint for reporting per-message status.
|
||||
message_send_checkpoint_endpoint: null
|
||||
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
|
||||
async_media: false
|
||||
|
||||
# Should the bridge use a websocket for connecting to the homeserver?
|
||||
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
|
||||
# mautrix-asmux (deprecated), and hungryserv (proprietary).
|
||||
websocket: false
|
||||
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
|
||||
ping_interval_seconds: 0
|
||||
|
||||
# Application service host/registration related details.
|
||||
# Changing these values requires regeneration of the registration.
|
||||
appservice:
|
||||
# The address that the homeserver can use to connect to this appservice.
|
||||
address: http://localhost:29334
|
||||
|
||||
# The hostname and port where this appservice should listen.
|
||||
hostname: 0.0.0.0
|
||||
port: 29334
|
||||
|
||||
# Database config.
|
||||
database:
|
||||
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
|
||||
type: postgres
|
||||
# The database URI.
|
||||
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
|
||||
# https://github.com/mattn/go-sqlite3#connection-string
|
||||
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
|
||||
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
|
||||
uri: postgres://user:password@host/database?sslmode=disable
|
||||
# Maximum number of connections. Mostly relevant for Postgres.
|
||||
max_open_conns: 20
|
||||
max_idle_conns: 2
|
||||
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
|
||||
# Parsed with https://pkg.go.dev/time#ParseDuration
|
||||
max_conn_idle_time: null
|
||||
max_conn_lifetime: null
|
||||
|
||||
# The unique ID of this appservice.
|
||||
id: discord
|
||||
# Appservice bot details.
|
||||
bot:
|
||||
# Username of the appservice bot.
|
||||
username: discordbot
|
||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
||||
# to leave display name/avatar as-is.
|
||||
displayname: Discord bridge bot
|
||||
avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
|
||||
|
||||
# Whether or not to receive ephemeral events via appservice transactions.
|
||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
||||
ephemeral_events: true
|
||||
|
||||
# Should incoming events be handled asynchronously?
|
||||
# This may be necessary for large public instances with lots of messages going through.
|
||||
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
|
||||
async_transactions: false
|
||||
|
||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
||||
as_token: "This value is generated when generating the registration"
|
||||
hs_token: "This value is generated when generating the registration"
|
||||
|
||||
# Bridge config
|
||||
bridge:
|
||||
# Localpart template of MXIDs for Discord users.
|
||||
# {{.}} is replaced with the internal ID of the Discord user.
|
||||
username_template: discord_{{.}}
|
||||
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
|
||||
# Available variables:
|
||||
# .ID - Internal user ID
|
||||
# .Username - Legacy display/username on Discord
|
||||
# .GlobalName - New displayname on Discord
|
||||
# .Discriminator - The 4 numbers after the name on Discord
|
||||
# .Bot - Whether the user is a bot
|
||||
# .System - Whether the user is an official system user
|
||||
# .Webhook - Whether the user is a webhook and is not an application
|
||||
# .Application - Whether the user is an application
|
||||
displayname_template: '{{if .Webhook}}Webhook{{else}}{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}{{end}}'
|
||||
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
|
||||
# Available variables:
|
||||
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
|
||||
# .ParentName - Parent channel name (used for categories).
|
||||
# .GuildName - Guild name.
|
||||
# .NSFW - Whether the channel is marked as NSFW.
|
||||
# .Type - Channel type (see values at https://github.com/bwmarrin/discordgo/blob/v0.25.0/structs.go#L251-L267)
|
||||
channel_name_template: '{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}'
|
||||
# Displayname template for Discord guilds (bridged as spaces).
|
||||
# Available variables:
|
||||
# .Name - Guild name
|
||||
guild_name_template: '{{.Name}}'
|
||||
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
||||
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
||||
# If set to `always`, all DM rooms will have explicit names and avatars set.
|
||||
# If set to `never`, DM rooms will never have names and avatars set.
|
||||
private_chat_portal_meta: default
|
||||
|
||||
# Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode.
|
||||
# If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address.
|
||||
# This should not have a trailing slash, the endpoint above will be appended to the provided address.
|
||||
public_address: null
|
||||
# A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature.
|
||||
avatar_proxy_key: generate
|
||||
|
||||
portal_message_buffer: 128
|
||||
|
||||
# Number of private channel portals to create on bridge startup.
|
||||
# Other portals will be created when receiving messages.
|
||||
startup_private_channel_create_limit: 5
|
||||
# Should the bridge send a read receipt from the bridge bot when a message has been sent to Discord?
|
||||
delivery_receipts: false
|
||||
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
||||
message_status_events: false
|
||||
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
|
||||
message_error_notices: true
|
||||
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
|
||||
# This can avoid unnecessary invite events in guild rooms when members are synced in.
|
||||
restricted_rooms: true
|
||||
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
|
||||
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
|
||||
autojoin_thread_on_open: true
|
||||
# Should inline fields in Discord embeds be bridged as HTML tables to Matrix?
|
||||
# Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI.
|
||||
embed_fields_as_tables: true
|
||||
# Should guild channels be muted when the portal is created? This only meant for single-user instances,
|
||||
# it won't mute it for all users if there are multiple Matrix users in the same Discord guild.
|
||||
mute_channels_on_create: false
|
||||
# Should the bridge update the m.direct account data event when double puppeting is enabled.
|
||||
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
|
||||
# and is therefore prone to race conditions.
|
||||
sync_direct_chat_list: false
|
||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
||||
# This field will automatically be changed back to false after it, except if the config file is not writable.
|
||||
resend_bridge_info: false
|
||||
# Should incoming custom emoji reactions be bridged as mxc:// URIs?
|
||||
# If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available.
|
||||
custom_emoji_reactions: true
|
||||
# Should the bridge attempt to completely delete portal rooms when a channel is deleted on Discord?
|
||||
# If true, the bridge will try to kick Matrix users from the room. Otherwise, the bridge only makes ghosts leave.
|
||||
delete_portal_on_channel_delete: false
|
||||
# Should the bridge delete all portal rooms when you leave a guild on Discord?
|
||||
# This only applies if the guild has no other Matrix users on this bridge instance.
|
||||
delete_guild_on_leave: true
|
||||
# Whether or not created rooms should have federation enabled.
|
||||
# If false, created portal rooms will never be federated.
|
||||
federate_rooms: true
|
||||
# Prefix messages from webhooks with the profile info? This can be used along with a custom displayname_template
|
||||
# to better handle webhooks that change their name all the time (like ones used by bridges).
|
||||
#
|
||||
# This will use the fallback mode in MSC4144, which means clients that support MSC4144 will not show the prefix
|
||||
# (and will instead show the name and avatar as the message sender).
|
||||
prefix_webhook_messages: true
|
||||
# Bridge webhook avatars?
|
||||
enable_webhook_avatars: false
|
||||
# Should the bridge upload media to the Discord CDN directly before sending the message when using a user token,
|
||||
# like the official client does? The other option is sending the media in the message send request as a form part
|
||||
# (which is always used by bots and webhooks).
|
||||
use_discord_cdn_upload: true
|
||||
# Proxy for Discord connections
|
||||
proxy:
|
||||
# Should mxc uris copied from Discord be cached?
|
||||
# This can be `never` to never cache, `unencrypted` to only cache unencrypted mxc uris, or `always` to cache everything.
|
||||
# If you have a media repo that generates non-unique mxc uris, you should set this to never.
|
||||
cache_media: unencrypted
|
||||
# Settings for converting Discord media to custom mxc:// URIs instead of reuploading.
|
||||
# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
|
||||
direct_media:
|
||||
# Should custom mxc:// URIs be used instead of reuploading media?
|
||||
enabled: false
|
||||
# The server name to use for the custom mxc:// URIs.
|
||||
# This server name will effectively be a real Matrix server, it just won't implement anything other than media.
|
||||
# You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge.
|
||||
server_name: discord-media.example.com
|
||||
# Optionally a custom .well-known response. This defaults to `server_name:443`
|
||||
well_known_response:
|
||||
# The bridge supports MSC3860 media download redirects and will use them if the requester supports it.
|
||||
# Optionally, you can force redirects and not allow proxying at all by setting this to false.
|
||||
allow_proxy: true
|
||||
# Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file.
|
||||
# This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them.
|
||||
server_key: generate
|
||||
# Settings for converting animated stickers.
|
||||
animated_sticker:
|
||||
# Format to which animated stickers should be converted.
|
||||
# disable - No conversion, send as-is (lottie JSON)
|
||||
# png - converts to non-animated png (fastest)
|
||||
# gif - converts to animated gif
|
||||
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
|
||||
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
|
||||
target: webp
|
||||
# Arguments for converter. All converters take width and height.
|
||||
args:
|
||||
width: 320
|
||||
height: 320
|
||||
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
|
||||
# Servers to always allow double puppeting from
|
||||
double_puppet_server_map:
|
||||
example.com: https://example.com
|
||||
# Allow using double puppeting from any server with a valid client .well-known file.
|
||||
double_puppet_allow_discovery: false
|
||||
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
|
||||
#
|
||||
# If set, double puppeting will be enabled automatically for local users
|
||||
# instead of users having to find an access token and run `login-matrix`
|
||||
# manually.
|
||||
login_shared_secret_map:
|
||||
example.com: foobar
|
||||
|
||||
# The prefix for commands. Only required in non-management rooms.
|
||||
command_prefix: '!discord'
|
||||
# Messages sent upon joining a management room.
|
||||
# Markdown is supported. The defaults are listed below.
|
||||
management_room_text:
|
||||
# Sent when joining a room.
|
||||
welcome: "Hello, I'm a Discord bridge bot."
|
||||
# Sent when joining a management room and the user is already logged in.
|
||||
welcome_connected: "Use `help` for help."
|
||||
# Sent when joining a management room and the user is not logged in.
|
||||
welcome_unconnected: "Use `help` for help or `login` to log in."
|
||||
# Optional extra text sent when joining a management room.
|
||||
additional_help: ""
|
||||
|
||||
# Settings for backfilling messages.
|
||||
backfill:
|
||||
# Limits for forward backfilling.
|
||||
forward_limits:
|
||||
# Initial backfill (when creating portal). 0 means backfill is disabled.
|
||||
# A special unlimited value is not supported, you must set a limit. Initial backfill will
|
||||
# fetch all messages first before backfilling anything, so high limits can take a lot of time.
|
||||
initial:
|
||||
dm: 0
|
||||
channel: 0
|
||||
thread: 0
|
||||
# Missed message backfill (on startup).
|
||||
# 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
|
||||
# When using unlimited backfill (-1), messages are backfilled as they are fetched.
|
||||
# With limits, all messages up to the limit are fetched first and backfilled afterwards.
|
||||
missed:
|
||||
dm: 0
|
||||
channel: 0
|
||||
thread: 0
|
||||
# Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
|
||||
# This can be used as a rough heuristic to disable backfilling in channels that are too active.
|
||||
# Currently only applies to missed message backfill.
|
||||
max_guild_members: -1
|
||||
|
||||
# End-to-bridge encryption support options.
|
||||
#
|
||||
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
||||
encryption:
|
||||
# Allow encryption, work in group chat rooms with e2ee enabled
|
||||
allow: false
|
||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||
default: false
|
||||
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
|
||||
# Changing this option requires updating the appservice registration file.
|
||||
appservice: false
|
||||
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
|
||||
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
|
||||
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
|
||||
# Changing this option requires updating the appservice registration file.
|
||||
msc4190: false
|
||||
# Require encryption, drop any unencrypted messages.
|
||||
require: false
|
||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
||||
# You must use a client that supports requesting keys from other users to use this feature.
|
||||
allow_key_sharing: false
|
||||
# Should users mentions be in the event wire content to enable the server to send push notifications?
|
||||
plaintext_mentions: false
|
||||
# Options for deleting megolm sessions from the bridge.
|
||||
delete_keys:
|
||||
# Beeper-specific: delete outbound sessions when hungryserv confirms
|
||||
# that the user has uploaded the key to key backup.
|
||||
delete_outbound_on_ack: false
|
||||
# Don't store outbound sessions in the inbound table.
|
||||
dont_store_outbound: false
|
||||
# Ratchet megolm sessions forward after decrypting messages.
|
||||
ratchet_on_decrypt: false
|
||||
# Delete fully used keys (index >= max_messages) after decrypting messages.
|
||||
delete_fully_used_on_decrypt: false
|
||||
# Delete previous megolm sessions from same device when receiving a new one.
|
||||
delete_prev_on_new_session: false
|
||||
# Delete megolm sessions received from a device when the device is deleted.
|
||||
delete_on_device_delete: false
|
||||
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
|
||||
periodically_delete_expired: false
|
||||
# Delete inbound megolm sessions that don't have the received_at field used for
|
||||
# automatic ratcheting and expired session deletion. This is meant as a migration
|
||||
# to delete old keys prior to the bridge update.
|
||||
delete_outdated_inbound: false
|
||||
# What level of device verification should be required from users?
|
||||
#
|
||||
# Valid levels:
|
||||
# unverified - Send keys to all device in the room.
|
||||
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
|
||||
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
|
||||
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
|
||||
# Note that creating user signatures from the bridge bot is not currently possible.
|
||||
# verified - Require manual per-device verification
|
||||
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
|
||||
verification_levels:
|
||||
# Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
|
||||
receive: unverified
|
||||
# Minimum level that the bridge should accept for incoming Matrix messages.
|
||||
send: unverified
|
||||
# Minimum level that the bridge should require for accepting key requests.
|
||||
share: cross-signed-tofu
|
||||
# Options for Megolm room key rotation. These options allow you to
|
||||
# configure the m.room.encryption event content. See:
|
||||
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
|
||||
# more information about that event.
|
||||
rotation:
|
||||
# Enable custom Megolm room key rotation settings. Note that these
|
||||
# settings will only apply to rooms created after this option is
|
||||
# set.
|
||||
enable_custom: false
|
||||
# The maximum number of milliseconds a session should be used
|
||||
# before changing it. The Matrix spec recommends 604800000 (a week)
|
||||
# as the default.
|
||||
milliseconds: 604800000
|
||||
# The maximum number of messages that should be sent with a given a
|
||||
# session before changing it. The Matrix spec recommends 100 as the
|
||||
# default.
|
||||
messages: 100
|
||||
|
||||
# Disable rotating keys when a user's devices change?
|
||||
# You should not enable this option unless you understand all the implications.
|
||||
disable_device_change_key_rotation: false
|
||||
|
||||
# Settings for provisioning API
|
||||
provisioning:
|
||||
# Prefix for the provisioning API paths.
|
||||
prefix: /_matrix/provision
|
||||
# Shared secret for authentication. If set to "generate", a random secret will be generated,
|
||||
# or if set to "disable", the provisioning API will be disabled.
|
||||
shared_secret: generate
|
||||
# Enable debug API at /debug with provisioning authentication.
|
||||
debug_endpoints: false
|
||||
|
||||
# Permissions for using the bridge.
|
||||
# Permitted values:
|
||||
# relay - Talk through the relaybot (if enabled), no access otherwise
|
||||
# user - Access to use the bridge to chat with a Discord account.
|
||||
# admin - User level and some additional administration tools
|
||||
# Permitted keys:
|
||||
# * - All Matrix users
|
||||
# domain - All users on that homeserver
|
||||
# mxid - Specific user
|
||||
permissions:
|
||||
"*": relay
|
||||
"example.com": user
|
||||
"@admin:example.com": admin
|
||||
|
||||
# Logging config. See https://github.com/tulir/zeroconfig for details.
|
||||
logging:
|
||||
min_level: debug
|
||||
writers:
|
||||
- type: stdout
|
||||
format: pretty-colored
|
||||
- type: file
|
||||
format: json
|
||||
filename: ./logs/mautrix-discord.log
|
||||
max_size: 100
|
||||
max_backups: 10
|
||||
compress: true
|
||||
260
formatter.go
260
formatter.go
|
|
@ -1,260 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2023 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/util"
|
||||
"go.mau.fi/util/variationselector"
|
||||
"golang.org/x/exp/slices"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/format/mdext"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
|
||||
//
|
||||
// Discord allows escaping with just one backslash, e.g. \__a__,
|
||||
// but standard markdown requires both to be escaped (\_\_a__)
|
||||
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
|
||||
|
||||
func escapeReplacement(s string) string {
|
||||
return s[:2] + `\` + s[2:]
|
||||
}
|
||||
|
||||
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
|
||||
// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
|
||||
type indentableParagraphParser struct {
|
||||
parser.BlockParser
|
||||
}
|
||||
|
||||
var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
|
||||
|
||||
func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
var removeFeaturesExceptLinks = []any{
|
||||
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
|
||||
parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
|
||||
parser.NewCodeBlockParser(),
|
||||
}
|
||||
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
|
||||
var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
|
||||
var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
|
||||
|
||||
var discordRenderer = goldmark.New(
|
||||
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
|
||||
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
|
||||
)
|
||||
var discordRendererWithInlineLinks = goldmark.New(
|
||||
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
|
||||
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
|
||||
)
|
||||
|
||||
func (portal *Portal) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string {
|
||||
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
|
||||
|
||||
var buf strings.Builder
|
||||
ctx := parser.NewContext()
|
||||
ctx.Set(parserContextPortal, portal)
|
||||
renderer := discordRenderer
|
||||
if allowInlineLinks {
|
||||
renderer = discordRendererWithInlineLinks
|
||||
}
|
||||
err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("markdown parser errored: %w", err))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
|
||||
return format.UnwrapSingleParagraph(portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(text, allowInlineLinks))
|
||||
}
|
||||
|
||||
const formatterContextPortalKey = "fi.mau.discord.portal"
|
||||
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
|
||||
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
|
||||
const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews"
|
||||
|
||||
func appendIfNotContains(arr []string, newItem string) []string {
|
||||
for _, item := range arr {
|
||||
if item == newItem {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
return append(arr, newItem)
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
|
||||
if len(mxid) == 0 {
|
||||
return displayname
|
||||
}
|
||||
if mxid[0] == '#' {
|
||||
alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
|
||||
if err != nil {
|
||||
return displayname
|
||||
}
|
||||
mxid = alias.RoomID.String()
|
||||
}
|
||||
if mxid[0] == '!' {
|
||||
portal := br.GetPortalByMXID(id.RoomID(mxid))
|
||||
if portal != nil {
|
||||
if eventID == "" {
|
||||
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
|
||||
return fmt.Sprintf("<#%s>", portal.Key.ChannelID)
|
||||
//if currentPortal.GuildID == portal.GuildID {
|
||||
//} else if portal.GuildID != "" {
|
||||
// return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name)
|
||||
//} else {
|
||||
// // TODO is mentioning private channels possible at all?
|
||||
//}
|
||||
} else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
|
||||
guildID := portal.GuildID
|
||||
if guildID == "" {
|
||||
guildID = "@me"
|
||||
}
|
||||
return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID)
|
||||
}
|
||||
}
|
||||
} else if mxid[0] == '@' {
|
||||
allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID)
|
||||
if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) {
|
||||
return displayname
|
||||
}
|
||||
mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
|
||||
parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
|
||||
if ok {
|
||||
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
|
||||
return fmt.Sprintf("<@%s>", parsedID)
|
||||
}
|
||||
mentionedUser := br.GetUserByMXID(id.UserID(mxid))
|
||||
if mentionedUser != nil && mentionedUser.DiscordID != "" {
|
||||
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
|
||||
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
|
||||
}
|
||||
}
|
||||
return displayname
|
||||
}
|
||||
|
||||
const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
|
||||
|
||||
// Discord links start with http:// or https://, contain at least two characters afterwards,
|
||||
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
|
||||
//
|
||||
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
|
||||
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
|
||||
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
|
||||
|
||||
var discordMarkdownEscaper = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`_`, `\_`,
|
||||
`*`, `\*`,
|
||||
`~`, `\~`,
|
||||
"`", "\\`",
|
||||
`|`, `\|`,
|
||||
`<`, `\<`,
|
||||
`#`, `\#`,
|
||||
)
|
||||
|
||||
func escapeDiscordMarkdown(s string) string {
|
||||
submatches := discordLinkRegex.FindAllStringIndex(s, -1)
|
||||
if submatches == nil {
|
||||
return discordMarkdownEscaper.Replace(s)
|
||||
}
|
||||
var builder strings.Builder
|
||||
offset := 0
|
||||
for _, match := range submatches {
|
||||
start := match[0]
|
||||
end := match[1]
|
||||
builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start]))
|
||||
builder.WriteString(s[start:end])
|
||||
offset = end
|
||||
}
|
||||
builder.WriteString(discordMarkdownEscaper.Replace(s[offset:]))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
var matrixHTMLParser = &format.HTMLParser{
|
||||
TabsToSpaces: 4,
|
||||
Newline: "\n",
|
||||
HorizontalLine: "\n---\n",
|
||||
ItalicConverter: func(s string, ctx format.Context) string {
|
||||
return fmt.Sprintf("*%s*", s)
|
||||
},
|
||||
UnderlineConverter: func(s string, ctx format.Context) string {
|
||||
return fmt.Sprintf("__%s__", s)
|
||||
},
|
||||
TextConverter: func(s string, ctx format.Context) string {
|
||||
if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") {
|
||||
// If we're in a code block, don't escape markdown
|
||||
return s
|
||||
}
|
||||
return escapeDiscordMarkdown(s)
|
||||
},
|
||||
SpoilerConverter: func(text, reason string, ctx format.Context) string {
|
||||
if reason != "" {
|
||||
return fmt.Sprintf("(%s) ||%s||", reason, text)
|
||||
}
|
||||
return fmt.Sprintf("||%s||", text)
|
||||
},
|
||||
LinkConverter: func(text, href string, ctx format.Context) string {
|
||||
linkPreviews := ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey].([]string)
|
||||
allowPreview := linkPreviews == nil || slices.Contains(linkPreviews, href)
|
||||
if text == href {
|
||||
if !allowPreview {
|
||||
return fmt.Sprintf("<%s>", text)
|
||||
}
|
||||
return text
|
||||
} else if !discordLinkRegexFull.MatchString(href) {
|
||||
return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href))
|
||||
} else if !allowPreview {
|
||||
return fmt.Sprintf("[%s](<%s>)", escapeDiscordMarkdown(text), href)
|
||||
} else {
|
||||
return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
|
||||
allowedMentions := &discordgo.MessageAllowedMentions{
|
||||
Parse: []discordgo.AllowedMentionType{},
|
||||
Users: []string{},
|
||||
RepliedUser: true,
|
||||
}
|
||||
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
|
||||
ctx := format.NewContext()
|
||||
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
|
||||
ctx.ReturnData[formatterContextPortalKey] = portal
|
||||
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
|
||||
if content.Mentions != nil {
|
||||
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
|
||||
}
|
||||
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
|
||||
} else {
|
||||
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEscapeDiscordMarkdown(t *testing.T) {
|
||||
type escapeTest struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}
|
||||
|
||||
tests := []escapeTest{
|
||||
{"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."},
|
||||
{"Backslash", `foo\bar`, `foo\\bar`},
|
||||
{"Underscore", `foo_bar`, `foo\_bar`},
|
||||
{"Asterisk", `foo*bar`, `foo\*bar`},
|
||||
{"Tilde", `foo~bar`, `foo\~bar`},
|
||||
{"Backtick", "foo`bar", "foo\\`bar"},
|
||||
{"Forward tick", `foo´bar`, `foo´bar`},
|
||||
{"Pipe", `foo|bar`, `foo\|bar`},
|
||||
{"Less than", `foo<bar`, `foo\<bar`},
|
||||
{"Greater than", `foo>bar`, `foo>bar`},
|
||||
{"Multiple things", `\_*~|`, `\\\_\*\~\|`},
|
||||
{"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`},
|
||||
{"Multiple URLs", `hello_world https://example.com/foo_bar *testing* https://a_b_c/*def*`, `hello\_world https://example.com/foo_bar \*testing\* https://a_b_c/*def*`},
|
||||
{"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"},
|
||||
{"URL ends with less than", `https://example.com<foo_bar`, `https://example.com<foo\_bar`},
|
||||
{"Short URL", `https://_`, `https://_`},
|
||||
{"Insecure URL", `http://example.com/foo_bar`, `http://example.com/foo_bar`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, escapeDiscordMarkdown(test.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
56
go.mod
56
go.mod
|
|
@ -1,45 +1,45 @@
|
|||
module go.mau.fi/mautrix-discord
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.0
|
||||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.27.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/yuin/goldmark v1.7.12
|
||||
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc
|
||||
golang.org/x/sync v0.16.0
|
||||
maunium.net/go/maulogger/v2 v2.4.1
|
||||
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25
|
||||
golang.org/x/term v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.mau.fi/zeroconfig v0.1.2 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
go.mau.fi/zeroconfig v0.2.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/mauflag v1.0.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2
|
||||
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260429033617-4dea361a9bb6
|
||||
|
|
|
|||
102
go.sum
102
go.sum
|
|
@ -1,68 +1,70 @@
|
|||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ=
|
||||
github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/beeper/discordgo v0.0.0-20260429033617-4dea361a9bb6 h1:L956OBNYiTXMSNzJ1cADxf395/IXxXrSqD1kC97ufjA=
|
||||
github.com/beeper/discordgo v0.0.0-20260429033617-4dea361a9bb6/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs=
|
||||
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw=
|
||||
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
|
||||
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 h1:YPEmc+li7TF6C9AdRTcSLMb6yCHdF27/wNT7kFLIVNg=
|
||||
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs=
|
||||
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
|
||||
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
|
|
@ -71,7 +73,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
|
||||
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
|
||||
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo=
|
||||
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM=
|
||||
|
|
|
|||
335
guildportal.go
335
guildportal.go
|
|
@ -1,335 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "maunium.net/go/maulogger/v2"
|
||||
"maunium.net/go/maulogger/v2/maulogadapt"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"go.mau.fi/mautrix-discord/config"
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
)
|
||||
|
||||
type Guild struct {
|
||||
*database.Guild
|
||||
|
||||
bridge *DiscordBridge
|
||||
log log.Logger
|
||||
|
||||
roomCreateLock sync.Mutex
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild {
|
||||
if dbGuild == nil {
|
||||
if id == "" || !createIfNotExist {
|
||||
return nil
|
||||
}
|
||||
|
||||
dbGuild = br.DB.Guild.New()
|
||||
dbGuild.ID = id
|
||||
dbGuild.Insert()
|
||||
}
|
||||
|
||||
guild := br.NewGuild(dbGuild)
|
||||
|
||||
br.guildsByID[guild.ID] = guild
|
||||
if guild.MXID != "" {
|
||||
br.guildsByMXID[guild.MXID] = guild
|
||||
}
|
||||
|
||||
return guild
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild {
|
||||
br.guildsLock.Lock()
|
||||
defer br.guildsLock.Unlock()
|
||||
|
||||
portal, ok := br.guildsByMXID[mxid]
|
||||
if !ok {
|
||||
return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false)
|
||||
}
|
||||
|
||||
return portal
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild {
|
||||
br.guildsLock.Lock()
|
||||
defer br.guildsLock.Unlock()
|
||||
|
||||
guild, ok := br.guildsByID[id]
|
||||
if !ok {
|
||||
return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist)
|
||||
}
|
||||
|
||||
return guild
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetAllGuilds() []*Guild {
|
||||
return br.dbGuildsToGuilds(br.DB.Guild.GetAll())
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild {
|
||||
br.guildsLock.Lock()
|
||||
defer br.guildsLock.Unlock()
|
||||
|
||||
output := make([]*Guild, len(dbGuilds))
|
||||
for index, dbGuild := range dbGuilds {
|
||||
if dbGuild == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
guild, ok := br.guildsByID[dbGuild.ID]
|
||||
if !ok {
|
||||
guild = br.loadGuild(dbGuild, "", false)
|
||||
}
|
||||
|
||||
output[index] = guild
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild {
|
||||
guild := &Guild{
|
||||
Guild: dbGuild,
|
||||
bridge: br,
|
||||
log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)),
|
||||
}
|
||||
|
||||
return guild
|
||||
}
|
||||
|
||||
func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) {
|
||||
bridgeInfo := event.BridgeEventContent{
|
||||
BridgeBot: guild.bridge.Bot.UserID,
|
||||
Creator: guild.bridge.Bot.UserID,
|
||||
Protocol: event.BridgeInfoSection{
|
||||
ID: "discordgo",
|
||||
DisplayName: "Discord",
|
||||
AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
|
||||
ExternalURL: "https://discord.com/",
|
||||
},
|
||||
Channel: event.BridgeInfoSection{
|
||||
ID: guild.ID,
|
||||
DisplayName: guild.Name,
|
||||
AvatarURL: guild.AvatarURL.CUString(),
|
||||
},
|
||||
}
|
||||
bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID)
|
||||
return bridgeInfoStateKey, bridgeInfo
|
||||
}
|
||||
|
||||
func (guild *Guild) UpdateBridgeInfo() {
|
||||
if len(guild.MXID) == 0 {
|
||||
guild.log.Debugln("Not updating bridge info: no Matrix room created")
|
||||
return
|
||||
}
|
||||
guild.log.Debugln("Updating bridge info...")
|
||||
stateKey, content := guild.getBridgeInfo()
|
||||
_, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content)
|
||||
if err != nil {
|
||||
guild.log.Warnln("Failed to update m.bridge:", err)
|
||||
}
|
||||
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
||||
_, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content)
|
||||
if err != nil {
|
||||
guild.log.Warnln("Failed to update uk.half-shot.bridge:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
|
||||
guild.roomCreateLock.Lock()
|
||||
defer guild.roomCreateLock.Unlock()
|
||||
if guild.MXID != "" {
|
||||
return nil
|
||||
}
|
||||
guild.log.Infoln("Creating Matrix room for guild")
|
||||
guild.UpdateInfo(user, meta)
|
||||
|
||||
bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo()
|
||||
|
||||
initialState := []*event.Event{{
|
||||
Type: event.StateBridge,
|
||||
Content: event.Content{Parsed: bridgeInfo},
|
||||
StateKey: &bridgeInfoStateKey,
|
||||
}, {
|
||||
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
||||
Type: event.StateHalfShotBridge,
|
||||
Content: event.Content{Parsed: bridgeInfo},
|
||||
StateKey: &bridgeInfoStateKey,
|
||||
}}
|
||||
|
||||
if !guild.AvatarURL.IsEmpty() {
|
||||
initialState = append(initialState, &event.Event{
|
||||
Type: event.StateRoomAvatar,
|
||||
Content: event.Content{Parsed: &event.RoomAvatarEventContent{
|
||||
URL: guild.AvatarURL,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
creationContent := map[string]interface{}{
|
||||
"type": event.RoomTypeSpace,
|
||||
}
|
||||
if !guild.bridge.Config.Bridge.FederateRooms {
|
||||
creationContent["m.federate"] = false
|
||||
}
|
||||
|
||||
resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
|
||||
Visibility: "private",
|
||||
Name: guild.Name,
|
||||
Preset: "private_chat",
|
||||
InitialState: initialState,
|
||||
CreationContent: creationContent,
|
||||
RoomVersion: "11",
|
||||
})
|
||||
if err != nil {
|
||||
guild.log.Warnln("Failed to create room:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
guild.MXID = resp.RoomID
|
||||
guild.NameSet = true
|
||||
guild.AvatarSet = !guild.AvatarURL.IsEmpty()
|
||||
guild.Update()
|
||||
guild.bridge.guildsLock.Lock()
|
||||
guild.bridge.guildsByMXID[guild.MXID] = guild
|
||||
guild.bridge.guildsLock.Unlock()
|
||||
guild.log.Infoln("Matrix room created:", guild.MXID)
|
||||
|
||||
user.ensureInvited(nil, guild.MXID, false, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
|
||||
if meta.Unavailable {
|
||||
guild.log.Debugfln("Ignoring unavailable guild update")
|
||||
return meta
|
||||
}
|
||||
changed := false
|
||||
changed = guild.UpdateName(meta) || changed
|
||||
changed = guild.UpdateAvatar(meta.Icon) || changed
|
||||
if changed {
|
||||
guild.UpdateBridgeInfo()
|
||||
guild.Update()
|
||||
}
|
||||
source.ensureInvited(nil, guild.MXID, false, false)
|
||||
return meta
|
||||
}
|
||||
|
||||
func (guild *Guild) UpdateName(meta *discordgo.Guild) bool {
|
||||
name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{
|
||||
Name: meta.Name,
|
||||
})
|
||||
if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") {
|
||||
return false
|
||||
}
|
||||
guild.log.Debugfln("Updating name %q -> %q", guild.Name, name)
|
||||
guild.Name = name
|
||||
guild.PlainName = meta.Name
|
||||
guild.NameSet = false
|
||||
if guild.MXID != "" {
|
||||
_, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name)
|
||||
if err != nil {
|
||||
guild.log.Warnln("Failed to update room name: %s", err)
|
||||
} else {
|
||||
guild.NameSet = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (guild *Guild) UpdateAvatar(iconID string) bool {
|
||||
if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") {
|
||||
return false
|
||||
}
|
||||
guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID)
|
||||
guild.AvatarSet = false
|
||||
guild.Avatar = iconID
|
||||
guild.AvatarURL = id.ContentURI{}
|
||||
if guild.Avatar != "" {
|
||||
// TODO direct media support
|
||||
copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{
|
||||
AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID),
|
||||
})
|
||||
if err != nil {
|
||||
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
|
||||
return true
|
||||
}
|
||||
guild.AvatarURL = copied.MXC
|
||||
}
|
||||
if guild.MXID != "" {
|
||||
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
|
||||
if err != nil {
|
||||
guild.log.Warnln("Failed to update room avatar:", err)
|
||||
} else {
|
||||
guild.AvatarSet = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (guild *Guild) cleanup() {
|
||||
if guild.MXID == "" {
|
||||
return
|
||||
}
|
||||
intent := guild.bridge.Bot
|
||||
if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
|
||||
err := intent.BeeperDeleteRoom(guild.MXID)
|
||||
if err != nil && !errors.Is(err, mautrix.MNotFound) {
|
||||
guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
|
||||
}
|
||||
|
||||
func (guild *Guild) RemoveMXID() {
|
||||
guild.bridge.guildsLock.Lock()
|
||||
defer guild.bridge.guildsLock.Unlock()
|
||||
if guild.MXID == "" {
|
||||
return
|
||||
}
|
||||
delete(guild.bridge.guildsByMXID, guild.MXID)
|
||||
guild.MXID = ""
|
||||
guild.AvatarSet = false
|
||||
guild.NameSet = false
|
||||
guild.BridgingMode = database.GuildBridgeNothing
|
||||
guild.Update()
|
||||
}
|
||||
|
||||
func (guild *Guild) Delete() {
|
||||
guild.Guild.Delete()
|
||||
guild.bridge.guildsLock.Lock()
|
||||
delete(guild.bridge.guildsByID, guild.ID)
|
||||
if guild.MXID != "" {
|
||||
delete(guild.bridge.guildsByMXID, guild.MXID)
|
||||
}
|
||||
guild.bridge.guildsLock.Unlock()
|
||||
|
||||
}
|
||||
208
main.go
208
main.go
|
|
@ -1,208 +0,0 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"go.mau.fi/util/configupgrade"
|
||||
"go.mau.fi/util/exsync"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"maunium.net/go/mautrix/bridge"
|
||||
"maunium.net/go/mautrix/bridge/commands"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/config"
|
||||
"go.mau.fi/mautrix-discord/database"
|
||||
)
|
||||
|
||||
// Information to find out exactly which commit the bridge was built from.
|
||||
// These are filled at build time with the -X linker flag.
|
||||
var (
|
||||
Tag = "unknown"
|
||||
Commit = "unknown"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
//go:embed example-config.yaml
|
||||
var ExampleConfig string
|
||||
|
||||
type DiscordBridge struct {
|
||||
bridge.Bridge
|
||||
|
||||
Config *config.Config
|
||||
DB *database.Database
|
||||
|
||||
DMA *DirectMediaAPI
|
||||
provisioning *ProvisioningAPI
|
||||
|
||||
usersByMXID map[id.UserID]*User
|
||||
usersByID map[string]*User
|
||||
usersLock sync.Mutex
|
||||
|
||||
managementRooms map[id.RoomID]*User
|
||||
managementRoomsLock sync.Mutex
|
||||
|
||||
portalsByMXID map[id.RoomID]*Portal
|
||||
portalsByID map[database.PortalKey]*Portal
|
||||
portalsLock sync.Mutex
|
||||
|
||||
threadsByID map[string]*Thread
|
||||
threadsByRootMXID map[id.EventID]*Thread
|
||||
threadsByCreationNoticeMXID map[id.EventID]*Thread
|
||||
threadsLock sync.Mutex
|
||||
|
||||
guildsByMXID map[id.RoomID]*Guild
|
||||
guildsByID map[string]*Guild
|
||||
guildsLock sync.Mutex
|
||||
|
||||
puppets map[string]*Puppet
|
||||
puppetsByCustomMXID map[id.UserID]*Puppet
|
||||
puppetsLock sync.Mutex
|
||||
|
||||
attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
|
||||
parallelAttachmentSemaphore *semaphore.Weighted
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetExampleConfig() string {
|
||||
return ExampleConfig
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetConfigPtr() interface{} {
|
||||
br.Config = &config.Config{
|
||||
BaseConfig: &br.Bridge.Config,
|
||||
}
|
||||
br.Config.BaseConfig.Bridge = &br.Config.Bridge
|
||||
return br.Config
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) Init() {
|
||||
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
|
||||
br.RegisterCommands()
|
||||
br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
|
||||
|
||||
matrixHTMLParser.PillConverter = br.pillConverter
|
||||
|
||||
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
|
||||
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) Start() {
|
||||
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
|
||||
br.provisioning = newProvisioningAPI(br)
|
||||
}
|
||||
if br.Config.Bridge.PublicAddress != "" {
|
||||
br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet)
|
||||
}
|
||||
br.DMA = newDirectMediaAPI(br)
|
||||
br.WaitWebsocketConnected()
|
||||
go br.startUsers()
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) Stop() {
|
||||
for _, user := range br.usersByMXID {
|
||||
if user.Session == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
br.Log.Debugln("Disconnecting", user.MXID)
|
||||
user.Session.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
|
||||
p := br.GetPortalByMXID(mxid)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
|
||||
p := br.GetUserByMXID(mxid)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
|
||||
_, isGhost := br.ParsePuppetMXID(mxid)
|
||||
return isGhost
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
|
||||
p := br.GetPuppetByMXID(mxid)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
|
||||
//TODO implement
|
||||
}
|
||||
|
||||
func main() {
|
||||
br := &DiscordBridge{
|
||||
usersByMXID: make(map[id.UserID]*User),
|
||||
usersByID: make(map[string]*User),
|
||||
|
||||
managementRooms: make(map[id.RoomID]*User),
|
||||
|
||||
portalsByMXID: make(map[id.RoomID]*Portal),
|
||||
portalsByID: make(map[database.PortalKey]*Portal),
|
||||
|
||||
threadsByID: make(map[string]*Thread),
|
||||
threadsByRootMXID: make(map[id.EventID]*Thread),
|
||||
threadsByCreationNoticeMXID: make(map[id.EventID]*Thread),
|
||||
|
||||
guildsByID: make(map[string]*Guild),
|
||||
guildsByMXID: make(map[id.RoomID]*Guild),
|
||||
|
||||
puppets: make(map[string]*Puppet),
|
||||
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
|
||||
|
||||
attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
|
||||
parallelAttachmentSemaphore: semaphore.NewWeighted(3),
|
||||
}
|
||||
br.Bridge = bridge.Bridge{
|
||||
Name: "mautrix-discord",
|
||||
URL: "https://github.com/mautrix/discord",
|
||||
Description: "A Matrix-Discord puppeting bridge.",
|
||||
Version: "0.7.5",
|
||||
ProtocolName: "Discord",
|
||||
BeeperServiceName: "discordgo",
|
||||
BeeperNetworkName: "discord",
|
||||
|
||||
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
|
||||
|
||||
ConfigUpgrader: &configupgrade.StructUpgrader{
|
||||
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
|
||||
Blocks: config.SpacedBlocks,
|
||||
Base: ExampleConfig,
|
||||
},
|
||||
|
||||
Child: br,
|
||||
}
|
||||
br.InitVersion(Tag, Commit, BuildTime)
|
||||
|
||||
br.Main()
|
||||
}
|
||||
227
pkg/connector/backfill.go
Normal file
227
pkg/connector/backfill.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
var (
|
||||
_ bridgev2.BackfillingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.BackfillingNetworkAPIWithLimits = (*DiscordClient)(nil)
|
||||
)
|
||||
|
||||
func (d *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
|
||||
if !d.IsLoggedIn() {
|
||||
return nil, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
parentChannelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
var knownThreadRootID *networkid.MessageID
|
||||
|
||||
if fetchParams.ThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(fetchParams.ThreadRoot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if thread == nil {
|
||||
return &bridgev2.FetchMessagesResponse{
|
||||
Messages: nil,
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
channelID = threadChannelID
|
||||
threadRootID := fetchParams.ThreadRoot
|
||||
knownThreadRootID = &threadRootID
|
||||
}
|
||||
|
||||
guildID := fetchParams.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
refererOpt := makeDiscordReferer(guildID, parentChannelID, threadChannelID)
|
||||
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("action", "fetch messages").
|
||||
Str("channel_id", channelID).
|
||||
Str("thread_channel_id", threadChannelID).
|
||||
Int("desired_count", fetchParams.Count).
|
||||
Bool("forward", fetchParams.Forward).Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
var beforeID string
|
||||
var afterID string
|
||||
|
||||
if fetchParams.AnchorMessage != nil {
|
||||
anchorID := discordid.ParseMessageID(fetchParams.AnchorMessage.ID)
|
||||
|
||||
if fetchParams.Forward {
|
||||
afterID = anchorID
|
||||
} else {
|
||||
beforeID = anchorID
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelMessages returns messages ordered from newest to oldest.
|
||||
count := min(fetchParams.Count, 100)
|
||||
log.Debug().Msg("Fetching channel history for backfill")
|
||||
msgs, err := d.Session.ChannelMessages(channelID, count, beforeID, afterID, "", refererOpt)
|
||||
if err != nil {
|
||||
return nil, d.tryWrappingError(ctx, err)
|
||||
}
|
||||
|
||||
// Update our user cache with all of the users present in the response. This
|
||||
// indirectly makes `GetUserInfo` on `DiscordClient` return the information
|
||||
// we've fetched above.
|
||||
cachedDiscordUserIDs := d.userCache.UpdateWithMessages(msgs)
|
||||
|
||||
{
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("action", "update ghosts via fetched messages").
|
||||
Logger()
|
||||
ctx := log.WithContext(ctx)
|
||||
|
||||
// Update/create all of the ghosts for the users involved. This lets us
|
||||
// set a correct per-message profile on each message, even for users
|
||||
// that we've never seen until now.
|
||||
for _, discordUserID := range cachedDiscordUserIDs {
|
||||
|
||||
ghost, err := d.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID))
|
||||
if err != nil {
|
||||
log.Err(err).Str("ghost_id", discordUserID).
|
||||
Msg("Failed to get ghost associated with message")
|
||||
continue
|
||||
}
|
||||
ghost.UpdateInfoIfNecessary(ctx, d.UserLogin, bridgev2.RemoteEventMessage)
|
||||
}
|
||||
}
|
||||
|
||||
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
|
||||
provablyReadMessageCount := 0
|
||||
for _, msg := range msgs {
|
||||
parsedMsgID, _ := strconv.ParseInt(msg.ID, 10, 64)
|
||||
msgTs, _ := discordgo.SnowflakeTimestamp(msg.ID)
|
||||
|
||||
readState := d.readStateForID(msg.ChannelID)
|
||||
if readState != nil {
|
||||
lastAckedMsgID, _ := strconv.ParseInt(string(readState.LastMessageID), 10, 64)
|
||||
if lastAckedMsgID >= parsedMsgID {
|
||||
provablyReadMessageCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: For now, we aren't backfilling reactions. This is because:
|
||||
//
|
||||
// - Discord does not provide enough historical reaction data in the
|
||||
// response from the message history endpoint to construct valid
|
||||
// BackfillReactions.
|
||||
// - Fetching the reaction data would be prohibitively expensive for
|
||||
// messages with many reactions. Messages in large guilds can have
|
||||
// tens of thousands of reactions.
|
||||
// - Indicating aggregated child events[1] from BackfillMessage doesn't
|
||||
// seem possible due to how portal backfilling batching currently
|
||||
// works.
|
||||
//
|
||||
// [1]: https://spec.matrix.org/v1.16/client-server-api/#reference-relations
|
||||
//
|
||||
// It might be worth fetching the reaction data anyways if we observe
|
||||
// a small overall number of reactions.
|
||||
sender := d.makeEventSender(msg.Author)
|
||||
|
||||
// Use the ghost's intent, falling back to the bridge's.
|
||||
ghost, err := d.connector.Bridge.GetGhostByID(ctx, sender.Sender)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to look up ghost while converting backfilled message")
|
||||
}
|
||||
var intent bridgev2.MatrixAPI
|
||||
if ghost == nil {
|
||||
intent = fetchParams.Portal.Bridge.Bot
|
||||
} else {
|
||||
intent = ghost.Intent
|
||||
}
|
||||
|
||||
converted = append(converted, &bridgev2.BackfillMessage{
|
||||
ID: discordid.MakeMessageID(msg.ID),
|
||||
ConvertedMessage: d.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, d.UserLogin, d.Session, msg, knownThreadRootID),
|
||||
Sender: sender,
|
||||
Timestamp: msgTs,
|
||||
StreamOrder: parsedMsgID,
|
||||
})
|
||||
|
||||
if fetchParams.ThreadRoot == "" && msg.Flags&discordgo.MessageFlagsHasThread != 0 {
|
||||
latest := ""
|
||||
if msg.Thread != nil {
|
||||
latest = msg.Thread.LastMessageID
|
||||
}
|
||||
if latest == "" {
|
||||
latest = msg.ID
|
||||
}
|
||||
converted[len(converted)-1].ShouldBackfillThread = true
|
||||
converted[len(converted)-1].LastThreadMessage = discordid.MakeMessageID(latest)
|
||||
if err := d.upsertThreadInfoFromMessage(ctx, msg); err != nil {
|
||||
log.Err(err).Str("message_id", msg.ID).Msg("Failed to store thread info while backfilling")
|
||||
}
|
||||
}
|
||||
}
|
||||
// FetchMessagesResponse expects messages to always be ordered from oldest to newest.
|
||||
slices.Reverse(converted)
|
||||
|
||||
log.Debug().
|
||||
Int("converted_count", len(converted)).
|
||||
Int("provably_read_message_count", provablyReadMessageCount).
|
||||
Msg("Finished fetching and converting, returning backfill response")
|
||||
|
||||
// It doesn't seem like we can express unreadness for every message, so do
|
||||
// it for the entire batch. A single unread message makes the entire batch
|
||||
// unread, even if some messages were actually read.
|
||||
entireBatchWasProvablyRead := len(msgs) == provablyReadMessageCount
|
||||
return &bridgev2.FetchMessagesResponse{
|
||||
Messages: converted,
|
||||
Forward: fetchParams.Forward,
|
||||
MarkRead: entireBatchWasProvablyRead,
|
||||
// This might not actually be true if the channel's total number of messages is itself a multiple
|
||||
// of `count`, but that's probably okay.
|
||||
HasMore: len(msgs) == count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) GetBackfillMaxBatchCount(
|
||||
_ context.Context,
|
||||
portal *bridgev2.Portal,
|
||||
_ *database.BackfillTask,
|
||||
) int {
|
||||
backfillQueueConfig := d.connector.Bridge.Config.Backfill.Queue
|
||||
|
||||
switch portal.RoomType {
|
||||
case database.RoomTypeDM:
|
||||
return backfillQueueConfig.GetOverride("dm")
|
||||
case database.RoomTypeGroupDM:
|
||||
return backfillQueueConfig.GetOverride("group_dm")
|
||||
default:
|
||||
return backfillQueueConfig.GetOverride("channel")
|
||||
}
|
||||
}
|
||||
166
pkg/connector/capabilities.go
Normal file
166
pkg/connector/capabilities.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.mau.fi/util/ffmpeg"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
|
||||
Provisioning: bridgev2.ProvisioningCapabilities{
|
||||
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{},
|
||||
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{},
|
||||
},
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
|
||||
return DiscordGeneralCaps
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetBridgeInfoVersion() (info, caps int) {
|
||||
return 1, 4
|
||||
}
|
||||
|
||||
/*func supportedIfFFmpeg() event.CapabilitySupportLevel {
|
||||
if ffmpeg.Supported() {
|
||||
return event.CapLevelPartialSupport
|
||||
}
|
||||
return event.CapLevelRejected
|
||||
}*/
|
||||
|
||||
func capID() string {
|
||||
base := "fi.mau.discord.capabilities.2026_03_18"
|
||||
if ffmpeg.Supported() {
|
||||
return base + "+ffmpeg"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// TODO: This limit is increased depending on user subscription status (Discord Nitro).
|
||||
const MaxTextLength = 2000
|
||||
|
||||
// TODO: This limit is increased depending on user subscription status (Discord Nitro).
|
||||
// TODO: Verify this figure (10 MiB).
|
||||
const MaxFileSize = 10485760
|
||||
|
||||
var discordCaps = &event.RoomFeatures{
|
||||
ID: capID(),
|
||||
Reply: event.CapLevelFullySupported,
|
||||
Reaction: event.CapLevelFullySupported,
|
||||
Edit: event.CapLevelFullySupported,
|
||||
Delete: event.CapLevelFullySupported,
|
||||
Formatting: event.FormattingFeatureMap{
|
||||
event.FmtBold: event.CapLevelFullySupported,
|
||||
event.FmtItalic: event.CapLevelFullySupported,
|
||||
event.FmtStrikethrough: event.CapLevelFullySupported,
|
||||
event.FmtInlineCode: event.CapLevelFullySupported,
|
||||
event.FmtCodeBlock: event.CapLevelFullySupported,
|
||||
event.FmtSyntaxHighlighting: event.CapLevelFullySupported,
|
||||
event.FmtBlockquote: event.CapLevelFullySupported,
|
||||
event.FmtInlineLink: event.CapLevelFullySupported,
|
||||
event.FmtUserLink: event.CapLevelFullySupported,
|
||||
event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support.
|
||||
event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support.
|
||||
event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support.
|
||||
event.FmtUnorderedList: event.CapLevelFullySupported,
|
||||
event.FmtOrderedList: event.CapLevelFullySupported,
|
||||
event.FmtListStart: event.CapLevelFullySupported,
|
||||
event.FmtListJumpValue: event.CapLevelUnsupported,
|
||||
event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support.
|
||||
},
|
||||
File: event.FileFeatureMap{
|
||||
event.MsgImage: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"image/jpeg": event.CapLevelFullySupported,
|
||||
"image/png": event.CapLevelFullySupported,
|
||||
"image/gif": event.CapLevelFullySupported,
|
||||
"image/webp": event.CapLevelFullySupported,
|
||||
"image/*": event.CapLevelPartialSupport,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
event.MsgVideo: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"video/mp4": event.CapLevelFullySupported,
|
||||
"video/webm": event.CapLevelFullySupported,
|
||||
"video/*": event.CapLevelPartialSupport,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
event.MsgAudio: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"audio/mpeg": event.CapLevelFullySupported,
|
||||
"audio/webm": event.CapLevelFullySupported,
|
||||
"audio/wav": event.CapLevelFullySupported,
|
||||
"audio/*": event.CapLevelPartialSupport,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
event.CapMsgVoice: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"audio/ogg; codecs=opus": event.CapLevelFullySupported,
|
||||
"audio/ogg": event.CapLevelFullySupported,
|
||||
"audio/webm; codecs=opus": event.CapLevelFullySupported,
|
||||
"audio/webm": event.CapLevelFullySupported,
|
||||
"audio/*": event.CapLevelPartialSupport,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
event.MsgFile: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"*/*": event.CapLevelFullySupported,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
event.CapMsgGIF: {
|
||||
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||
"image/gif": event.CapLevelFullySupported,
|
||||
},
|
||||
Caption: event.CapLevelFullySupported,
|
||||
MaxCaptionLength: MaxTextLength,
|
||||
MaxSize: MaxFileSize,
|
||||
},
|
||||
},
|
||||
LocationMessage: event.CapLevelUnsupported,
|
||||
MaxTextLength: MaxTextLength,
|
||||
Thread: event.CapLevelPartialSupport,
|
||||
}
|
||||
|
||||
func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
|
||||
if portal.Metadata.(*discordid.PortalMetadata).GuildID == "" {
|
||||
caps := discordCaps.Clone()
|
||||
caps.Thread = event.CapLevelUnsupported
|
||||
return caps
|
||||
}
|
||||
return discordCaps
|
||||
}
|
||||
271
pkg/connector/chatinfo.go
Normal file
271
pkg/connector/chatinfo.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/ptr"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
// getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space.
|
||||
func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) {
|
||||
selfEvtSender := d.selfEventSender()
|
||||
|
||||
return &bridgev2.ChatInfo{
|
||||
Name: &guild.Name,
|
||||
Topic: nil,
|
||||
Members: &bridgev2.ChatMemberList{
|
||||
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
|
||||
selfEvtSender.Sender: {EventSender: selfEvtSender},
|
||||
},
|
||||
// As recommended by the spec, prohibit normal events by setting
|
||||
// events_default to a suitably high number.
|
||||
PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
|
||||
},
|
||||
Avatar: d.makeAvatarForGuild(guild),
|
||||
Type: ptr.Ptr(database.RoomTypeSpace),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func portalIsPrivate(p *bridgev2.Portal) bool {
|
||||
return p.RoomType == database.RoomTypeDM || p.RoomType == database.RoomTypeGroupDM
|
||||
}
|
||||
|
||||
func channelIsPrivate(ch *discordgo.Channel) bool {
|
||||
return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
|
||||
}
|
||||
|
||||
func readableChannelType(typ discordgo.ChannelType) (desc string) {
|
||||
desc = "other"
|
||||
|
||||
switch typ {
|
||||
case discordgo.ChannelTypeGuildText:
|
||||
desc = "guild text"
|
||||
case discordgo.ChannelTypeDM:
|
||||
desc = "dm"
|
||||
case discordgo.ChannelTypeGroupDM:
|
||||
desc = "group dm"
|
||||
case discordgo.ChannelTypeGuildPublicThread:
|
||||
desc = "public thread"
|
||||
case discordgo.ChannelTypeGuildPrivateThread:
|
||||
desc = "private thread"
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *DiscordClient) makeAvatarForChannel(ctx context.Context, ch *discordgo.Channel) *bridgev2.Avatar {
|
||||
if channelIsPrivate(ch) {
|
||||
return &bridgev2.Avatar{
|
||||
ID: discordid.MakeAvatarID(ch.Icon),
|
||||
Get: func(ctx context.Context) ([]byte, error) {
|
||||
url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
|
||||
return httpGet(ctx, d.httpClient, url, "channel/gdm icon")
|
||||
},
|
||||
Remove: ch.Icon == "",
|
||||
}
|
||||
} else {
|
||||
if !d.connector.Config.GuildAvatarsInRoomsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
guild, err := d.Session.State.Guild(ch.GuildID)
|
||||
|
||||
if err != nil || guild == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.makeAvatarForGuild(guild)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordClient) getPrivateChannelMemberList(ch *discordgo.Channel) bridgev2.ChatMemberList {
|
||||
var members bridgev2.ChatMemberList
|
||||
members.IsFull = true
|
||||
members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
|
||||
|
||||
if len(ch.Recipients) > 0 {
|
||||
selfEventSender := d.selfEventSender()
|
||||
|
||||
// Private channels' array of participants doesn't include ourselves,
|
||||
// so inject ourselves as a member.
|
||||
members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
|
||||
|
||||
for _, recipient := range ch.Recipients {
|
||||
sender := d.makeEventSender(recipient)
|
||||
members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
|
||||
}
|
||||
|
||||
members.TotalMemberCount = len(ch.Recipients)
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
func (d *DiscordClient) getChannelNameParams(ch *discordgo.Channel) *ChannelNameParams {
|
||||
params := &ChannelNameParams{
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
NSFW: ch.NSFW,
|
||||
IsDM: ch.Type == discordgo.ChannelTypeDM,
|
||||
IsGroupDM: ch.Type == discordgo.ChannelTypeGroupDM,
|
||||
IsCategory: ch.Type == discordgo.ChannelTypeGuildCategory,
|
||||
IsGuildChannel: ch.GuildID != "",
|
||||
}
|
||||
|
||||
if ch.ParentID != "" {
|
||||
parent, err := d.Session.State.Channel(ch.ParentID)
|
||||
if err == nil && parent != nil {
|
||||
params.ParentName = parent.Name
|
||||
}
|
||||
}
|
||||
|
||||
if ch.GuildID != "" {
|
||||
guild, err := d.Session.State.Guild(ch.GuildID)
|
||||
if err == nil && guild != nil {
|
||||
params.GuildName = guild.Name
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
func (d *DiscordClient) getChannelName(ch *discordgo.Channel) *string {
|
||||
if ch.Type == discordgo.ChannelTypeDM {
|
||||
// Respect friend nicknames.
|
||||
if len(ch.Recipients) > 0 {
|
||||
if rel := d.relationshipWithUserID(ch.Recipients[0].ID); rel != nil && rel.Nickname != "" {
|
||||
return &rel.Nickname
|
||||
}
|
||||
} else {
|
||||
// Impossible?
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
name := d.connector.Config.FormatChannelName(d.getChannelNameParams(ch))
|
||||
return &name
|
||||
}
|
||||
|
||||
// getChannelChatInfo computes [bridgev2.ChatInfo] for a guild channel or private (DM or group DM) channel.
|
||||
func (d *DiscordClient) getChannelChatInfo(ctx context.Context, ch *discordgo.Channel) (*bridgev2.ChatInfo, error) {
|
||||
var roomType database.RoomType
|
||||
switch ch.Type {
|
||||
case discordgo.ChannelTypeGuildCategory:
|
||||
roomType = database.RoomTypeSpace
|
||||
case discordgo.ChannelTypeDM:
|
||||
roomType = database.RoomTypeDM
|
||||
case discordgo.ChannelTypeGroupDM:
|
||||
roomType = database.RoomTypeGroupDM
|
||||
default:
|
||||
roomType = database.RoomTypeDefault
|
||||
}
|
||||
|
||||
var parentPortalID *networkid.PortalID
|
||||
if ch.Type == discordgo.ChannelTypeGuildCategory || (ch.ParentID == "" && ch.GuildID != "") {
|
||||
// Categories and uncategorized guild channels always have the guild as their parent.
|
||||
parentPortalID = ptr.Ptr(discordid.MakeGuildPortalIDWithID(ch.GuildID))
|
||||
} else if ch.ParentID != "" {
|
||||
// Categorized guild channels.
|
||||
parentPortalID = ptr.Ptr(discordid.MakeChannelPortalIDWithID(ch.ParentID))
|
||||
}
|
||||
|
||||
var memberList bridgev2.ChatMemberList
|
||||
if channelIsPrivate(ch) {
|
||||
memberList = d.getPrivateChannelMemberList(ch)
|
||||
} else {
|
||||
// TODO we're _always_ sending partial member lists for guilds; we can probably
|
||||
// do better than that
|
||||
selfEventSender := d.selfEventSender()
|
||||
|
||||
memberList = bridgev2.ChatMemberList{
|
||||
IsFull: false,
|
||||
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
|
||||
selfEventSender.Sender: {EventSender: selfEventSender},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &bridgev2.ChatInfo{
|
||||
Name: d.getChannelName(ch),
|
||||
Topic: &ch.Topic,
|
||||
Avatar: d.makeAvatarForChannel(ctx, ch),
|
||||
|
||||
Members: &memberList,
|
||||
|
||||
Type: &roomType,
|
||||
ParentID: parentPortalID,
|
||||
|
||||
UserLocal: &bridgev2.UserLocalPortalInfo{
|
||||
MutedUntil: ptr.Ptr(d.channelMutedUntil(ch.GuildID, ch.ID)),
|
||||
},
|
||||
CanBackfill: true,
|
||||
|
||||
ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
|
||||
meta := portal.Metadata.(*discordid.PortalMetadata)
|
||||
if meta.GuildID != ch.GuildID {
|
||||
meta.GuildID = ch.GuildID
|
||||
changed = true
|
||||
}
|
||||
if meta.ChannelType == nil || *meta.ChannelType != ch.Type {
|
||||
meta.ChannelType = ptr.Ptr(ch.Type)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||
if !d.IsLoggedIn() {
|
||||
return nil, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
guildID := discordid.ParseGuildPortalID(portal.ID)
|
||||
if guildID != "" {
|
||||
// Portal is a space representing a Discord guild.
|
||||
|
||||
guild, err := d.Session.State.Guild(guildID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't get guild: %w", err)
|
||||
}
|
||||
|
||||
return d.getGuildSpaceInfo(ctx, guild)
|
||||
} else {
|
||||
// Portal is to a channel of some kind (private or guild).
|
||||
channelID := discordid.ParseChannelPortalID(portal.ID)
|
||||
|
||||
ch, err := d.Session.State.Channel(channelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't get channel: %w", err)
|
||||
}
|
||||
|
||||
return d.getChannelChatInfo(ctx, ch)
|
||||
}
|
||||
}
|
||||
1065
pkg/connector/client.go
Normal file
1065
pkg/connector/client.go
Normal file
File diff suppressed because it is too large
Load diff
121
pkg/connector/config.go
Normal file
121
pkg/connector/config.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
up "go.mau.fi/util/configupgrade"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed example-config.yaml
|
||||
var ExampleConfig string
|
||||
|
||||
const defaultChannelNameTemplate = `{{if and .IsGuildChannel (not .IsCategory)}}#{{end}}{{.Name}}`
|
||||
|
||||
type Config struct {
|
||||
Guilds struct {
|
||||
BridgingGuildIDs []string `yaml:"bridging_guild_ids"`
|
||||
} `yaml:"guilds"`
|
||||
|
||||
// ChannelNameTemplate formats Matrix room names for Discord channels other
|
||||
// than 1:1 DMs, which intentionally use bridgev2's ghost-derived default.
|
||||
ChannelNameTemplate string `yaml:"channel_name_template"`
|
||||
CustomEmojiReactions *bool `yaml:"custom_emoji_reactions"`
|
||||
GuildAvatarsInRooms *bool `yaml:"guild_avatars_in_rooms"`
|
||||
|
||||
ForbidDMingStrangers *bool `yaml:"forbid_dming_strangers"`
|
||||
|
||||
LogWhenDroppingMessages bool `yaml:"log_when_dropping_messages"`
|
||||
|
||||
channelNameTemplate *template.Template `yaml:"-"`
|
||||
}
|
||||
|
||||
type umConfig Config
|
||||
|
||||
func (c *Config) UnmarshalYAML(node *yaml.Node) error {
|
||||
err := node.Decode((*umConfig)(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.ChannelNameTemplate == "" {
|
||||
c.ChannelNameTemplate = defaultChannelNameTemplate
|
||||
}
|
||||
|
||||
c.channelNameTemplate, err = template.New("channel_name").Parse(c.ChannelNameTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelNameParams describes the values available to [Config.FormatChannelName].
|
||||
//
|
||||
// It intentionally includes both the raw Discord channel type and convenience
|
||||
// booleans so templates can express v1-style naming rules without relying on
|
||||
// numeric channel type constants.
|
||||
type ChannelNameParams struct {
|
||||
Name string
|
||||
ParentName string
|
||||
GuildName string
|
||||
Type discordgo.ChannelType
|
||||
NSFW bool
|
||||
IsDM bool
|
||||
IsGroupDM bool
|
||||
IsCategory bool
|
||||
IsGuildChannel bool
|
||||
}
|
||||
|
||||
// FormatChannelName renders [Config.ChannelNameTemplate] for non-guild-space
|
||||
// channel portals. One-to-one DMs intentionally bypass this helper so bridgev2
|
||||
// can derive the room name from the other user's ghost.
|
||||
func (c *Config) FormatChannelName(params *ChannelNameParams) string {
|
||||
var buffer strings.Builder
|
||||
_ = c.channelNameTemplate.Execute(&buffer, params)
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
func (c Config) ForbidDMingStrangersEnabled() bool {
|
||||
return c.ForbidDMingStrangers == nil || *c.ForbidDMingStrangers
|
||||
}
|
||||
|
||||
func (c Config) CustomEmojiReactionsEnabled() bool {
|
||||
return c.CustomEmojiReactions == nil || *c.CustomEmojiReactions
|
||||
}
|
||||
|
||||
func (c Config) GuildAvatarsInRoomsEnabled() bool {
|
||||
return c.GuildAvatarsInRooms != nil && *c.GuildAvatarsInRooms
|
||||
}
|
||||
|
||||
func upgradeConfig(helper up.Helper) {
|
||||
helper.Copy(up.List, "guilds", "bridging_guild_ids")
|
||||
helper.Copy(up.Bool, "guilds", "guild_avatars_in_rooms")
|
||||
helper.Copy(up.Bool, "forbid_dming_strangers")
|
||||
helper.Copy(up.Str, "channel_name_template")
|
||||
helper.Copy(up.Bool, "custom_emoji_reactions")
|
||||
helper.Copy(up.Bool, "log_when_dropping_messages")
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) {
|
||||
return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig)
|
||||
}
|
||||
95
pkg/connector/connector.go
Normal file
95
pkg/connector/connector.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/connector/discorddb"
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
"go.mau.fi/mautrix-discord/pkg/msgconv"
|
||||
)
|
||||
|
||||
type DiscordConnector struct {
|
||||
Bridge *bridgev2.Bridge
|
||||
Config Config
|
||||
DB *discorddb.DiscordDB
|
||||
MsgConv *msgconv.MessageConverter
|
||||
attachmentCache *attachmentCache
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
|
||||
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
|
||||
_ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
|
||||
)
|
||||
|
||||
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
|
||||
d.Bridge = bridge
|
||||
d.DB = discorddb.New(bridge.DB.Database, bridge.Log.With().Str("db_section", "discord").Logger())
|
||||
d.MsgConv = msgconv.NewMessageConverter(bridge)
|
||||
d.attachmentCache = NewAttachmentCache()
|
||||
d.MsgConv.CacheDirectMediaAttachment = d.attachmentCache.Insert
|
||||
d.httpClient = d.Bridge.GetHTTPClientSettings().Compile()
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
|
||||
d.MsgConv.MaxFileSize = maxSize
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) Start(ctx context.Context) error {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
err := d.DB.Upgrade(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to upgrade Discord database")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msg("Setting up provisioning API")
|
||||
|
||||
err = d.setUpProvisioningAPIs()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to set up provisioning API, proceeding")
|
||||
// Don't treat this error as fatal.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetName() bridgev2.BridgeName {
|
||||
return bridgev2.BridgeName{
|
||||
DisplayName: "Discord",
|
||||
NetworkURL: "https://discord.com",
|
||||
NetworkIcon: "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC",
|
||||
NetworkID: "discord",
|
||||
BeeperBridgeType: "discordgo",
|
||||
DefaultPort: 29334,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
|
||||
return networkid.RawTransactionID(discordid.GenerateNonce())
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
|
|
@ -14,22 +14,21 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
package connector
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
*bridgeconfig.BaseConfig `yaml:",inline"`
|
||||
|
||||
Bridge BridgeConfig `yaml:"bridge"`
|
||||
}
|
||||
|
||||
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
||||
_, homeserver, _ := userID.Parse()
|
||||
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
|
||||
|
||||
return hasSecret
|
||||
func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes {
|
||||
return database.MetaTypes{
|
||||
Portal: func() any {
|
||||
return &discordid.PortalMetadata{}
|
||||
},
|
||||
UserLogin: func() any {
|
||||
return &discordid.UserLoginMetadata{}
|
||||
},
|
||||
}
|
||||
}
|
||||
205
pkg/connector/directmedia.go
Normal file
205
pkg/connector/directmedia.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/mediaproxy"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
var (
|
||||
_ bridgev2.DirectMediableNetwork = (*DiscordConnector)(nil)
|
||||
)
|
||||
|
||||
func (d *DiscordConnector) Download(
|
||||
ctx context.Context,
|
||||
mediaID networkid.MediaID,
|
||||
params map[string]string,
|
||||
) (mediaproxy.GetMediaResponse, error) {
|
||||
info, err := discordid.ParseMediaID(mediaID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse media id for download: %w", err)
|
||||
}
|
||||
|
||||
return d.downloadAttachment(ctx, info)
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) SetUseDirectMedia() {
|
||||
d.MsgConv.DirectMedia = true
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) downloadAttachment(
|
||||
ctx context.Context,
|
||||
info *discordid.MediaInfo,
|
||||
) (*mediaproxy.GetMediaResponseURL, error) {
|
||||
url, expiresAt, err := d.resolveAttachmentURL(ctx, info)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh attachment url for download: %w", err)
|
||||
}
|
||||
if expiresAt.IsZero() {
|
||||
// A zero expiry becomes effectively immutable caching in mediaproxy.
|
||||
// Unknown expiry is safer as no-store for now.
|
||||
expiresAt = time.Now()
|
||||
}
|
||||
return &mediaproxy.GetMediaResponseURL{
|
||||
URL: url,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) resolveAttachmentURL(ctx context.Context, info *discordid.MediaInfo) (url string, expires time.Time, err error) {
|
||||
if entry, ok := d.attachmentCache.Get(info.MediaInfoV1); ok {
|
||||
return entry.URL, entry.Expiry, nil
|
||||
}
|
||||
|
||||
url, expiresAt, err := d.refreshAttachmentURL(ctx, info)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
d.attachmentCache.Insert(info, url)
|
||||
return url, expiresAt, nil
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) refreshAttachmentURL(
|
||||
ctx context.Context,
|
||||
info *discordid.MediaInfo,
|
||||
) (url string, expires time.Time, err error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("action", "refresh attachment url").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
login, err := d.Bridge.GetExistingUserLoginByID(ctx, info.UserLoginID)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
} else if login == nil {
|
||||
return "", time.Time{}, mautrix.MNotFound.WithMessage("Direct media login not found")
|
||||
}
|
||||
|
||||
client, ok := login.Client.(*DiscordClient)
|
||||
if !ok || client == nil || !client.IsLoggedIn() {
|
||||
return "", time.Time{}, mautrix.MNotFound.WithMessage("Direct media login is not connected")
|
||||
}
|
||||
|
||||
channelID := info.ChannelID
|
||||
messageID := info.MessageID
|
||||
attachmentID := info.AttachmentID
|
||||
|
||||
parentChannelID := channelID
|
||||
threadChannelID := ""
|
||||
threadInfo, err := d.DB.Thread.GetByThreadChannelID(ctx, string(info.UserLoginID), channelID)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to query thread info: %w", err)
|
||||
} else if threadInfo != nil {
|
||||
parentChannelID = threadInfo.ParentChannelID
|
||||
threadChannelID = threadInfo.ThreadChannelID
|
||||
}
|
||||
|
||||
var requestOptions []discordgo.RequestOption
|
||||
portalKey := discordid.MakeChannelPortalKey(parentChannelID, info.UserLoginID, d.Bridge.Config.SplitPortals)
|
||||
portal, err := d.Bridge.GetExistingPortalByKey(ctx, portalKey)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to query portal for direct media: %w", err)
|
||||
} else if portal != nil {
|
||||
if meta, ok := portal.Metadata.(*discordid.PortalMetadata); ok {
|
||||
requestOptions = append(requestOptions, makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID))
|
||||
}
|
||||
} else if threadChannelID == "" {
|
||||
// DMs still benefit from @me referers.
|
||||
requestOptions = append(requestOptions, makeDiscordReferer("", parentChannelID, ""))
|
||||
}
|
||||
|
||||
var messages []*discordgo.Message
|
||||
if client.Session.IsUser {
|
||||
messages, err = client.Session.ChannelMessages(channelID, 5, "", "", messageID, requestOptions...)
|
||||
} else {
|
||||
var msg *discordgo.Message
|
||||
msg, err = client.Session.ChannelMessage(channelID, messageID, requestOptions...)
|
||||
if err == nil && msg != nil {
|
||||
messages = []*discordgo.Message{msg}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to fetch direct media message: %w", err)
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
for _, att := range msg.Attachments {
|
||||
if att.ID == attachmentID {
|
||||
expiresAt := normalizeAttachmentExpiry(parseAttachmentExpiryFromURL(att.URL))
|
||||
// (Trace is not the default log level, so this is only visible
|
||||
// in development scenarios.)
|
||||
log.Trace().
|
||||
Str("channel_id", channelID).
|
||||
Str("message_id", messageID).
|
||||
Str("attachment_id", attachmentID).
|
||||
Time("expires_at", expiresAt).
|
||||
Msg("Resolved direct media attachment URL")
|
||||
// TODO(skip): This is ignoring the rest of the attachments.
|
||||
return att.URL, expiresAt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", time.Time{}, mautrix.MNotFound.WithMessage("Attachment not found in message")
|
||||
}
|
||||
|
||||
func parseAttachmentExpiryParam(ex string) time.Time {
|
||||
tsBytes, err := hex.DecodeString(ex)
|
||||
if err != nil || len(tsBytes) != 4 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
parsedTS := int64(binary.BigEndian.Uint32(tsBytes))
|
||||
now := time.Now()
|
||||
expiry := time.Unix(parsedTS, 0)
|
||||
if expiry.Before(now) || expiry.After(now.Add(365*24*time.Hour)) {
|
||||
// Looks to be invalid.
|
||||
return time.Time{}
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
func parseAttachmentExpiryFromURL(rawURL string) time.Time {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return parseAttachmentExpiryParam(parsedURL.Query().Get("ex"))
|
||||
}
|
||||
|
||||
func normalizeAttachmentExpiry(expiry time.Time) time.Time {
|
||||
// Default to a validity period of 24 hours.
|
||||
if expiry.IsZero() {
|
||||
return time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
return expiry
|
||||
}
|
||||
91
pkg/connector/directmedia_cache.go
Normal file
91
pkg/connector/directmedia_cache.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
const attachmentCacheLife = 5 * time.Minute
|
||||
|
||||
type attachmentCacheEntry struct {
|
||||
Expiry time.Time
|
||||
URL string
|
||||
}
|
||||
|
||||
func (ce *attachmentCacheEntry) IsExpired() bool {
|
||||
return time.Until(ce.Expiry) <= attachmentCacheLife
|
||||
}
|
||||
|
||||
// attachmentCache tracks expiring attachment URLs from Discord. An
|
||||
// attachmentCache is safe for concurrent use by multiple goroutines.
|
||||
type attachmentCache struct {
|
||||
sync.RWMutex
|
||||
cache map[discordid.MediaInfoV1]attachmentCacheEntry
|
||||
}
|
||||
|
||||
// TODO(skip): The cache grows in an unbounded fashion.
|
||||
|
||||
func NewAttachmentCache() *attachmentCache {
|
||||
return &attachmentCache{
|
||||
cache: make(map[discordid.MediaInfoV1]attachmentCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *attachmentCache) Get(key discordid.MediaInfoV1) (*attachmentCacheEntry, bool) {
|
||||
ac.Lock()
|
||||
defer ac.Unlock()
|
||||
|
||||
cached, ok := ac.cache[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if cached.IsExpired() {
|
||||
delete(ac.cache, key)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &cached, true
|
||||
}
|
||||
|
||||
func (ac *attachmentCache) Insert(info *discordid.MediaInfo, url string) {
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
|
||||
expiry := normalizeAttachmentExpiry(parseAttachmentExpiryFromURL(url))
|
||||
|
||||
ac.Lock()
|
||||
defer ac.Unlock()
|
||||
|
||||
key := info.MediaInfoV1
|
||||
entry := attachmentCacheEntry{
|
||||
URL: url,
|
||||
Expiry: expiry,
|
||||
}
|
||||
|
||||
if expiry.IsZero() || entry.IsExpired() {
|
||||
delete(ac.cache, key)
|
||||
return
|
||||
}
|
||||
|
||||
ac.cache[key] = entry
|
||||
}
|
||||
21
pkg/connector/directmedia_test.go
Normal file
21
pkg/connector/directmedia_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseAttachmentExpiryParam(t *testing.T) {
|
||||
losAngeles, err := time.LoadLocation("America/Los_Angeles")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test timezone: %v", err)
|
||||
}
|
||||
|
||||
expiry := parseAttachmentExpiryParam("69be6214").In(losAngeles)
|
||||
got := expiry.String()
|
||||
want := "2026-03-21 02:17:08 -0700 PDT"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("unexpected parsed expiry: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
52
pkg/connector/discorddb/00-latest-schema.sql
Normal file
52
pkg/connector/discorddb/00-latest-schema.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- v0 -> v3 (compatible with v1+): latest schema
|
||||
|
||||
-- https://docs.discord.com/developers/resources/emoji#emoji-object
|
||||
CREATE TABLE custom_emoji (
|
||||
discord_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
animated BOOLEAN NOT NULL,
|
||||
|
||||
mxc TEXT,
|
||||
|
||||
PRIMARY KEY (discord_id)
|
||||
);
|
||||
CREATE INDEX custom_emoji_mxc_idx ON custom_emoji (mxc);
|
||||
|
||||
CREATE TABLE role (
|
||||
discord_guild_id TEXT NOT NULL,
|
||||
discord_id TEXT NOT NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
|
||||
mentionable BOOLEAN NOT NULL,
|
||||
managed BOOLEAN NOT NULL,
|
||||
hoist BOOLEAN NOT NULL,
|
||||
|
||||
color INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
permissions BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (discord_guild_id, discord_id)
|
||||
);
|
||||
|
||||
CREATE TABLE discord_thread (
|
||||
-- The ID of the UserLogin that witnessed the thread.
|
||||
user_login_id TEXT NOT NULL,
|
||||
|
||||
-- The ID of the thread itself. For public threads, this exactly matches the
|
||||
-- ID of the message that the thread originates from.
|
||||
thread_channel_id TEXT NOT NULL,
|
||||
|
||||
-- The ID of the thread's "root" message. For public threads, this will
|
||||
-- match `id` and therefore the message that the thread originates from.
|
||||
-- For private threads, this will be NULL.
|
||||
root_message_id TEXT,
|
||||
|
||||
-- The Discord channel ID that the thread belongs to.
|
||||
parent_channel_id TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_login_id, thread_channel_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX discord_thread_user_login_root_msg_uidx
|
||||
ON discord_thread (user_login_id, root_message_id);
|
||||
19
pkg/connector/discorddb/02-roles.sql
Normal file
19
pkg/connector/discorddb/02-roles.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- v1 -> v2 (compatible with v1+): roles
|
||||
|
||||
CREATE TABLE role (
|
||||
discord_guild_id TEXT NOT NULL,
|
||||
discord_id TEXT NOT NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
|
||||
mentionable BOOLEAN NOT NULL,
|
||||
managed BOOLEAN NOT NULL,
|
||||
hoist BOOLEAN NOT NULL,
|
||||
|
||||
color INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
permissions BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (discord_guild_id, discord_id)
|
||||
);
|
||||
22
pkg/connector/discorddb/03-threads.sql
Normal file
22
pkg/connector/discorddb/03-threads.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- v2 -> v3 (compatible with v1+): threads
|
||||
|
||||
CREATE TABLE discord_thread (
|
||||
-- The ID of the UserLogin that witnessed the thread.
|
||||
user_login_id TEXT NOT NULL,
|
||||
|
||||
-- The ID of the thread itself. For public threads, this exactly matches the
|
||||
-- ID of the message that the thread originates from.
|
||||
thread_channel_id TEXT NOT NULL,
|
||||
|
||||
-- The ID of the thread's "root" message. For public threads, this will
|
||||
-- match `id` and therefore the message that the thread originates from.
|
||||
-- For private threads, this will be NULL.
|
||||
root_message_id TEXT,
|
||||
|
||||
-- The Discord channel ID that the thread belongs to.
|
||||
parent_channel_id TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_login_id, thread_channel_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX discord_thread_user_login_root_msg_uidx
|
||||
ON discord_thread (user_login_id, root_message_id);
|
||||
60
pkg/connector/discorddb/database.go
Normal file
60
pkg/connector/discorddb/database.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package discorddb
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/dbutil"
|
||||
)
|
||||
|
||||
type DiscordDB struct {
|
||||
*dbutil.Database
|
||||
CustomEmoji *CustomEmojiQuery
|
||||
Role *RoleQuery
|
||||
Thread *ThreadQuery
|
||||
}
|
||||
|
||||
var table dbutil.UpgradeTable
|
||||
|
||||
//go:embed *.sql
|
||||
var upgrades embed.FS
|
||||
|
||||
func init() {
|
||||
table.RegisterFS(upgrades)
|
||||
}
|
||||
|
||||
func UpgradeTable() dbutil.UpgradeTable {
|
||||
return table
|
||||
}
|
||||
|
||||
func New(db *dbutil.Database, log zerolog.Logger) *DiscordDB {
|
||||
db = db.Child("discord_version", table, dbutil.ZeroLogger(log))
|
||||
return &DiscordDB{
|
||||
Database: db,
|
||||
CustomEmoji: &CustomEmojiQuery{
|
||||
QueryHelper: dbutil.MakeQueryHelper(db, newCustomEmoji),
|
||||
},
|
||||
Role: &RoleQuery{
|
||||
QueryHelper: dbutil.MakeQueryHelper(db, newRole),
|
||||
},
|
||||
Thread: &ThreadQuery{
|
||||
QueryHelper: dbutil.MakeQueryHelper(db, newThread),
|
||||
},
|
||||
}
|
||||
}
|
||||
81
pkg/connector/discorddb/emoji.go
Normal file
81
pkg/connector/discorddb/emoji.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package discorddb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type CustomEmojiQuery struct {
|
||||
*dbutil.QueryHelper[*CustomEmoji]
|
||||
}
|
||||
|
||||
type CustomEmoji struct {
|
||||
ID string
|
||||
Name string
|
||||
Animated bool
|
||||
ImageMXC id.ContentURIString
|
||||
}
|
||||
|
||||
func (ce *CustomEmoji) sqlVariables() []any {
|
||||
return []any{ce.ID, ce.Name, ce.Animated, dbutil.StrPtr(ce.ImageMXC)}
|
||||
}
|
||||
|
||||
func newCustomEmoji(_ *dbutil.QueryHelper[*CustomEmoji]) *CustomEmoji {
|
||||
return &CustomEmoji{}
|
||||
}
|
||||
|
||||
const (
|
||||
getCustomEmojiByMXCQuery = `
|
||||
SELECT discord_id, name, animated, mxc FROM custom_emoji WHERE mxc=$1 ORDER BY name
|
||||
`
|
||||
getCustomEmojiByDiscordIDQuery = `
|
||||
SELECT discord_id, name, animated, mxc FROM custom_emoji WHERE discord_id=$1 ORDER BY name
|
||||
`
|
||||
upsertCustomEmojiQuery = `
|
||||
INSERT INTO custom_emoji (discord_id, name, animated, mxc)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (discord_id) DO UPDATE
|
||||
SET name = excluded.name, animated = excluded.animated, mxc = excluded.mxc
|
||||
`
|
||||
)
|
||||
|
||||
func (ceq *CustomEmojiQuery) GetByDiscordID(ctx context.Context, discordID string) (*CustomEmoji, error) {
|
||||
return ceq.QueryOne(ctx, getCustomEmojiByDiscordIDQuery, &discordID)
|
||||
}
|
||||
|
||||
func (ceq *CustomEmojiQuery) GetByMXC(ctx context.Context, mxc string) (*CustomEmoji, error) {
|
||||
return ceq.QueryOne(ctx, getCustomEmojiByMXCQuery, &mxc)
|
||||
}
|
||||
|
||||
func (ceq *CustomEmojiQuery) Put(ctx context.Context, emoji *CustomEmoji) error {
|
||||
return ceq.Exec(ctx, upsertCustomEmojiQuery, emoji.sqlVariables()...)
|
||||
}
|
||||
|
||||
func (ce *CustomEmoji) Scan(row dbutil.Scannable) (*CustomEmoji, error) {
|
||||
var imageURL sql.NullString
|
||||
err := row.Scan(&ce.ID, &ce.Name, &ce.Animated, &imageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ce.ImageMXC = id.ContentURIString(imageURL.String)
|
||||
return ce, nil
|
||||
}
|
||||
151
pkg/connector/discorddb/role.go
Normal file
151
pkg/connector/discorddb/role.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package discorddb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"go.mau.fi/util/dbutil"
|
||||
)
|
||||
|
||||
type RoleQuery struct {
|
||||
*dbutil.QueryHelper[*Role]
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
GuildID string
|
||||
discordgo.Role
|
||||
}
|
||||
|
||||
func (r *Role) sqlVariables() []any {
|
||||
return []any{
|
||||
r.GuildID,
|
||||
r.ID,
|
||||
r.Name,
|
||||
dbutil.StrPtr(r.Icon),
|
||||
r.Mentionable,
|
||||
r.Managed,
|
||||
r.Hoist,
|
||||
r.Color,
|
||||
r.Position,
|
||||
r.Permissions,
|
||||
}
|
||||
}
|
||||
|
||||
func newRole(_ *dbutil.QueryHelper[*Role]) *Role {
|
||||
return &Role{}
|
||||
}
|
||||
|
||||
const (
|
||||
getRoleByIDQuery = `
|
||||
SELECT discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions
|
||||
FROM role
|
||||
WHERE discord_guild_id=$1 AND discord_id=$2
|
||||
`
|
||||
getRolesByGuildIDQuery = `
|
||||
SELECT discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions
|
||||
FROM role
|
||||
WHERE discord_guild_id=$1
|
||||
ORDER BY position DESC, discord_id
|
||||
`
|
||||
upsertRoleQuery = `
|
||||
INSERT INTO role (discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (discord_guild_id, discord_id) DO UPDATE
|
||||
SET name = excluded.name,
|
||||
icon = excluded.icon,
|
||||
mentionable = excluded.mentionable,
|
||||
managed = excluded.managed,
|
||||
hoist = excluded.hoist,
|
||||
color = excluded.color,
|
||||
position = excluded.position,
|
||||
permissions = excluded.permissions
|
||||
`
|
||||
deleteRolesByGuildIDQuery = `
|
||||
DELETE FROM role WHERE discord_guild_id=$1
|
||||
`
|
||||
deleteRoleByIDQuery = `
|
||||
DELETE FROM role WHERE discord_guild_id=$1 AND discord_id=$2
|
||||
`
|
||||
)
|
||||
|
||||
func (rq *RoleQuery) GetByID(ctx context.Context, guildID, roleID string) (*Role, error) {
|
||||
return rq.QueryOne(ctx, getRoleByIDQuery, &guildID, &roleID)
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) GetByGuildID(ctx context.Context, guildID string) ([]*Role, error) {
|
||||
return rq.QueryMany(ctx, getRolesByGuildIDQuery, &guildID)
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) Put(ctx context.Context, role *Role) error {
|
||||
return rq.Exec(ctx, upsertRoleQuery, role.sqlVariables()...)
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) PutMany(ctx context.Context, roles []*Role) error {
|
||||
for _, role := range roles {
|
||||
if err := rq.Put(ctx, role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) DeleteByGuildID(ctx context.Context, guildID string) error {
|
||||
return rq.Exec(ctx, deleteRolesByGuildIDQuery, &guildID)
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) DeleteByID(ctx context.Context, guildID, roleID string) error {
|
||||
return rq.Exec(ctx, deleteRoleByIDQuery, &guildID, &roleID)
|
||||
}
|
||||
|
||||
func (rq *RoleQuery) ReplaceGuildRoles(ctx context.Context, guildID string, roles []*Role) error {
|
||||
return rq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||
if err := rq.DeleteByGuildID(ctx, guildID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
role.GuildID = guildID
|
||||
if err := rq.Put(ctx, role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Role) Scan(row dbutil.Scannable) (*Role, error) {
|
||||
var icon sql.NullString
|
||||
err := row.Scan(
|
||||
&r.GuildID,
|
||||
&r.ID,
|
||||
&r.Name,
|
||||
&icon,
|
||||
&r.Mentionable,
|
||||
&r.Managed,
|
||||
&r.Hoist,
|
||||
&r.Color,
|
||||
&r.Position,
|
||||
&r.Permissions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Icon = icon.String
|
||||
return r, nil
|
||||
}
|
||||
106
pkg/connector/discorddb/thread.go
Normal file
106
pkg/connector/discorddb/thread.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package discorddb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
)
|
||||
|
||||
type ThreadQuery struct {
|
||||
*dbutil.QueryHelper[*Thread]
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
UserLoginID string
|
||||
ThreadChannelID string
|
||||
RootMessageID string
|
||||
ParentChannelID string
|
||||
}
|
||||
|
||||
func (t *Thread) sqlVariables() []any {
|
||||
var rootMsgID *string
|
||||
if t.RootMessageID != "" {
|
||||
rootMsgID = &t.RootMessageID
|
||||
}
|
||||
return []any{
|
||||
t.UserLoginID,
|
||||
t.ThreadChannelID,
|
||||
rootMsgID,
|
||||
t.ParentChannelID,
|
||||
}
|
||||
}
|
||||
|
||||
func newThread(_ *dbutil.QueryHelper[*Thread]) *Thread {
|
||||
return &Thread{}
|
||||
}
|
||||
|
||||
const (
|
||||
getThreadByChannelIDQuery = `
|
||||
SELECT user_login_id, thread_channel_id, root_message_id, parent_channel_id
|
||||
FROM discord_thread
|
||||
WHERE user_login_id=$1 AND thread_channel_id=$2
|
||||
`
|
||||
getThreadByRootMessageIDQuery = `
|
||||
SELECT user_login_id, thread_channel_id, root_message_id, parent_channel_id
|
||||
FROM discord_thread
|
||||
WHERE user_login_id=$1 AND root_message_id=$2
|
||||
`
|
||||
upsertThreadQuery = `
|
||||
INSERT INTO discord_thread (user_login_id, thread_channel_id, root_message_id, parent_channel_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
|
||||
SET root_message_id = excluded.root_message_id,
|
||||
parent_channel_id = excluded.parent_channel_id
|
||||
`
|
||||
deleteThreadByChannelIDQuery = `
|
||||
DELETE FROM discord_thread WHERE user_login_id=$1 AND thread_channel_id=$2
|
||||
`
|
||||
)
|
||||
|
||||
func (tq *ThreadQuery) GetByThreadChannelID(ctx context.Context, userLoginID, threadChannelID string) (*Thread, error) {
|
||||
return tq.QueryOne(ctx, getThreadByChannelIDQuery, &userLoginID, &threadChannelID)
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) GetByRootMessageID(ctx context.Context, userLoginID, rootMessageID string) (*Thread, error) {
|
||||
return tq.QueryOne(ctx, getThreadByRootMessageIDQuery, &userLoginID, &rootMessageID)
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) Put(ctx context.Context, thread *Thread) error {
|
||||
return tq.Exec(ctx, upsertThreadQuery, thread.sqlVariables()...)
|
||||
}
|
||||
|
||||
func (tq *ThreadQuery) DeleteByThreadChannelID(ctx context.Context, userLoginID, threadChannelID string) error {
|
||||
return tq.Exec(ctx, deleteThreadByChannelIDQuery, &userLoginID, &threadChannelID)
|
||||
}
|
||||
|
||||
func (t *Thread) Scan(row dbutil.Scannable) (*Thread, error) {
|
||||
var rootMsgID sql.NullString
|
||||
err := row.Scan(
|
||||
&t.UserLoginID,
|
||||
&t.ThreadChannelID,
|
||||
&rootMsgID,
|
||||
&t.ParentChannelID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.RootMessageID = rootMsgID.String
|
||||
return t, nil
|
||||
}
|
||||
106
pkg/connector/emoji.go
Normal file
106
pkg/connector/emoji.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/connector/discorddb"
|
||||
)
|
||||
|
||||
func (d *DiscordConnector) getCustomEmojiDownloadURL(emojiID string, animated bool) (string, string) {
|
||||
// TODO probably best to leverage http.DetectContentType instead of
|
||||
// assuming the media type
|
||||
if animated {
|
||||
return discordgo.EndpointEmojiAnimated(emojiID), "image/webp"
|
||||
}
|
||||
// TODO think about using webp for size savings
|
||||
return discordgo.EndpointEmoji(emojiID), "image/png"
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetCustomEmojiByMXC(ctx context.Context, mxc string) (*discorddb.CustomEmoji, error) {
|
||||
return d.DB.CustomEmoji.GetByMXC(ctx, mxc)
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) GetCustomEmojiMXC(ctx context.Context, emojiID, name string, animated bool) (id.ContentURIString, error) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("action", "get discord custom emoji").
|
||||
Str("emoji_id", emojiID).
|
||||
Str("emoji_name", name).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
dbEmoji, err := d.DB.CustomEmoji.GetByDiscordID(ctx, emojiID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get custom emoji from database: %w", err)
|
||||
}
|
||||
|
||||
if dbEmoji != nil && dbEmoji.ImageMXC != "" {
|
||||
if dbEmoji.Name != name || dbEmoji.Animated != animated {
|
||||
// Make sure to save changed information.
|
||||
dbEmoji.Name = name
|
||||
dbEmoji.Animated = animated
|
||||
|
||||
err = d.DB.CustomEmoji.Put(ctx, dbEmoji)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to update custom emoji metadata in database")
|
||||
}
|
||||
}
|
||||
|
||||
return dbEmoji.ImageMXC, nil
|
||||
}
|
||||
|
||||
// Custom emoji wasn't in the database or it lacked an MXC, so we have to
|
||||
// download it.
|
||||
|
||||
emojiURL, mimeType := d.getCustomEmojiDownloadURL(emojiID, animated)
|
||||
data, err := httpGet(ctx, d.httpClient, emojiURL, "emoji")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mxc, _, err := d.Bridge.Bot.UploadMedia(ctx, "", data, "", mimeType)
|
||||
|
||||
log = log.With().Str("image_mxc", string(mxc)).Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload emoji to Matrix: %w", err)
|
||||
}
|
||||
|
||||
if dbEmoji == nil {
|
||||
dbEmoji = &discorddb.CustomEmoji{
|
||||
ID: emojiID,
|
||||
}
|
||||
}
|
||||
|
||||
dbEmoji.Name = name
|
||||
dbEmoji.Animated = animated
|
||||
dbEmoji.ImageMXC = mxc
|
||||
|
||||
err = d.DB.CustomEmoji.Put(ctx, dbEmoji)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to save custom emoji")
|
||||
}
|
||||
|
||||
return mxc, nil
|
||||
}
|
||||
113
pkg/connector/events_chat_resync.go
Normal file
113
pkg/connector/events_chat_resync.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
type DiscordChatResync struct {
|
||||
Client *DiscordClient
|
||||
channel *discordgo.Channel
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
|
||||
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
|
||||
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
|
||||
)
|
||||
|
||||
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
|
||||
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
|
||||
return c
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
|
||||
ch := d.channel
|
||||
return d.Client.portalKeyForChannel(ch)
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
|
||||
return bridgev2.EventSender{}
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
|
||||
return bridgev2.RemoteEventChatResync
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||
return d.Client.GetChatInfo(ctx, portal)
|
||||
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) ShouldCreatePortal() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// compareMessageIDs compares two Discord message IDs.
|
||||
//
|
||||
// If the first ID is lower, -1 is returned.
|
||||
// If the second ID is lower, 1 is returned.
|
||||
// If the IDs are equal, 0 is returned.
|
||||
func compareMessageIDs(id1, id2 string) int {
|
||||
if id1 == id2 {
|
||||
return 0
|
||||
}
|
||||
if len(id1) < len(id2) {
|
||||
return -1
|
||||
} else if len(id2) < len(id1) {
|
||||
return 1
|
||||
}
|
||||
if id1 < id2 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool {
|
||||
return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1
|
||||
}
|
||||
|
||||
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("resyncing_channel_id", d.channel.ID).
|
||||
Str("resyncing_channel_last_message_id", d.channel.LastMessageID).
|
||||
Str("resyncing_guild_id", d.channel.GuildID).
|
||||
Bool("has_latest_bridged", latestBridged != nil).
|
||||
Logger()
|
||||
|
||||
if latestBridged == nil {
|
||||
needsBackfill := d.channel.LastMessageID != ""
|
||||
log.Debug().Bool("needs_backfill", needsBackfill).Msg("Computed needs backfill")
|
||||
return needsBackfill, nil
|
||||
}
|
||||
|
||||
needsBackfill := shouldBackfill(
|
||||
discordid.ParseMessageID(latestBridged.ID),
|
||||
d.channel.LastMessageID,
|
||||
)
|
||||
log.Debug().Bool("needs_backfill", needsBackfill).Msg("Computed needs backfill")
|
||||
return needsBackfill, nil
|
||||
}
|
||||
61
pkg/connector/events_guild_resync.go
Normal file
61
pkg/connector/events_guild_resync.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
)
|
||||
|
||||
type DiscordGuildResync struct {
|
||||
Client *DiscordClient
|
||||
guild *discordgo.Guild
|
||||
portalKey networkid.PortalKey
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordGuildResync)(nil)
|
||||
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordGuildResync)(nil)
|
||||
)
|
||||
|
||||
func (d *DiscordGuildResync) AddLogContext(c zerolog.Context) zerolog.Context {
|
||||
return c.Str("guild_id", d.guild.ID).Str("guild_name", d.guild.Name)
|
||||
}
|
||||
|
||||
func (d *DiscordGuildResync) GetPortalKey() networkid.PortalKey {
|
||||
return d.portalKey
|
||||
}
|
||||
|
||||
func (d *DiscordGuildResync) GetSender() bridgev2.EventSender {
|
||||
return bridgev2.EventSender{}
|
||||
}
|
||||
|
||||
func (d *DiscordGuildResync) GetType() bridgev2.RemoteEventType {
|
||||
return bridgev2.RemoteEventChatResync
|
||||
}
|
||||
|
||||
func (d *DiscordGuildResync) ShouldCreatePortal() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||
return d.Client.GetChatInfo(ctx, portal)
|
||||
}
|
||||
38
pkg/connector/example-config.yaml
Normal file
38
pkg/connector/example-config.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Configuration options related to Discord guilds (also known as "servers").
|
||||
guilds:
|
||||
# UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure
|
||||
# during bridge development. If no guild IDs are specified, then no guilds
|
||||
# are bridged at all.
|
||||
bridging_guild_ids: []
|
||||
|
||||
# Should guild channel portals take on the guild icon as their avatars?
|
||||
guild_avatars_in_rooms: false
|
||||
|
||||
# Should the bridge refuse to send direct messages to recipients the user isn't
|
||||
# friends with on Discord? Discord generally considers this to be a "risky"
|
||||
# action.
|
||||
forbid_dming_strangers: true
|
||||
|
||||
# Template for Matrix room names created for Discord channels, except for 1:1
|
||||
# DMs. 1:1 DMs intentionally do not use this template as their room metadata is
|
||||
# derived from the other user's ghost (when private_chat_portal_meta is enabled).
|
||||
#
|
||||
# Available variables:
|
||||
# .Name - The Discord channel name.
|
||||
# .ParentName - The parent channel/category name, if any.
|
||||
# .GuildName - The guild name for guild channels.
|
||||
# .Type - The raw Discord channel type.
|
||||
# .NSFW - Whether the channel is marked NSFW.
|
||||
# .IsDM - Whether the channel is a 1:1 DM.
|
||||
# .IsGroupDM - Whether the channel is a group DM.
|
||||
# .IsCategory - Whether the channel is a guild category.
|
||||
# .IsGuildChannel - Whether the channel belongs to a guild.
|
||||
channel_name_template: "{{if and .IsGuildChannel (not .IsCategory)}}#{{end}}{{.Name}}"
|
||||
|
||||
# Should incoming custom emoji reactions be bridged as mxc:// URIs?
|
||||
# If false, they are bridged as :shortcode: instead.
|
||||
custom_emoji_reactions: true
|
||||
|
||||
# Should we log when messages from unbridged guild channels are dropped? This
|
||||
# only includes metadata such as channel and message ID.
|
||||
log_when_dropping_messages: true
|
||||
949
pkg/connector/handlediscord.go
Normal file
949
pkg/connector/handlediscord.go
Normal file
|
|
@ -0,0 +1,949 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/bridgev2/simplevent"
|
||||
"maunium.net/go/mautrix/bridgev2/status"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"go.mau.fi/util/variationselector"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
"go.mau.fi/mautrix-discord/pkg/router"
|
||||
)
|
||||
|
||||
const (
|
||||
DCNotLoggedIn status.BridgeStateErrorCode = "dc-not-logged-in"
|
||||
DCWebsocketDisconnect4004 status.BridgeStateErrorCode = "dc-websocket-disconnect-4004"
|
||||
DCUnknownWebsocketError status.BridgeStateErrorCode = "dc-unknown-websocket-error"
|
||||
DCHTTP40002 status.BridgeStateErrorCode = "dc-http-40002"
|
||||
)
|
||||
const accountVerificationRequiredMessage = "You need to verify your account in the Discord app."
|
||||
|
||||
func init() {
|
||||
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
|
||||
DCWebsocketDisconnect4004: "Please log in to your Discord account again.",
|
||||
DCNotLoggedIn: "Please log in to your Discord account.",
|
||||
DCHTTP40002: accountVerificationRequiredMessage,
|
||||
// (For DCUnknownWebsocketError, provide a specific error message when
|
||||
// sending state. If there were a generic message here, it would
|
||||
// overwrite that.)
|
||||
})
|
||||
}
|
||||
|
||||
type DiscordEventMeta struct {
|
||||
Type bridgev2.RemoteEventType
|
||||
LogContext func(c zerolog.Context) zerolog.Context
|
||||
route router.Route
|
||||
}
|
||||
|
||||
func (em *DiscordEventMeta) AddLogContext(c zerolog.Context) zerolog.Context {
|
||||
if em.LogContext == nil {
|
||||
return c
|
||||
}
|
||||
c = em.LogContext(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (em *DiscordEventMeta) GetType() bridgev2.RemoteEventType {
|
||||
return em.Type
|
||||
}
|
||||
|
||||
func (em *DiscordEventMeta) GetPortalKey() networkid.PortalKey {
|
||||
return em.route.PortalKey
|
||||
}
|
||||
|
||||
func (em *DiscordEventMeta) PortalReceiverIsUncertain() bool {
|
||||
return em.route.Uncertain
|
||||
}
|
||||
|
||||
type DiscordMessage struct {
|
||||
*DiscordEventMeta
|
||||
Data *discordgo.Message
|
||||
Client *DiscordClient
|
||||
ThreadRootID *networkid.MessageID
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) ShouldCreatePortal() bool {
|
||||
// Do not create a portal merely to bridge a message deletion or edit.
|
||||
return m.Type == bridgev2.RemoteEventMessage
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("action", "convert discord edit").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
// FIXME don't redundantly reupload attachments
|
||||
convertedEdit := m.Client.connector.MsgConv.ToMatrix(
|
||||
ctx,
|
||||
portal,
|
||||
intent,
|
||||
m.Client.UserLogin,
|
||||
m.Client.Session,
|
||||
m.Data,
|
||||
m.ThreadRootID,
|
||||
)
|
||||
|
||||
// TODO this is really gross and relies on how we assign incrementing numeric
|
||||
// part ids. to return a semantically correct `ConvertedEdit` we should ditch
|
||||
// this system
|
||||
slices.SortStableFunc(existing, func(a *database.Message, b *database.Message) int {
|
||||
ai, _ := strconv.Atoi(string(a.PartID))
|
||||
bi, _ := strconv.Atoi(string(b.PartID))
|
||||
return ai - bi
|
||||
})
|
||||
|
||||
if len(convertedEdit.Parts) != len(existing) {
|
||||
// FIXME support # of parts changing; triggerable by removing individual
|
||||
// attachments, etc.
|
||||
//
|
||||
// at the very least we can make this better by handling attachments,
|
||||
// which are always(?) at the end
|
||||
log.Warn().Int("n_parts_existing", len(existing)).Int("n_parts_after_edit", len(convertedEdit.Parts)).
|
||||
Msg("Ignoring message edit that changed number of parts")
|
||||
return nil, bridgev2.ErrIgnoringRemoteEvent
|
||||
}
|
||||
|
||||
parts := make([]*bridgev2.ConvertedEditPart, 0, len(existing))
|
||||
for pi, part := range convertedEdit.Parts {
|
||||
parts = append(parts, part.ToEditPart(existing[pi]))
|
||||
}
|
||||
|
||||
return &bridgev2.ConvertedEdit{
|
||||
ModifiedParts: parts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteEventWithUncertainPortalReceiver = (*DiscordMessage)(nil)
|
||||
_ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
|
||||
)
|
||||
|
||||
func (m *DiscordMessage) GetTargetMessage() networkid.MessageID {
|
||||
return discordid.MakeMessageID(m.Data.ID)
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) GetTransactionID() networkid.TransactionID {
|
||||
if m.Data.Nonce == "" {
|
||||
return ""
|
||||
}
|
||||
return networkid.TransactionID(m.Data.Nonce)
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
|
||||
return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data, m.ThreadRootID), nil
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) GetID() networkid.MessageID {
|
||||
return discordid.MakeMessageID(m.Data.ID)
|
||||
}
|
||||
|
||||
func (m *DiscordMessage) GetSender() bridgev2.EventSender {
|
||||
if m.Data.Author == nil {
|
||||
// Message deletions don't have a sender associated with them.
|
||||
return bridgev2.EventSender{}
|
||||
}
|
||||
|
||||
return m.Client.makeEventSender(m.Data.Author)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) wrapDiscordMessage(ctx context.Context, msg *discordgo.Message, route *router.Route, typ bridgev2.RemoteEventType) DiscordMessage {
|
||||
if msg == nil {
|
||||
msg = &discordgo.Message{}
|
||||
}
|
||||
|
||||
return DiscordMessage{
|
||||
DiscordEventMeta: &DiscordEventMeta{
|
||||
Type: typ,
|
||||
route: *route,
|
||||
},
|
||||
Data: msg,
|
||||
Client: d,
|
||||
ThreadRootID: route.FromThreadRootMessageID(),
|
||||
}
|
||||
}
|
||||
|
||||
type DiscordReaction struct {
|
||||
*DiscordEventMeta
|
||||
Reaction *discordgo.MessageReaction
|
||||
Client *DiscordClient
|
||||
|
||||
Emoji string
|
||||
EmojiID networkid.EmojiID
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
func (r *DiscordReaction) GetSender() bridgev2.EventSender {
|
||||
return r.Client.makeEventSenderWithID(r.Reaction.UserID)
|
||||
}
|
||||
|
||||
func (r *DiscordReaction) GetTargetMessage() networkid.MessageID {
|
||||
return discordid.MakeMessageID(r.Reaction.MessageID)
|
||||
}
|
||||
|
||||
func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID {
|
||||
return r.EmojiID
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.RemoteReaction = (*DiscordReaction)(nil)
|
||||
_ bridgev2.RemoteEventWithUncertainPortalReceiver = (*DiscordReaction)(nil)
|
||||
_ bridgev2.RemoteReactionRemove = (*DiscordReaction)(nil)
|
||||
_ bridgev2.RemoteReactionWithExtraContent = (*DiscordReaction)(nil)
|
||||
)
|
||||
|
||||
func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) {
|
||||
return r.Emoji, r.EmojiID
|
||||
}
|
||||
|
||||
func (r *DiscordReaction) GetReactionExtraContent() map[string]any {
|
||||
return r.Extra
|
||||
}
|
||||
|
||||
func (d *DiscordClient) wrapDiscordReaction(ctx context.Context, reaction *discordgo.MessageReaction, route *router.Route, beingAdded bool) (*DiscordReaction, error) {
|
||||
if reaction == nil {
|
||||
return nil, nil
|
||||
}
|
||||
evtType := bridgev2.RemoteEventReaction
|
||||
if !beingAdded {
|
||||
evtType = bridgev2.RemoteEventReactionRemove
|
||||
}
|
||||
|
||||
var matrixEmoji string
|
||||
var emojiID string
|
||||
var extra map[string]any
|
||||
|
||||
if reaction.Emoji.ID != "" {
|
||||
// A custom emoji.
|
||||
emojiID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID)
|
||||
shortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name)
|
||||
|
||||
extra = map[string]any{
|
||||
"fi.mau.discord.reaction": map[string]any{
|
||||
"id": reaction.Emoji.ID,
|
||||
"name": reaction.Emoji.Name,
|
||||
// "mxc" is added later if it's `beingAdded`.
|
||||
},
|
||||
"com.beeper.reaction.shortcode": shortcode,
|
||||
}
|
||||
|
||||
if beingAdded {
|
||||
reactionMXC, err := d.connector.GetCustomEmojiMXC(
|
||||
ctx,
|
||||
reaction.Emoji.ID,
|
||||
reaction.Emoji.Name,
|
||||
reaction.Emoji.Animated,
|
||||
)
|
||||
|
||||
if err != nil || reactionMXC == "" {
|
||||
zerolog.Ctx(ctx).Err(err).
|
||||
Str("emoji_id", reaction.Emoji.ID).
|
||||
Str("emoji_name", reaction.Emoji.Name).
|
||||
Msg("Failed to get Matrix MXC for custom emoji reaction being added")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extra["fi.mau.discord.reaction"].(map[string]any)["mxc"] = reactionMXC
|
||||
|
||||
if d.connector.Config.CustomEmojiReactionsEnabled() {
|
||||
matrixEmoji = string(reactionMXC)
|
||||
} else {
|
||||
matrixEmoji = shortcode
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A Unicode emoji.
|
||||
emojiID = reaction.Emoji.Name
|
||||
matrixEmoji = variationselector.Add(reaction.Emoji.Name)
|
||||
}
|
||||
|
||||
return &DiscordReaction{
|
||||
DiscordEventMeta: &DiscordEventMeta{
|
||||
Type: evtType,
|
||||
route: *route,
|
||||
},
|
||||
Reaction: reaction,
|
||||
Client: d,
|
||||
Emoji: matrixEmoji,
|
||||
EmojiID: discordid.MakeEmojiID(emojiID),
|
||||
Extra: extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleDiscordTyping(ctx context.Context, typing *discordgo.TypingStart, route *router.Route) {
|
||||
if typing.UserID == d.Session.State.User.ID {
|
||||
return
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("typing_channel_id", typing.ChannelID).
|
||||
Str("typing_user_id", typing.UserID).
|
||||
Str("typing_guild_id", typing.GuildID).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
// Make sure we have this user's info in case we haven't seen them at all yet.
|
||||
_ = d.userCache.Resolve(ctx, typing.UserID)
|
||||
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Typing{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventTyping,
|
||||
PortalKey: route.PortalKey,
|
||||
Sender: d.makeEventSenderWithID(typing.UserID),
|
||||
UncertainReceiver: route.Uncertain,
|
||||
},
|
||||
Timeout: 12 * time.Second,
|
||||
Type: bridgev2.TypingTypeText,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleChannelCreate(ctx context.Context, ch *discordgo.ChannelCreate) error {
|
||||
log := zerolog.Ctx(ctx).With().Str("channel_id", ch.ID).Logger()
|
||||
|
||||
if ch.GuildID == "" {
|
||||
log.Debug().Msg("Private channel was created, creating portal")
|
||||
d.queueChannelResync(ctx, ch.Channel)
|
||||
} else {
|
||||
log.Debug().Msg("Guild channel was created")
|
||||
// FIXME(skip): Sync guild channels. Same logic as syncGuild.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleChannelUpdate(ctx context.Context, upd *discordgo.ChannelUpdate) error {
|
||||
if upd.BeforeUpdate == nil {
|
||||
// Channel doesn't exist in the discordgo's state; don't bother bridging.
|
||||
return nil
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().Str("action", "handle channel update").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
portalKey := d.portalKeyForChannel(upd.Channel)
|
||||
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to look up existing channel: %w", err)
|
||||
}
|
||||
if portal == nil {
|
||||
// Don't bridge updates for channels we haven't actually bridged.
|
||||
return nil
|
||||
}
|
||||
|
||||
ts := time.Now()
|
||||
// Re-use main GetChatInfo logic to avoid drift. The rest of this function
|
||||
// is mostly removing what didn't change.
|
||||
patch, err := d.GetChatInfo(ctx, portal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to recompute chat info: %w", err)
|
||||
}
|
||||
|
||||
patch.Type = nil
|
||||
patch.CanBackfill = false
|
||||
|
||||
old := upd.BeforeUpdate
|
||||
// People leaving or joining a group DM isn't expressed via CHANNEL_UPDATE.
|
||||
patch.Members = nil
|
||||
if upd.Name == old.Name {
|
||||
patch.Name = nil
|
||||
}
|
||||
if upd.Topic == old.Topic {
|
||||
patch.Topic = nil
|
||||
}
|
||||
if upd.Icon == old.Icon {
|
||||
patch.Avatar = nil
|
||||
}
|
||||
if upd.ParentID == old.ParentID {
|
||||
patch.ParentID = nil
|
||||
}
|
||||
|
||||
d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventChatInfoChange,
|
||||
PortalKey: portalKey,
|
||||
Timestamp: ts,
|
||||
},
|
||||
ChatInfoChange: &bridgev2.ChatInfoChange{
|
||||
ChatInfo: patch,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleChannelDelete handles a channel being deleted. This can be a guild
|
||||
// channel getting "actually" deleted or a private channel getting "closed".
|
||||
func (d *DiscordClient) handleChannelDelete(ctx context.Context, evt *discordgo.ChannelDelete) error {
|
||||
portalKey := d.portalKeyForChannel(evt.Channel)
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("channel_id", evt.ID).
|
||||
Str("guild_id", evt.GuildID).
|
||||
Stringer("deleted_channel_portal_key", portalKey).Logger()
|
||||
|
||||
log.Debug().Msg("Handling channel deletion")
|
||||
d.queueChatDelete(portalKey, evt.Channel.GuildID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) queueChatDelete(portalKey networkid.PortalKey, deletedChannelGuildID string) {
|
||||
ts := time.Now()
|
||||
|
||||
onlyForMe := true
|
||||
if !d.connector.Bridge.Config.SplitPortals && deletedChannelGuildID != "" {
|
||||
// When split portals are disabled and a guild channel was deleted,
|
||||
// then it should be deleted for everyone.
|
||||
onlyForMe = false
|
||||
}
|
||||
|
||||
d.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventChatDelete,
|
||||
PortalKey: portalKey,
|
||||
Timestamp: ts,
|
||||
},
|
||||
OnlyForMe: onlyForMe,
|
||||
// Do not pass Children: true as deleting a guild channel category
|
||||
// merely detaches the parent_id from all child channels.
|
||||
// CHANNEL_UPDATE events will be dispatched for all child channels,
|
||||
// which should reparent them.
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleThreadUpdate(ctx context.Context, thread *discordgo.Channel) error {
|
||||
if thread == nil || !isThread(thread) {
|
||||
return nil
|
||||
}
|
||||
return d.upsertThreadInfoFromChannel(ctx, thread)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleThreadDelete(ctx context.Context, thread *discordgo.Channel) error {
|
||||
if thread == nil || thread.ID == "" {
|
||||
return nil
|
||||
}
|
||||
return d.connector.DB.Thread.DeleteByThreadChannelID(ctx, string(d.UserLogin.ID), thread.ID)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) queueIndividualMembershipChange(
|
||||
ctx context.Context,
|
||||
portalKey networkid.PortalKey,
|
||||
user *discordgo.User,
|
||||
membership event.Membership,
|
||||
ts time.Time,
|
||||
) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
userID := discordid.MakeUserID(user.ID)
|
||||
info := d.getUserInfo(ctx, user)
|
||||
|
||||
log.Debug().
|
||||
Stringer("portal_key", portalKey).
|
||||
Str("moving_user_id", user.ID).
|
||||
Str("membership", string(membership)).
|
||||
Msg("Queueing chat info change in response to membership change")
|
||||
|
||||
d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventChatInfoChange,
|
||||
PortalKey: portalKey,
|
||||
Timestamp: ts,
|
||||
},
|
||||
ChatInfoChange: &bridgev2.ChatInfoChange{
|
||||
MemberChanges: &bridgev2.ChatMemberList{
|
||||
MemberMap: bridgev2.ChatMemberMap{
|
||||
userID: bridgev2.ChatMember{
|
||||
// TODO: Can't effectively send MemberSender here to
|
||||
// attribute e.g. someone getting kicked from a group
|
||||
// DM because that information isn't in the gateway
|
||||
// payload. Might need to wait for the corresponding
|
||||
// system message.
|
||||
EventSender: d.makeEventSender(user),
|
||||
Membership: membership,
|
||||
UserInfo: info,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleRecipientAdd(ctx context.Context, evt *discordgo.ChannelRecipientAdd, route *router.Route) error {
|
||||
d.queueIndividualMembershipChange(ctx, route.PortalKey, evt.User, event.MembershipJoin, time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleRecipientRemove(ctx context.Context, evt *discordgo.ChannelRecipientRemove, route *router.Route) error {
|
||||
d.queueIndividualMembershipChange(ctx, route.PortalKey, evt.User, event.MembershipLeave, time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleGuildMemberJoinMessage(ctx context.Context, msg *discordgo.Message, route *router.Route) {
|
||||
ts := msg.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
d.queueIndividualMembershipChange(ctx, route.PortalKey, msg.Author, event.MembershipJoin, ts)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleMessageAck(ctx context.Context, ack *discordgo.MessageAck, bridged bool, route *router.Route) {
|
||||
d.readStatesLock.Lock()
|
||||
zerolog.Ctx(ctx).Trace().
|
||||
Str("channel_id", ack.ChannelID).
|
||||
Str("message_id", ack.MessageID).
|
||||
Msg("Updating state with MESSAGE_ACK")
|
||||
|
||||
// TODO: mention_count can appear in MESSAGE_ACK payloads. Update it if it's
|
||||
// present and not `null`. This needs discordgo changes. (There's even more
|
||||
// missing fields than this.)
|
||||
d.readStates[ack.ChannelID] = &discordgo.ReadState{
|
||||
ID: ack.ChannelID,
|
||||
LastMessageID: discordgo.StringOrInt(ack.MessageID),
|
||||
}
|
||||
d.readStatesLock.Unlock()
|
||||
|
||||
if bridged {
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Receipt{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventReadReceipt,
|
||||
PortalKey: route.PortalKey,
|
||||
Sender: d.selfEventSender(),
|
||||
UncertainReceiver: route.Uncertain,
|
||||
},
|
||||
LastTarget: discordid.MakeMessageID(ack.MessageID),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// channelIsBridged uses routing logic to check whether a portal (with an
|
||||
// existing room) exists for a given Discord channel ID.
|
||||
func (d *DiscordClient) channelIsBridged(ctx context.Context, channelID string) (bool, *router.Route) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
route, err := d.Route(ctx, channelID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to route channel when determining channel bridgedness")
|
||||
return false, nil
|
||||
}
|
||||
existingPortal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, route.PortalKey)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to look up existing portal when determining channel bridgedness")
|
||||
return false, route
|
||||
}
|
||||
return existingPortal != nil && existingPortal.MXID != "", route
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleUserGuildSettingsUpdate(ctx context.Context, evt *discordgo.UserGuildSettingsUpdate) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
log.Debug().Msg("Handling user guild settings update")
|
||||
d.applySingleGuildSettings(evt.UserGuildSettings)
|
||||
}
|
||||
|
||||
func messageCtx(ctx context.Context, msg *discordgo.Message) (context.Context, *zerolog.Logger) {
|
||||
if msg == nil {
|
||||
return ctx, zerolog.Ctx(ctx)
|
||||
}
|
||||
|
||||
wipLog := zerolog.Ctx(ctx).With().
|
||||
Str("guild_id", msg.GuildID).
|
||||
Str("channel_id", msg.ChannelID).
|
||||
Str("message_id", msg.ID)
|
||||
if msg.Author != nil {
|
||||
wipLog = wipLog.Str("author_id", msg.Author.ID).
|
||||
Bool("author_bot", msg.Author.Bot)
|
||||
}
|
||||
if msg.WebhookID != "" {
|
||||
wipLog = wipLog.Str("webhook_id", msg.WebhookID)
|
||||
}
|
||||
log := wipLog.Logger()
|
||||
|
||||
return log.WithContext(ctx), &log
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleDiscordStateEvent(rawEvt any) {
|
||||
ctx := d.UserLogin.Bridge.BackgroundCtx
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
switch evt := rawEvt.(type) {
|
||||
case *discordgo.ReadySupplemental:
|
||||
log.Info().
|
||||
Int("n_lazy_private_channels", len(evt.LazyPrivateChannels)).
|
||||
Msg("Received supplemental READY")
|
||||
case *discordgo.Ready:
|
||||
d.rebuildRelationships()
|
||||
case *discordgo.RelationshipAdd:
|
||||
d.upsertRelationship(evt.Relationship)
|
||||
case *discordgo.RelationshipUpdate:
|
||||
d.upsertRelationship(evt.Relationship)
|
||||
case *discordgo.RelationshipRemove:
|
||||
d.removeRelationship(evt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleRelationshipNickChange(ctx context.Context, userID, nickname string) {
|
||||
ch := d.dmChannelForUserID(userID)
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
portalKey := d.portalKeyForChannel(ch)
|
||||
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to look up DM portal for relationship nick change")
|
||||
return
|
||||
}
|
||||
if portal == nil || portal.MXID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var name *string
|
||||
if nickname != "" {
|
||||
name = &nickname
|
||||
} else {
|
||||
name = bridgev2.DefaultChatName
|
||||
}
|
||||
|
||||
d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventChatInfoChange,
|
||||
PortalKey: portalKey,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
ChatInfoChange: &bridgev2.ChatInfoChange{
|
||||
ChatInfo: &bridgev2.ChatInfo{
|
||||
Name: name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
d.UserLogin.Log.Error().
|
||||
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
|
||||
Any(zerolog.ErrorFieldName, err).
|
||||
Msg("Panic in Discord event handler")
|
||||
|
||||
props := d.baseAnalyticsProps(d.UserLogin.Bridge.BackgroundCtx)
|
||||
props["eventType"] = fmt.Sprintf("%T", rawEvt)
|
||||
props["error"] = fmt.Sprint(err)
|
||||
|
||||
d.UserLogin.TrackAnalytics("Discord event handler panic", props)
|
||||
}()
|
||||
|
||||
log := d.UserLogin.Log.With().Str("action", "handle discord event").
|
||||
Type("event_type", rawEvt).
|
||||
Logger()
|
||||
ctx := log.WithContext(d.UserLogin.Bridge.BackgroundCtx)
|
||||
|
||||
// NOTE: discordgo seemingly dispatches both the proper unmarshalled type
|
||||
// (e.g. `*discordgo.TypingStart`) _as well as_ a "raw" *discordgo.Event
|
||||
// (e.g. `*discordgo.Event` with `Type` of `TYPING_START`) for every gateway
|
||||
// event.
|
||||
|
||||
// NOTE: We explicitly return early from paths where we would otherwise
|
||||
// QueueRemoteEvent for a portal that hasn't been bridged by the user yet.
|
||||
// (Specifically, we check for an extant portal with an associated room.)
|
||||
// This avoids the eager creation of stub portals that have bogus metadata
|
||||
// (e.g. GuildID == "" despite being a guild channel). This is because you
|
||||
// can't specify metadata upfront when a portal is implicitly created. We
|
||||
// might want to rely on our metadata always being "correct" in the future.
|
||||
//
|
||||
// This also helps avoid excessive "Dropping event as portal doesn't exist"
|
||||
// logs from Mautrix. You receive events for every guild you're in, so this
|
||||
// can become noisy fast.
|
||||
|
||||
switch evt := rawEvt.(type) {
|
||||
case *discordgo.Ready:
|
||||
log.Info().
|
||||
Int("n_dms", len(evt.PrivateChannels)).
|
||||
Int("n_guilds", len(evt.Guilds)).
|
||||
Int("n_merged_members", len(evt.MergedMembers)).
|
||||
Int("n_relationships", len(evt.Relationships)).
|
||||
Int("n_users", len(evt.Users)).
|
||||
Msg("Received READY dispatch from discordgo")
|
||||
|
||||
d.userCache.UpdateWithReady(evt)
|
||||
d.syncRemoteProfile(ctx)
|
||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||
StateEvent: status.StateConnected,
|
||||
})
|
||||
case *discordgo.Resumed:
|
||||
// (All missed gateway events have been replayed, and all subsequent
|
||||
// events will be new.)
|
||||
log.Info().Msg("Received RESUMED dispatch from discordgo")
|
||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||
StateEvent: status.StateConnected,
|
||||
})
|
||||
case *discordgo.InvalidAuth:
|
||||
log.Warn().Msg("Got logged out of Discord due to invalid token")
|
||||
d.tokenInvalidated(ctx, "while connected")
|
||||
case *discordgo.TypingStart:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
d.handleDiscordTyping(ctx, evt, route)
|
||||
case *discordgo.GuildCreate:
|
||||
if evt.Unavailable {
|
||||
break
|
||||
}
|
||||
if err := d.syncGuildRoles(ctx, evt.ID, evt.Roles); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.ID).Msg("Failed to sync guild roles from guild create event")
|
||||
}
|
||||
case *discordgo.GuildUpdate:
|
||||
if err := d.syncGuildRoles(ctx, evt.ID, evt.Roles); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.ID).Msg("Failed to sync guild roles from guild update event")
|
||||
}
|
||||
case *discordgo.GuildRoleCreate:
|
||||
roleID := ""
|
||||
if evt.Role != nil {
|
||||
roleID = evt.Role.ID
|
||||
}
|
||||
if err := d.upsertGuildRole(ctx, evt.GuildID, evt.Role); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", roleID).Msg("Failed to store role create event")
|
||||
}
|
||||
case *discordgo.GuildRoleUpdate:
|
||||
roleID := ""
|
||||
if evt.Role != nil {
|
||||
roleID = evt.Role.ID
|
||||
}
|
||||
if err := d.upsertGuildRole(ctx, evt.GuildID, evt.Role); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", roleID).Msg("Failed to store role update event")
|
||||
}
|
||||
case *discordgo.GuildRoleDelete:
|
||||
if err := d.connector.DB.Role.DeleteByID(ctx, evt.GuildID, evt.RoleID); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", evt.RoleID).Msg("Failed to delete role from database")
|
||||
}
|
||||
case *discordgo.ChannelCreate:
|
||||
if err := d.handleChannelCreate(ctx, evt); err != nil {
|
||||
log.Err(err).Msg("Failed to handle channel create")
|
||||
}
|
||||
case *discordgo.ChannelUpdate:
|
||||
bridged, _ := d.channelIsBridged(ctx, evt.ID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
err := d.handleChannelUpdate(ctx, evt)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to handle channel update")
|
||||
}
|
||||
case *discordgo.ChannelDelete:
|
||||
// The route computed by channelIsBridged will always be uncertain
|
||||
// because the channel has already disappeared from discordgo's state.
|
||||
bridged, _ := d.channelIsBridged(ctx, evt.ID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
if err := d.handleChannelDelete(ctx, evt); err != nil {
|
||||
log.Err(err).Msg("Failed to handle channel delete")
|
||||
}
|
||||
case *discordgo.ChannelRecipientAdd:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
if err := d.handleRecipientAdd(ctx, evt, route); err != nil {
|
||||
log.Err(err).Msg("Failed to handle channel recipient add")
|
||||
}
|
||||
case *discordgo.ChannelRecipientRemove:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
if err := d.handleRecipientRemove(ctx, evt, route); err != nil {
|
||||
log.Err(err).Msg("Failed to handle channel recipient remove")
|
||||
}
|
||||
case *discordgo.ThreadCreate:
|
||||
err := d.handleThreadUpdate(ctx, evt.Channel)
|
||||
if err != nil {
|
||||
log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread create event")
|
||||
}
|
||||
case *discordgo.ThreadUpdate:
|
||||
err := d.handleThreadUpdate(ctx, evt.Channel)
|
||||
if err != nil {
|
||||
log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread update event")
|
||||
}
|
||||
case *discordgo.ThreadDelete:
|
||||
err := d.handleThreadDelete(ctx, evt.Channel)
|
||||
if err != nil {
|
||||
log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread delete event")
|
||||
}
|
||||
case *discordgo.ThreadListSync:
|
||||
for _, thread := range evt.Threads {
|
||||
err := d.handleThreadUpdate(ctx, thread)
|
||||
if err != nil {
|
||||
log.Err(err).Str("thread_id", thread.ID).Msg("Failed to handle thread in thread list sync event")
|
||||
}
|
||||
}
|
||||
case *discordgo.MessageCreate:
|
||||
if evt.Author == nil {
|
||||
log.Trace().Int("message_type", int(evt.Message.Type)).
|
||||
Str("guild_id", evt.GuildID).
|
||||
Str("message_id", evt.ID).
|
||||
Str("channel_id", evt.ChannelID).
|
||||
Msg("Dropping message that lacks an author")
|
||||
return
|
||||
}
|
||||
ctx, log := messageCtx(ctx, evt.Message)
|
||||
inBridgedChannel, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
isDM := route != nil && route.FromChannel != nil && channelIsPrivate(route.FromChannel)
|
||||
if !inBridgedChannel && !isDM {
|
||||
if d.connector.Config.LogWhenDroppingMessages {
|
||||
log.Debug().
|
||||
Str("channel_id", evt.ChannelID).
|
||||
Str("message_id", evt.ID).
|
||||
Bool("route_uncertain", route != nil && route.Uncertain).
|
||||
Bool("from_channel_known", route != nil && route.FromChannel != nil).
|
||||
Bool("from_thread_known", route != nil && route.FromThread != nil).
|
||||
Msg("Dropping message for non-bridged channel")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if evt.Message.Type == discordgo.MessageTypeGuildMemberJoin {
|
||||
d.userCache.UpdateWithMessage(evt.Message)
|
||||
d.handleGuildMemberJoinMessage(ctx, evt.Message, route)
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil {
|
||||
log.Err(err).Msg("Failed to persist thread info from message create")
|
||||
}
|
||||
d.userCache.UpdateWithMessage(evt.Message)
|
||||
|
||||
wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventMessage)
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
||||
case *discordgo.MessageUpdate:
|
||||
ctx, log := messageCtx(ctx, evt.Message)
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil {
|
||||
log.Err(err).Str("message_id", evt.ID).Msg("Failed to persist thread info from message update")
|
||||
}
|
||||
|
||||
wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventEdit)
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
||||
case *discordgo.UserUpdate:
|
||||
// The current user changed. (This is not sent out for anyone else.)
|
||||
log.Info().Msg("Current user was updated")
|
||||
|
||||
// discordgo does not update State.User for us. This is probably a bug.
|
||||
// Do it ourselves in the meantime.
|
||||
{
|
||||
state := d.Session.State
|
||||
state.Lock()
|
||||
*d.Session.State.User = *evt.User
|
||||
state.Unlock()
|
||||
}
|
||||
d.userCache.UpdateWithUserUpdate(evt)
|
||||
|
||||
if d.syncRemoteProfile(ctx) {
|
||||
// Send out a new bridge state so clients immediately get the
|
||||
// updated profile.
|
||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||
StateEvent: status.StateConnected,
|
||||
})
|
||||
}
|
||||
case *discordgo.MessageDelete:
|
||||
ctx, _ := messageCtx(ctx, evt.Message)
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
|
||||
wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventMessageRemove)
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
||||
// TODO *discordgo.MessageDeleteBulk
|
||||
case *discordgo.MessageReactionAdd:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
wrappedEvt, err := d.wrapDiscordReaction(ctx, evt.MessageReaction, route, true)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Dropping incoming reaction due to error")
|
||||
} else {
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, wrappedEvt)
|
||||
}
|
||||
// TODO case *discordgo.MessageReactionRemoveAll:
|
||||
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
|
||||
case *discordgo.MessageReactionRemove:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
if !bridged {
|
||||
return
|
||||
}
|
||||
wrappedEvt, err := d.wrapDiscordReaction(ctx, evt.MessageReaction, route, false)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Dropping incoming reaction removal due to error")
|
||||
} else {
|
||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, wrappedEvt)
|
||||
}
|
||||
// NOTE: Relationship updates are also handled in handleDiscordStateEvent,
|
||||
// which is synchronously invoked before this one. This is to ensure
|
||||
// coherence in the face of concurrency, because this method is always
|
||||
// dispatched on a new goroutine.
|
||||
case *discordgo.RelationshipAdd:
|
||||
d.handleRelationshipNickChange(ctx, evt.ID, evt.Nickname)
|
||||
case *discordgo.RelationshipUpdate:
|
||||
d.handleRelationshipNickChange(ctx, evt.ID, evt.Nickname)
|
||||
case *discordgo.RelationshipRemove:
|
||||
d.handleRelationshipNickChange(ctx, evt.ID, "")
|
||||
case *discordgo.PresenceUpdate:
|
||||
return
|
||||
case *discordgo.MessageAck:
|
||||
bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
|
||||
d.handleMessageAck(ctx, evt, bridged, route)
|
||||
case *discordgo.UserGuildSettingsUpdate:
|
||||
d.handleUserGuildSettingsUpdate(ctx, evt)
|
||||
case *discordgo.GuildDelete:
|
||||
if evt.Unavailable {
|
||||
log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
|
||||
// For now, leave the portals alone if the guild only went away due to an outage.
|
||||
return
|
||||
}
|
||||
if err := d.connector.DB.Role.DeleteByGuildID(ctx, evt.ID); err != nil {
|
||||
log.Err(err).Str("guild_id", evt.ID).Msg("Failed to delete guild roles from database")
|
||||
}
|
||||
d.deleteGuildPortalSpace(ctx, evt.ID)
|
||||
}
|
||||
}
|
||||
614
pkg/connector/handlematrix.go
Normal file
614
pkg/connector/handlematrix.go
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"go.mau.fi/util/ptr"
|
||||
"go.mau.fi/util/variationselector"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
var (
|
||||
_ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
_ bridgev2.MuteHandlingNetworkAPI = (*DiscordClient)(nil)
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextKeyChannel contextKey = iota
|
||||
)
|
||||
|
||||
type SendAttempt struct {
|
||||
At time.Time
|
||||
ChannelType discordgo.ChannelType
|
||||
RecipientRelationshipType *discordgo.RelationshipType
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
|
||||
if !d.IsLoggedIn() {
|
||||
return nil, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().Str("action", "matrix message send").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
portal := msg.Portal
|
||||
guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
parentChannelID := discordid.ParseChannelPortalID(portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
threadRootRemoteID := getMatrixThreadRootRemoteMessageID(msg.ThreadRoot)
|
||||
|
||||
if threadRootRemoteID != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, threadRootRemoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
} else if guildID != "" {
|
||||
var startErr error
|
||||
threadChannelID, startErr = d.startThreadFromMatrix(ctx, guildID, parentChannelID, threadRootRemoteID, getThreadName(msg.Content))
|
||||
if startErr != nil {
|
||||
// If creating the thread failed, try resolving it once more in case it already exists.
|
||||
thread, err = d.getThreadByRootMessageID(ctx, threadRootRemoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to create Discord thread from Matrix message: %w", startErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if threadChannelID != "" {
|
||||
channelID = threadChannelID
|
||||
}
|
||||
refererOpt := makeDiscordReferer(guildID, parentChannelID, threadChannelID)
|
||||
|
||||
ch := d.channelWithID(ctx, channelID)
|
||||
ctx = context.WithValue(ctx, contextKeyChannel, ch)
|
||||
|
||||
// Perform any required screening before making any requests to Discord at
|
||||
// all (message conversion does).
|
||||
if err := d.screenOutgoingMessage(ctx, ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg, channelID, refererOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if sendReq.Reference != nil && sendReq.Reference.ChannelID == parentChannelID && threadChannelID != "" {
|
||||
sendReq.Reference.ChannelID = threadChannelID
|
||||
}
|
||||
|
||||
if ch != nil {
|
||||
var relType *discordgo.RelationshipType
|
||||
if rel := d.relationshipWithDMRecipient(ch); rel != nil {
|
||||
relType = &rel.Type
|
||||
}
|
||||
|
||||
if channelIsPrivate(ch) {
|
||||
// NOTE: These analytics are so that we can get some data on what's
|
||||
// causing Discord to disable/restrict/ban accounts. For message
|
||||
// attempts, we only send these for DMs at the moment.
|
||||
//
|
||||
// (This fires a goroutine internally so it won't block.)
|
||||
d.sendOutgoingMessageAttemptAnalytics(ctx, map[string]any{
|
||||
"messageFlags": sendReq.Flags,
|
||||
"messageType": sendReq.Type,
|
||||
"hasAttachments": len(sendReq.Attachments) > 0,
|
||||
"hasEmbeds": len(sendReq.Embeds) > 0,
|
||||
"isReplying": sendReq.Reference != nil && sendReq.Reference.Type == discordgo.MessageReferenceTypeDefault,
|
||||
})
|
||||
}
|
||||
|
||||
d.lastSendAttemptMutex.Lock()
|
||||
d.lastSendAttempt = &SendAttempt{
|
||||
At: time.Now(),
|
||||
ChannelType: ch.Type,
|
||||
RecipientRelationshipType: relType,
|
||||
}
|
||||
d.lastSendAttemptMutex.Unlock()
|
||||
}
|
||||
|
||||
sentMsg, err := d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, d.tryWrappingError(ctx, err)
|
||||
}
|
||||
sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID)
|
||||
dbMessage := &database.Message{
|
||||
ID: discordid.MakeMessageID(sentMsg.ID),
|
||||
SenderID: discordid.MakeUserID(sentMsg.Author.ID),
|
||||
Timestamp: sentMsgTimestamp,
|
||||
}
|
||||
if threadRootRemoteID != "" {
|
||||
dbMessage.ThreadRoot = discordid.MakeMessageID(threadRootRemoteID)
|
||||
}
|
||||
|
||||
return &bridgev2.MatrixMessageResponse{
|
||||
DB: dbMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var errCannotDMStranger = errors.New("can't direct message a stranger")
|
||||
|
||||
func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
if d.connector.Config.ForbidDMingStrangersEnabled() {
|
||||
dmRecipID := dmChannelRecipientID(destCh)
|
||||
if dmRecipID != nil {
|
||||
rel := d.relationshipWithUserID(*dmRecipID)
|
||||
friendsWithDMRecip := rel != nil && rel.Type == discordgo.RelationshipFriend
|
||||
|
||||
dmRecip := d.userCache.Resolve(ctx, *dmRecipID)
|
||||
|
||||
if dmRecip != nil && !dmRecip.Bot && !friendsWithDMRecip {
|
||||
loggedRelType := "none"
|
||||
if rel != nil {
|
||||
loggedRelType = readableRelationshipType(rel.Type)
|
||||
}
|
||||
log.Info().
|
||||
Str("relationship_type", loggedRelType).
|
||||
Msg("Preventing direct message send to a stranger")
|
||||
|
||||
return bridgev2.WrapErrorInStatus(errCannotDMStranger).
|
||||
WithStatus(event.MessageStatusFail).
|
||||
WithIsCertain(true).
|
||||
WithMessage("You can't message users who aren't on your friends list. To continue, use the Discord app to chat or add them as a friend.").
|
||||
WithSendNotice(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) sendOutgoingMessageAttemptAnalytics(ctx context.Context, extra map[string]any) {
|
||||
props := d.baseAnalyticsProps(ctx)
|
||||
maps.Copy(props, extra)
|
||||
|
||||
d.UserLogin.TrackAnalytics("Discord outgoing message attempt", props)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
content, _ := d.connector.MsgConv.ConvertMatrixMessageContent(
|
||||
ctx,
|
||||
msg.Portal,
|
||||
msg.Content,
|
||||
// Disregard link previews for now. Discord generally allows you to
|
||||
// remove individual link previews from a message though.
|
||||
[]string{},
|
||||
)
|
||||
|
||||
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
parentChannelID := discordid.ParseChannelPortalID(msg.Portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
if msg.EditTarget != nil && msg.EditTarget.ThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(msg.EditTarget.ThreadRoot))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve target thread for message edit: %w", err)
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
channelID = threadChannelID
|
||||
}
|
||||
}
|
||||
|
||||
_, err := d.Session.ChannelMessageEdit(
|
||||
channelID,
|
||||
discordid.ParseMessageID(msg.EditTarget.ID),
|
||||
content,
|
||||
makeDiscordReferer(guildID, parentChannelID, threadChannelID),
|
||||
)
|
||||
if err != nil {
|
||||
return d.tryWrappingError(ctx, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.MatrixReactionPreResponse{}, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
emojiID := reaction.Content.RelatesTo.Key
|
||||
|
||||
// Figure out if this is a custom emoji or not.
|
||||
if strings.HasPrefix(emojiID, "mxc://") {
|
||||
customEmoji, err := d.connector.GetCustomEmojiByMXC(ctx, emojiID)
|
||||
|
||||
if err != nil {
|
||||
return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("failed to get custom emoji by mxc: %w", err)
|
||||
} else if customEmoji == nil || customEmoji.ID == "" || customEmoji.Name == "" {
|
||||
return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("unknown custom emoji mxc: %q", emojiID)
|
||||
}
|
||||
|
||||
emojiID = fmt.Sprintf("%s:%s", customEmoji.Name, customEmoji.ID)
|
||||
} else {
|
||||
emojiID = variationselector.FullyQualify(emojiID)
|
||||
}
|
||||
|
||||
return bridgev2.MatrixReactionPreResponse{
|
||||
SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID),
|
||||
EmojiID: discordid.MakeEmojiID(emojiID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
|
||||
if !d.IsLoggedIn() {
|
||||
return nil, bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
portal := reaction.Portal
|
||||
meta := portal.Metadata.(*discordid.PortalMetadata)
|
||||
parentChannelID := discordid.ParseChannelPortalID(portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
if reaction.TargetMessage != nil && reaction.TargetMessage.ThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(reaction.TargetMessage.ThreadRoot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
channelID = threadChannelID
|
||||
}
|
||||
}
|
||||
|
||||
return nil, d.tryWrappingError(ctx, d.Session.MessageReactionAddUser(
|
||||
meta.GuildID,
|
||||
channelID,
|
||||
discordid.ParseMessageID(reaction.TargetMessage.ID),
|
||||
discordid.ParseEmojiID(reaction.PreHandleResp.EmojiID),
|
||||
makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID),
|
||||
))
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
removing := removal.TargetReaction
|
||||
emojiID := removing.EmojiID
|
||||
parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
targetMessage, err := d.UserLogin.Bridge.DB.Message.GetFirstPartByID(ctx, d.UserLogin.ID, removing.MessageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetMessage != nil && targetMessage.ThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(targetMessage.ThreadRoot))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
channelID = threadChannelID
|
||||
}
|
||||
}
|
||||
|
||||
return d.tryWrappingError(ctx, d.Session.MessageReactionRemoveUser(
|
||||
guildID,
|
||||
channelID,
|
||||
discordid.ParseMessageID(removing.MessageID),
|
||||
discordid.ParseEmojiID(emojiID),
|
||||
discordid.ParseUserLoginID(d.UserLogin.ID),
|
||||
makeDiscordReferer(guildID, parentChannelID, threadChannelID),
|
||||
))
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID)
|
||||
channelID := parentChannelID
|
||||
threadChannelID := ""
|
||||
if removal.TargetMessage != nil && removal.TargetMessage.ThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(removal.TargetMessage.ThreadRoot))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
channelID = threadChannelID
|
||||
}
|
||||
}
|
||||
messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
|
||||
return d.tryWrappingError(ctx, d.Session.ChannelMessageDelete(channelID, messageID, makeDiscordReferer(guildID, parentChannelID, threadChannelID)))
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
log := msg.Portal.Log.With().
|
||||
Str("event_id", string(msg.EventID)).
|
||||
Str("action", "matrix read receipt").Logger()
|
||||
|
||||
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
parentChannelID := discordid.ParseChannelPortalID(msg.Portal.ID)
|
||||
threadChannelID := ""
|
||||
threadRootRemoteID := ""
|
||||
threadID := msg.Receipt.ThreadID
|
||||
threadScoped := threadID != "" && threadID != event.ReadReceiptThreadMain
|
||||
|
||||
if threadScoped {
|
||||
rootMsg, err := d.UserLogin.Bridge.DB.Message.GetPartByMXID(ctx, threadID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to resolve thread root event from receipt")
|
||||
return err
|
||||
} else if rootMsg != nil {
|
||||
threadRootRemoteID = discordid.ParseMessageID(rootMsg.ID)
|
||||
if rootMsg.ThreadRoot != "" {
|
||||
threadRootRemoteID = discordid.ParseMessageID(rootMsg.ThreadRoot)
|
||||
}
|
||||
thread, err := d.getThreadByRootMessageID(ctx, threadRootRemoteID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to resolve thread channel from thread root")
|
||||
return err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
}
|
||||
}
|
||||
}
|
||||
if threadScoped && threadRootRemoteID == "" {
|
||||
log.Debug().Stringer("receipt_thread_id", threadID).Msg("Dropping thread-scoped read receipt: unknown thread root")
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetMessage *database.Message
|
||||
var targetMessageID string
|
||||
|
||||
// Figure out the ID of the Discord message that we'll mark as read. If the
|
||||
// receipt didn't exactly correspond with a message, try finding one close
|
||||
// by to use as the target.
|
||||
if msg.ExactMessage != nil {
|
||||
targetMessage = msg.ExactMessage
|
||||
targetMessageID = discordid.ParseMessageID(targetMessage.ID)
|
||||
log = log.With().
|
||||
Str("message_id", targetMessageID).
|
||||
Logger()
|
||||
} else {
|
||||
var err error
|
||||
if threadScoped && threadRootRemoteID != "" {
|
||||
targetMessage, err = d.UserLogin.Bridge.DB.Message.GetLastThreadMessage(ctx, msg.Portal.PortalKey, discordid.MakeMessageID(threadRootRemoteID))
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to find latest thread message")
|
||||
return err
|
||||
}
|
||||
if targetMessage != nil && targetMessage.Timestamp.After(msg.ReadUpTo) {
|
||||
targetMessage = nil
|
||||
}
|
||||
} else {
|
||||
targetMessage, err = d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to find closest message part")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if targetMessage != nil {
|
||||
// The read receipt didn't specify an exact message but we were able to
|
||||
// find one close by.
|
||||
|
||||
targetMessageID = discordid.ParseMessageID(targetMessage.ID)
|
||||
log = log.With().
|
||||
Str("closest_message_id", targetMessageID).
|
||||
Str("closest_event_id", targetMessage.MXID.String()).
|
||||
Logger()
|
||||
log.Debug().
|
||||
Msg("Read receipt target event not found, using closest message")
|
||||
} else {
|
||||
log.Debug().Msg("Dropping read receipt: no messages found")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if threadScoped && targetMessage != nil {
|
||||
targetMsgThreadRoot := discordid.ParseMessageID(targetMessage.ThreadRoot)
|
||||
if targetMsgThreadRoot == "" {
|
||||
targetMsgThreadRoot = discordid.ParseMessageID(targetMessage.ID)
|
||||
}
|
||||
if threadRootRemoteID != "" && targetMsgThreadRoot != threadRootRemoteID {
|
||||
log.Debug().
|
||||
Str("receipt_thread_root", threadRootRemoteID).
|
||||
Str("target_thread_root", targetMsgThreadRoot).
|
||||
Msg("Dropping read receipt due to thread mismatch")
|
||||
return nil
|
||||
}
|
||||
if threadChannelID == "" && targetMsgThreadRoot != "" {
|
||||
thread, err := d.getThreadByRootMessageID(ctx, targetMsgThreadRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if thread != nil {
|
||||
threadChannelID = thread.ThreadChannelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelID := parentChannelID
|
||||
if threadChannelID != "" {
|
||||
channelID = threadChannelID
|
||||
}
|
||||
resp, err := d.Session.ChannelMessageAckNoToken(
|
||||
channelID,
|
||||
targetMessageID,
|
||||
makeDiscordReferer(guildID, parentChannelID, threadChannelID),
|
||||
)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to send read receipt to Discord")
|
||||
return err
|
||||
} else if resp.Token != nil {
|
||||
log.Debug().
|
||||
Str("unexpected_resp_token", *resp.Token).
|
||||
Msg("Marked message as read on Discord (and got unexpected non-nil token)")
|
||||
} else {
|
||||
log.Debug().Msg("Marked message as read on Discord")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error {
|
||||
if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" {
|
||||
// Only private channels need this logic.
|
||||
return nil
|
||||
}
|
||||
|
||||
d.markedOpenedLock.Lock()
|
||||
defer d.markedOpenedLock.Unlock()
|
||||
|
||||
channelID := discordid.ParseChannelPortalID(portal.ID)
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("channel_id", channelID).Logger()
|
||||
|
||||
lastMarkedOpenedTs := d.markedOpened[channelID]
|
||||
if lastMarkedOpenedTs.IsZero() {
|
||||
d.markedOpened[channelID] = time.Now()
|
||||
|
||||
err := d.Session.MarkViewing(channelID)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to mark user as viewing channel")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace().Msg("Marked channel as being viewed")
|
||||
} else {
|
||||
log.Trace().Str("channel_id", channelID).
|
||||
Msg("Already marked channel as viewed, not doing so")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
// Don't mind if this fails.
|
||||
_ = d.viewingChannel(ctx, msg.Portal)
|
||||
|
||||
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
|
||||
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
|
||||
err := d.Session.ChannelTyping(channelID, makeDiscordReferer(guildID, channelID, ""))
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to mark user as typing")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msg("Marked user as typing")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error {
|
||||
if !d.IsLoggedIn() {
|
||||
return bridgev2.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("muting_channel_id", channelID).
|
||||
Int64("muting_until", msg.Content.MutedUntil).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
log.Debug().Msg("Handling Matrix mute")
|
||||
|
||||
ch := d.channelWithID(ctx, channelID)
|
||||
if ch == nil {
|
||||
log.Error().Msg("Failed to find channel to mute")
|
||||
return fmt.Errorf("failed to mute non-existent channel %s", channelID)
|
||||
}
|
||||
|
||||
mutedUntil := msg.Content.GetMutedUntilTime()
|
||||
isMuting := mutedUntil.After(time.Now())
|
||||
override := discordgo.UserGuildSettingsChannelOverrideEdit{
|
||||
Muted: ptr.Ptr(isMuting),
|
||||
}
|
||||
if isMuting && mutedUntil != event.MutedForever {
|
||||
// At the time of writing, arbitrary mute durations are supported by
|
||||
// Discord; you aren't restricted to the official client's choices
|
||||
// of 15 minutes, 1 hour, 3 hours, 8 hours, and 24 hours.
|
||||
secs := int(math.Round(msg.Content.GetMuteDuration().Seconds()))
|
||||
override.MuteConfig = &discordgo.MuteConfig{
|
||||
EndTime: &mutedUntil,
|
||||
SelectedTimeWindow: &secs,
|
||||
}
|
||||
}
|
||||
|
||||
overrides := make(map[string]*discordgo.UserGuildSettingsChannelOverrideEdit)
|
||||
overrides[ch.ID] = &override
|
||||
|
||||
edit := discordgo.UserGuildSettingsEdit{
|
||||
ChannelOverrides: overrides,
|
||||
}
|
||||
|
||||
log.Debug().Interface("muting_override", override).Msg("Computed channel override for mute")
|
||||
|
||||
guildID := ch.GuildID
|
||||
if guildID == "" {
|
||||
// Target private channels properly.
|
||||
guildID = "@me"
|
||||
}
|
||||
_, err := d.Session.UserGuildSettingsEdit(guildID, &edit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to edit guild settings in response to mute: %w", err)
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
56
pkg/connector/id.go
Normal file
56
pkg/connector/id.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
func (d *DiscordClient) portalKeyForChannel(ch *discordgo.Channel) networkid.PortalKey {
|
||||
switch ch.Type {
|
||||
case discordgo.ChannelTypeDM:
|
||||
return d.dmChannelPortalKey(ch.ID)
|
||||
case discordgo.ChannelTypeGroupDM:
|
||||
return d.groupDMChannelPortalKey(ch.ID)
|
||||
default:
|
||||
return d.guildChannelPortalKey(ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordClient) guildChannelPortalKey(channelID string) networkid.PortalKey {
|
||||
wantReceiver := d.connector.Bridge.Config.SplitPortals
|
||||
return discordid.MakeChannelPortalKey(channelID, d.UserLogin.ID, wantReceiver)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) groupDMChannelPortalKey(channelID string) networkid.PortalKey {
|
||||
// Same logic as guild channels (only specify a receiver when split portals
|
||||
// are enabled).
|
||||
return d.guildChannelPortalKey(channelID)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) dmChannelPortalKey(channelID string) networkid.PortalKey {
|
||||
// 1:1 DMs should _always_ have a receiver.
|
||||
return discordid.MakeChannelPortalKey(channelID, d.UserLogin.ID, true)
|
||||
}
|
||||
|
||||
func (d *DiscordClient) guildPortalKey(guildID string) networkid.PortalKey {
|
||||
wantReceiver := d.connector.Bridge.Config.SplitPortals
|
||||
return discordid.MakeGuildPortalKey(guildID, d.UserLogin.ID, wantReceiver)
|
||||
}
|
||||
76
pkg/connector/login.go
Normal file
76
pkg/connector/login.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
)
|
||||
|
||||
const LoginStepIDComplete = "fi.mau.discord.login.complete"
|
||||
|
||||
func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow {
|
||||
return []bridgev2.LoginFlow{
|
||||
{
|
||||
ID: LoginFlowIDBrowser,
|
||||
Name: "Browser",
|
||||
Description: "Log in to your Discord account in a web browser.",
|
||||
},
|
||||
{
|
||||
ID: LoginFlowIDRemoteAuth,
|
||||
Name: "QR Code",
|
||||
Description: "Scan a QR code with the Discord mobile app to log in.",
|
||||
},
|
||||
{
|
||||
ID: LoginFlowIDToken,
|
||||
Name: "Token",
|
||||
Description: "Provide a Discord user token to connect with.",
|
||||
},
|
||||
{
|
||||
ID: LoginFlowIDMachine,
|
||||
Name: "Email/Phone & Password",
|
||||
Description: "Log in with an email or phone number and a password. Supports multi-factor authentication (e.g. TOTP, SMS, etc.)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
|
||||
login := DiscordGenericLogin{
|
||||
connector: d,
|
||||
User: user,
|
||||
}
|
||||
|
||||
switch flowID {
|
||||
case LoginFlowIDToken:
|
||||
return &DiscordTokenLogin{DiscordGenericLogin: &login}, nil
|
||||
case LoginFlowIDRemoteAuth:
|
||||
return &DiscordRemoteAuthLogin{DiscordGenericLogin: &login}, nil
|
||||
case LoginFlowIDBrowser:
|
||||
return &DiscordBrowserLogin{DiscordGenericLogin: &login}, nil
|
||||
case LoginFlowIDMachine:
|
||||
mach, err := NewDiscordMachineLogin(ctx, &login)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set up discord login machine: %w", err)
|
||||
}
|
||||
|
||||
return mach, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown discord login flow id")
|
||||
}
|
||||
}
|
||||
97
pkg/connector/login_browser.go
Normal file
97
pkg/connector/login_browser.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
)
|
||||
|
||||
const LoginFlowIDBrowser = "token"
|
||||
|
||||
type DiscordBrowserLogin struct {
|
||||
*DiscordGenericLogin
|
||||
}
|
||||
|
||||
var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil)
|
||||
|
||||
const ExtractDiscordTokenJS = `
|
||||
new Promise((resolve) => {
|
||||
let mautrixDiscordTokenCheckInterval
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
document.head.append(iframe)
|
||||
|
||||
mautrixDiscordTokenCheckInterval = setInterval(() => {
|
||||
const token = iframe.contentWindow.localStorage.token
|
||||
if (token) {
|
||||
resolve({ token: token.slice(1, -1) })
|
||||
clearInterval(mautrixDiscordTokenCheckInterval)
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
`
|
||||
|
||||
func (dl *DiscordBrowserLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeCookies,
|
||||
StepID: "fi.mau.discord.cookies",
|
||||
Instructions: "Log in with Discord.",
|
||||
CookiesParams: &bridgev2.LoginCookiesParams{
|
||||
URL: "https://discord.com/login",
|
||||
UserAgent: "",
|
||||
Fields: []bridgev2.LoginCookieField{{
|
||||
ID: "token",
|
||||
Required: true,
|
||||
Sources: []bridgev2.LoginCookieFieldSource{{
|
||||
Type: bridgev2.LoginCookieTypeSpecial,
|
||||
Name: "fi.mau.discord.token",
|
||||
}},
|
||||
}},
|
||||
ExtractJS: ExtractDiscordTokenJS,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
token := cookies["token"]
|
||||
if token == "" {
|
||||
log.Error().Msg("Received empty token")
|
||||
return nil, fmt.Errorf("received empty token")
|
||||
}
|
||||
log.Debug().Msg("Logging in with submitted cookie")
|
||||
|
||||
ul, err := dl.FinalizeCreatingLogin(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't log in via browser: %w", err)
|
||||
}
|
||||
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeComplete,
|
||||
StepID: LoginStepIDComplete,
|
||||
Instructions: dl.CompleteInstructions(),
|
||||
CompleteParams: &bridgev2.LoginCompleteParams{
|
||||
UserLoginID: ul.ID,
|
||||
UserLogin: ul,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
104
pkg/connector/login_generic.go
Normal file
104
pkg/connector/login_generic.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||
)
|
||||
|
||||
// DiscordGenericLogin is embedded within each struct that implements
|
||||
// bridgev2.LoginProcess in order to encapsulate the common behavior that needs
|
||||
// to occur after procuring a valid user token. Namely, creating a gateway
|
||||
// connection to Discord and an associated UserLogin to wrap things up.
|
||||
//
|
||||
// It also implements a baseline Cancel method that closes the gateway
|
||||
// connection.
|
||||
type DiscordGenericLogin struct {
|
||||
User *bridgev2.User
|
||||
connector *DiscordConnector
|
||||
|
||||
Session *discordgo.Session
|
||||
|
||||
// The Discord user we've authenticated as. This is only non-nil if
|
||||
// a call to FinalizeCreatingLogin has succeeded.
|
||||
DiscordUser *discordgo.User
|
||||
}
|
||||
|
||||
func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("action", "finalize login").Logger()
|
||||
|
||||
// TODO we don't need an entire discordgo session for this as we're just
|
||||
// interested in /users/@me
|
||||
log.Info().Msg("Creating initial session with provided token")
|
||||
session, err := NewDiscordSession(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't create discord session: %w", err)
|
||||
}
|
||||
dl.Session = session
|
||||
|
||||
log.Info().Msg("Requesting @me with provided token")
|
||||
self, err := session.User("@me")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't request self user (bad credentials?): %w", err)
|
||||
}
|
||||
dl.DiscordUser = self
|
||||
|
||||
log.Info().Msg("Fetched @me")
|
||||
ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
|
||||
ID: discordid.MakeUserLoginID(self.ID),
|
||||
// (This will lack an avatar. Don't want to block login finalization on
|
||||
// downloading it.)
|
||||
RemoteProfile: makeRemoteProfile(self, nil),
|
||||
RemoteName: makeRemoteName(self),
|
||||
Metadata: &discordid.UserLoginMetadata{
|
||||
Token: token,
|
||||
HeartbeatSession: session.HeartbeatSession,
|
||||
},
|
||||
}, &bridgev2.NewLoginParams{
|
||||
DeleteOnConflict: true,
|
||||
})
|
||||
if err != nil {
|
||||
dl.Cancel()
|
||||
return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
|
||||
}
|
||||
|
||||
(ul.Client.(*DiscordClient)).Connect(ctx)
|
||||
|
||||
return ul, nil
|
||||
}
|
||||
|
||||
func (dl *DiscordGenericLogin) CompleteInstructions() string {
|
||||
return fmt.Sprintf("Logged in as %s", dl.DiscordUser.Username)
|
||||
}
|
||||
|
||||
func (dl *DiscordGenericLogin) Cancel() {
|
||||
if dl.Session != nil {
|
||||
dl.User.Log.Debug().Msg("Login cancelled, closing session")
|
||||
err := dl.Session.Close()
|
||||
if err != nil {
|
||||
dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
668
pkg/connector/login_machine.go
Normal file
668
pkg/connector/login_machine.go
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||
// Copyright (C) 2026 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordauth"
|
||||
)
|
||||
|
||||
const LoginFlowIDMachine = "machine"
|
||||
const LoginStepIDMachineInitialCreds = "fi.mau.discord.creds"
|
||||
const LoginStepIDMachineWait = "fi.mau.discord.wait"
|
||||
const LoginStepIDMachineCaptcha = "fi.mau.discord.captcha"
|
||||
const LoginStepIDMachineMFAMethod = "fi.mau.discord.mfa.method"
|
||||
const LoginStepIDMachineMFATOTP = "fi.mau.discord.mfa.totp"
|
||||
const LoginStepIDMachineMFABackup = "fi.mau.discord.mfa.backup"
|
||||
const LoginStepIDMachineMFASMS = "fi.mau.discord.mfa.sms"
|
||||
const InputDataFieldIDUsernameOrPhone = "username_or_phone"
|
||||
const InputDataFieldIDPassword = "password"
|
||||
const InputDataFieldIDMFAMethod = "mfa_method"
|
||||
const InputDataFieldIDMFABackupCode = "backup_code"
|
||||
const InputDataFieldIDMFASMSCode = "sms_code"
|
||||
const InputDataFieldIDMFATOTPCode = "totp_code"
|
||||
|
||||
type mfaOption string
|
||||
|
||||
const (
|
||||
mfaSms mfaOption = "Text me a code"
|
||||
mfaTotp mfaOption = "Use my authenticator app"
|
||||
mfaBackup mfaOption = "Enter a backup code"
|
||||
)
|
||||
|
||||
// For simplicity, AuthMachine exposes a blocking, "straight-line" API:
|
||||
// Prepare/Login do not yield intermediate preemption flows. Instead, they
|
||||
// synchronously call back into our ChallengeHandler methods (e.g. ContinueMFA
|
||||
// or SolveCaptcha) whenever user input is needed. CAPTCHA handling makes this
|
||||
// especially awkward, as any request in the flow may be preempted by one or
|
||||
// more CAPTCHA challenges before the original request can complete. This is
|
||||
// documented in further detail in the discordauth package.
|
||||
//
|
||||
// Anyhow, bridgev2 is the opposite shape: login is step-based and
|
||||
// request-scoped, and each provisioning request must return a LoginStep before
|
||||
// its context is canceled. To bridge that mismatch, AuthMachine runs on a
|
||||
// long-lived background goroutine. That worker emits signals such as "prompt
|
||||
// the user", "login complete", or "login failed", and DiscordMachineLogin
|
||||
// translates them into bridgev2 steps. User replies are then forwarded back to
|
||||
// the worker so the synchronous AuthMachine flow can continue. Channels are
|
||||
// used to bridge the gap.
|
||||
//
|
||||
// In practice, this means returning a dummy DisplayAndWait step to hand
|
||||
// control back to bridgev2 as our Wait method drains the next signal.
|
||||
// CAPTCHA challenges reuse this plumbing via LoginStepTypeCookies, dispatching
|
||||
// through SubmitCookies.
|
||||
|
||||
type DiscordMachineLogin struct {
|
||||
*DiscordGenericLogin
|
||||
Machine *discordauth.AuthMachine
|
||||
|
||||
machineCtx context.Context
|
||||
cancelMachine context.CancelFunc
|
||||
|
||||
currentlyPending *pendingPrompt
|
||||
currentlyPendingMu sync.Mutex
|
||||
|
||||
signals chan machineSignal
|
||||
}
|
||||
|
||||
type machineSignal struct {
|
||||
prompt *pendingPrompt
|
||||
done *discordauth.LoginCompleted
|
||||
err error
|
||||
}
|
||||
type pendingPrompt struct {
|
||||
step *bridgev2.LoginStep
|
||||
reply chan map[string]string
|
||||
}
|
||||
|
||||
var _ discordauth.ChallengeHandler = (*DiscordMachineLogin)(nil)
|
||||
var _ bridgev2.LoginProcessUserInput = (*DiscordMachineLogin)(nil)
|
||||
var _ bridgev2.LoginProcessCookies = (*DiscordMachineLogin)(nil)
|
||||
var _ bridgev2.LoginProcessDisplayAndWait = (*DiscordMachineLogin)(nil)
|
||||
|
||||
func NewDiscordMachineLogin(ctx context.Context, login *DiscordGenericLogin) (*DiscordMachineLogin, error) {
|
||||
http := login.User.Bridge.GetHTTPClientSettings().Compile()
|
||||
|
||||
launchSig, err := discordgo.NewVanillaSignature()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate launch signature: %w", err)
|
||||
}
|
||||
|
||||
personality := discordauth.Personality{
|
||||
UserAgent: discordgo.DroidBrowserUserAgent,
|
||||
Locale: "en-US",
|
||||
TimeZone: "UTC",
|
||||
DebugOptions: discordauth.DefaultDebugOptions,
|
||||
// TODO dedupe with droid.go in discordgo
|
||||
SuperProperties: discordauth.SuperProperties{
|
||||
OS: "Windows",
|
||||
Browser: "Chrome",
|
||||
SystemLocale: "en-US",
|
||||
HasClientMods: false,
|
||||
BrowserUserAgent: discordgo.DroidBrowserUserAgent,
|
||||
BrowserVersion: discordgo.DroidBrowserVersion,
|
||||
OSVersion: "10",
|
||||
ReleaseChannel: "stable",
|
||||
ClientBuildNumber: 497254,
|
||||
ClientLaunchID: uuid.NewString(),
|
||||
LaunchSignature: launchSig,
|
||||
ClientAppState: "focused",
|
||||
},
|
||||
ExtraHeaders: map[string]string{
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
},
|
||||
}
|
||||
|
||||
ml := &DiscordMachineLogin{
|
||||
DiscordGenericLogin: login,
|
||||
}
|
||||
ml.Machine = discordauth.NewAuthMachine(ctx, http, &personality, ml)
|
||||
return ml, nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) ContinueMFA(ctx context.Context, challenge *discordauth.MFAChallenge) (*discordauth.MFAContinue, error) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("action", "discord machine continue mfa").
|
||||
Str("login_instance_id", challenge.LoginInstanceID).
|
||||
Bool("mfa_required", challenge.MFARequired).
|
||||
Bool("mfa_sms_enabled", challenge.SMSEnabled).
|
||||
Bool("mfa_totp_enabled", challenge.TOTPEnabled).
|
||||
Bool("mfa_backup_codes_accepted", challenge.BackupCodesAccepted).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
log.Info().Msg("Entering MFA login flow")
|
||||
|
||||
mfaOptions := make([]string, 0)
|
||||
// (Reusing the identifier strings for each authenticator method from
|
||||
// discordauth as the option enumeration values for the user prompt.)
|
||||
if challenge.SMSEnabled {
|
||||
mfaOptions = append(mfaOptions, string(mfaSms))
|
||||
}
|
||||
if challenge.TOTPEnabled {
|
||||
mfaOptions = append(mfaOptions, string(mfaTotp))
|
||||
}
|
||||
if challenge.BackupCodesAccepted {
|
||||
mfaOptions = append(mfaOptions, string(mfaBackup))
|
||||
}
|
||||
|
||||
if len(mfaOptions) == 0 {
|
||||
return nil, fmt.Errorf("no supported MFA methods available (WebAuthn is unimplemented)")
|
||||
}
|
||||
|
||||
input, err := d.promptUser(ctx, &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDMachineMFAMethod,
|
||||
Instructions: "How do you want to verify it’s you?",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{
|
||||
{
|
||||
Type: bridgev2.LoginInputFieldTypeSelect,
|
||||
ID: InputDataFieldIDMFAMethod,
|
||||
Name: "Verification Method",
|
||||
Options: mfaOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prompt for MFA method: %w", err)
|
||||
}
|
||||
|
||||
selectedMethod := mfaOption(input[InputDataFieldIDMFAMethod])
|
||||
|
||||
log = log.With().Str("mfa_selected_method", string(selectedMethod)).Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
log.Info().Msg("User selected MFA method")
|
||||
|
||||
switch selectedMethod {
|
||||
case mfaBackup:
|
||||
input, err := d.promptUser(ctx, &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDMachineMFABackup,
|
||||
Instructions: "If your authenticator app is unavailable, you can sign in with a backup code. Backup codes are meant for emergencies only.",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{
|
||||
{
|
||||
Type: bridgev2.LoginInputFieldTypePassword,
|
||||
ID: InputDataFieldIDMFABackupCode,
|
||||
Name: "Backup code",
|
||||
Description: "You won’t be able to use this backup code again.",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prompt user for backup code: %w", err)
|
||||
}
|
||||
log.Info().Msg("Received backup code from user, proceeding")
|
||||
|
||||
backupCode := strings.TrimSpace(strings.ReplaceAll(
|
||||
input[InputDataFieldIDMFABackupCode],
|
||||
"-",
|
||||
"",
|
||||
))
|
||||
return &discordauth.MFAContinue{
|
||||
Type: discordauth.AuthenticatorBackup,
|
||||
MFAContinuation: discordauth.MFAContinuation{
|
||||
MFAState: challenge.MFAState,
|
||||
Code: backupCode,
|
||||
},
|
||||
}, nil
|
||||
case mfaTotp:
|
||||
input, err := d.promptUser(ctx, &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDMachineMFATOTP,
|
||||
Instructions: "Enter the code from your authenticator app.",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{
|
||||
{
|
||||
Type: bridgev2.LoginInputFieldType2FACode,
|
||||
ID: InputDataFieldIDMFATOTPCode,
|
||||
Name: "Authentication code",
|
||||
// TODO enforce length
|
||||
Pattern: `^(\d+)$`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prompt user for TOTP code: %w", err)
|
||||
}
|
||||
log.Info().Msg("Received TOTP code from user, proceeding")
|
||||
|
||||
totpCode := input[InputDataFieldIDMFATOTPCode]
|
||||
return &discordauth.MFAContinue{
|
||||
Type: discordauth.AuthenticatorTOTP,
|
||||
MFAContinuation: discordauth.MFAContinuation{
|
||||
MFAState: challenge.MFAState,
|
||||
Code: totpCode,
|
||||
},
|
||||
}, nil
|
||||
case mfaSms:
|
||||
log.Info().Msg("Requesting SMS from Discord")
|
||||
_, err := challenge.RequestSMS(ctx)
|
||||
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to request SMS from Discord")
|
||||
return nil, fmt.Errorf("failed to ask discord to send SMS: %w", err)
|
||||
}
|
||||
log.Info().Msg("Requested SMS from Discord")
|
||||
|
||||
input, err := d.promptUser(ctx, &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDMachineMFASMS,
|
||||
Instructions: "Enter the code Discord just texted you.",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{
|
||||
{
|
||||
Description: "The code might take a moment to arrive.",
|
||||
ID: InputDataFieldIDMFASMSCode,
|
||||
Name: "Verification code",
|
||||
// TODO enforce length
|
||||
Pattern: `^(\d+)$`,
|
||||
Type: bridgev2.LoginInputFieldType2FACode,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prompt user for SMS code: %w", err)
|
||||
}
|
||||
smsCode := strings.TrimSpace(input[InputDataFieldIDMFASMSCode])
|
||||
log.Info().Msg("Received SMS code from user, proceeding")
|
||||
|
||||
return &discordauth.MFAContinue{
|
||||
Type: discordauth.AuthenticatorSMS,
|
||||
MFAContinuation: discordauth.MFAContinuation{
|
||||
MFAState: challenge.MFAState,
|
||||
Code: smsCode,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown mfa method %v", selectedMethod)
|
||||
}
|
||||
}
|
||||
|
||||
type ExtractionConfig struct {
|
||||
SiteKey string `json:"siteKey"`
|
||||
Invisible bool `json:"invisible"`
|
||||
RqData string `json:"rqdata,omitempty"`
|
||||
}
|
||||
|
||||
const CaptchaExtractionField = "captcha_token"
|
||||
|
||||
// FIXME: This redirection stub is only necessary to work around some behavior in
|
||||
// Beeper Desktop where it only attaches the event listeners that dispatch the
|
||||
// injected JS snippets after the page loads completely. We can't run JavaScript
|
||||
// "upon load", which is what we really want here. To get around that, we can
|
||||
// load a small page that merely forces a redirection to the right origin.
|
||||
//
|
||||
// (The exact Discord URL we end up at here is mostly irrelevant, but it would
|
||||
// be nice to avoid loading the actual SPA.)
|
||||
const captchaRedirectionStub = `<!DOCTYPE html>
|
||||
<title>Loading</title>
|
||||
<meta http-equiv="refresh" content="1;url=https://discord.com/company-information">`
|
||||
const captchaExtractionJSTemplate = `new Promise((res0, rej0) => {
|
||||
if (!window.location.hostname.endsWith('discord.com')) {
|
||||
return
|
||||
}
|
||||
if (window.__meow_captchaPromise) {
|
||||
window.__meow_captchaPromise.then(res0, rej0)
|
||||
return
|
||||
}
|
||||
|
||||
const CFG = %__CONFIG_REPLACEME__%
|
||||
window.__meow_captchaPromise = new Promise((resolve, reject) => {
|
||||
window.__meow_h = () => {
|
||||
const c = document.createElement('div')
|
||||
c.style.cssText = 'position:fixed;inset:0;z-index:2147483646;' +
|
||||
'background:#fff;display:flex;align-items:center;' +
|
||||
'justify-content:center;padding:2rem'
|
||||
document.body.append(c)
|
||||
|
||||
const id = hcaptcha.render(c, {
|
||||
sitekey: CFG.siteKey,
|
||||
size: CFG.invisible ? 'invisible' : 'normal',
|
||||
callback: (token) => resolve({ captcha_token: token }),
|
||||
'error-callback': (e) => reject(new Error('hcaptcha: ' + e)),
|
||||
'expired-callback': () => reject(new Error('hcaptcha token expired')),
|
||||
'chalexpired-callback': () => reject(new Error('hcaptcha challenge expired')),
|
||||
})
|
||||
|
||||
if (CFG.rqdata) {
|
||||
hcaptcha.setData(id, {rqdata: CFG.rqdata})
|
||||
}
|
||||
if (CFG.invisible) {
|
||||
hcaptcha.execute(id)
|
||||
}
|
||||
}
|
||||
|
||||
const s = document.createElement('script')
|
||||
s.src = 'https://js.hcaptcha.com/1/api.js?render=explicit&onload=__meow_h&recaptchacompat=off'
|
||||
s.onerror = () => reject(new Error('failed to load hcaptcha'))
|
||||
document.head.append(s)
|
||||
})
|
||||
|
||||
window.__meow_captchaPromise.then(res0, rej0)
|
||||
})`
|
||||
|
||||
func captchaExtractionJS(cap *discordauth.Captcha) (string, error) {
|
||||
cfg := ExtractionConfig{
|
||||
Invisible: cap.Invisible,
|
||||
}
|
||||
if cap.SiteKey != nil {
|
||||
cfg.SiteKey = *cap.SiteKey
|
||||
}
|
||||
if cap.RqData != nil {
|
||||
cfg.RqData = *cap.RqData
|
||||
}
|
||||
|
||||
stateJSON, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal extraction state: %w", err)
|
||||
}
|
||||
|
||||
return strings.Replace(captchaExtractionJSTemplate, "%__CONFIG_REPLACEME__%", string(stateJSON), 1), nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) SolveCaptcha(ctx context.Context, cap *discordauth.Captcha) (*discordauth.CaptchaSolution, error) {
|
||||
log := cap.LogContext(zerolog.Ctx(ctx).With()).Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
log.Info().Msg("Encountered CAPTCHA challenge")
|
||||
|
||||
if cap.Service != discordauth.CaptchaServiceHCaptcha {
|
||||
return nil, fmt.Errorf("%s captchas are currently unsupported", cap.Service)
|
||||
}
|
||||
|
||||
extractJS, err := captchaExtractionJS(cap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute captcha extraction JS: %w", err)
|
||||
}
|
||||
log.Debug().Str("captcha_js", extractJS).Msg("Computed CAPTCHA solution extraction JS")
|
||||
|
||||
dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(captchaRedirectionStub))
|
||||
|
||||
input, err := d.promptUser(ctx, &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeCookies,
|
||||
StepID: LoginStepIDMachineCaptcha,
|
||||
Instructions: "Discord is presenting a CAPTCHA challenge.",
|
||||
CookiesParams: &bridgev2.LoginCookiesParams{
|
||||
URL: dataURL,
|
||||
ExtractJS: extractJS,
|
||||
Fields: []bridgev2.LoginCookieField{{
|
||||
ID: CaptchaExtractionField,
|
||||
Required: true,
|
||||
Sources: []bridgev2.LoginCookieFieldSource{{
|
||||
Type: bridgev2.LoginCookieTypeSpecial,
|
||||
Name: CaptchaExtractionField,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prompt user to solve captcha: %w", err)
|
||||
}
|
||||
|
||||
solutionToken := input[CaptchaExtractionField]
|
||||
if solutionToken == "" {
|
||||
return nil, fmt.Errorf("extracted captcha solution is blank")
|
||||
}
|
||||
|
||||
return &discordauth.CaptchaSolution{
|
||||
Solution: solutionToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) Cancel() {
|
||||
d.DiscordGenericLogin.Cancel()
|
||||
if d.cancelMachine != nil {
|
||||
d.cancelMachine()
|
||||
}
|
||||
}
|
||||
|
||||
func credsStep() *bridgev2.LoginStep {
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDMachineInitialCreds,
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{
|
||||
{
|
||||
Type: bridgev2.LoginInputFieldTypeUsername,
|
||||
ID: InputDataFieldIDUsernameOrPhone,
|
||||
Name: "Email or phone number",
|
||||
},
|
||||
{
|
||||
Type: bridgev2.LoginInputFieldTypePassword,
|
||||
ID: InputDataFieldIDPassword,
|
||||
Name: "Password",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func waitStep() *bridgev2.LoginStep {
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeDisplayAndWait,
|
||||
StepID: LoginStepIDMachineWait,
|
||||
Instructions: "Waiting for Discord…",
|
||||
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
|
||||
Type: bridgev2.LoginDisplayTypeNothing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
return credsStep(), nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
|
||||
return d.tryDrainingPendingPrompt(ctx, cookies), nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
// User input was submitted as part of a prompt that the worker signaled to
|
||||
// us.
|
||||
step := d.tryDrainingPendingPrompt(ctx, input)
|
||||
if step != nil {
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// Initial submission of the username/phone and password.
|
||||
username := strings.TrimSpace(input[InputDataFieldIDUsernameOrPhone])
|
||||
password := discordauth.NewSensitive(input[InputDataFieldIDPassword])
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("no username provided")
|
||||
}
|
||||
if password.IsZero() {
|
||||
return nil, fmt.Errorf("no password provided")
|
||||
}
|
||||
|
||||
log.Info().Msg("Starting worker goroutine")
|
||||
err := d.startWorker(ctx, &discordauth.Creds{
|
||||
Login: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start login worker: %w", err)
|
||||
}
|
||||
|
||||
return waitStep(), nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) tryDrainingPendingPrompt(ctx context.Context, input map[string]string) *bridgev2.LoginStep {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
d.currentlyPendingMu.Lock()
|
||||
// (Avoid holding the mutex across the channel send.)
|
||||
pending := d.currentlyPending
|
||||
d.currentlyPending = nil
|
||||
d.currentlyPendingMu.Unlock()
|
||||
|
||||
if pending == nil {
|
||||
log.Debug().Msg("No pending prompt")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Str("pending_step_id", pending.step.StepID).
|
||||
Msg("Received user input for pending step ID, sending reply")
|
||||
pending.reply <- input
|
||||
|
||||
// Go back to waiting for the worker to send a signal.
|
||||
return waitStep()
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) startWorker(ctx context.Context, creds *discordauth.Creds) error {
|
||||
// Act as a sort of "mailbox"; only buffer 1 signal at a time. Not
|
||||
// unbuffered because it wouldn't be ideal to block the worker goroutine on
|
||||
// waiting for the signal to be "consumed" per se.
|
||||
d.signals = make(chan machineSignal, 1)
|
||||
|
||||
// Don't want ourselves to get cancelled if the enclosing context does, but
|
||||
// we do want to preserve the data inside of the context (such as logging
|
||||
// stuff).
|
||||
//
|
||||
// Also, shadow the original context to avoid using it by accident.
|
||||
ctx, d.cancelMachine = context.WithCancel(context.WithoutCancel(ctx))
|
||||
d.machineCtx = ctx
|
||||
|
||||
go func() {
|
||||
// It's important that these calls occur on a goroutine because
|
||||
// AuthMachine methods can call into our handlers (e.g. ContinueMFA),
|
||||
// which need to synchronously prompt the user, and we need both sides
|
||||
// of the reply/signal channels to work in order to avoid a deadlock.
|
||||
|
||||
err := d.Machine.Prepare(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to prepare login: %w", err)
|
||||
_ = d.signal(d.machineCtx, machineSignal{err: err})
|
||||
return
|
||||
}
|
||||
|
||||
done, err := d.Machine.Login(ctx, creds)
|
||||
log := zerolog.Ctx(ctx)
|
||||
if err == nil {
|
||||
log.Info().
|
||||
Any("required_actions", done.RequiredActions).
|
||||
Msg("Login finished")
|
||||
} else {
|
||||
// FIXME detect bad password/username and just retry the step
|
||||
// instead of failing out
|
||||
log.Err(err).Msg("Login failed")
|
||||
}
|
||||
|
||||
// At the moment this can only error if we get canceled, and we don't
|
||||
// really care about that here. Just signal so we can tell bridgev2.
|
||||
_ = d.signal(d.machineCtx, machineSignal{done: done, err: err})
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signal should only be called by the background goroutine, and is used to
|
||||
// control the bridgev2 login process.
|
||||
func (d *DiscordMachineLogin) signal(ctx context.Context, sig machineSignal) error {
|
||||
select {
|
||||
case d.signals <- sig:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// promptUser should only be called by the background goroutine, and is used to
|
||||
// send a [bridgev2.LoginStep] to be presented to the user. The submitted
|
||||
// inputs are collected via channel and returned.
|
||||
func (d *DiscordMachineLogin) promptUser(ctx context.Context, step *bridgev2.LoginStep) (map[string]string, error) {
|
||||
reply := make(chan map[string]string, 1)
|
||||
pending := &pendingPrompt{step, reply}
|
||||
if err := d.signal(ctx, machineSignal{prompt: pending}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case input, ok := <-pending.reply:
|
||||
if !ok {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
return input, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) finalize(ctx context.Context, done *discordauth.LoginCompleted) (*bridgev2.LoginStep, error) {
|
||||
ul, err := d.FinalizeCreatingLogin(ctx, done.Token.UnwrapSensitive())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't log in via machine: %w", err)
|
||||
}
|
||||
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeComplete,
|
||||
StepID: LoginStepIDComplete,
|
||||
CompleteParams: &bridgev2.LoginCompleteParams{
|
||||
UserLoginID: ul.ID,
|
||||
UserLogin: ul,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordMachineLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
select {
|
||||
case signal := <-d.signals:
|
||||
if signal.err != nil {
|
||||
return nil, signal.err
|
||||
}
|
||||
|
||||
if signal.done != nil {
|
||||
return d.finalize(ctx, signal.done)
|
||||
}
|
||||
|
||||
// Sanity check.
|
||||
if signal.prompt == nil {
|
||||
return nil, fmt.Errorf("unexpected empty prompt")
|
||||
}
|
||||
|
||||
// Stash the prompt that we're about to show to the user so that we
|
||||
// can properly reply when mautrix calls our SubmitUserInput method.
|
||||
d.currentlyPendingMu.Lock()
|
||||
d.currentlyPending = signal.prompt
|
||||
d.currentlyPendingMu.Unlock()
|
||||
|
||||
return signal.prompt.step, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue