mirror of
https://github.com/mautrix/discord.git
synced 2026-11-09 15:49:18 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0ea259f92 | ||
|
|
0e509a7ce1 | ||
|
|
4cd87ec1b5 |
||
|
|
52cff5853f |
||
|
|
13636de20f |
||
|
|
28e559c5bd |
||
|
|
d17912102c |
||
|
|
a8a0e0a57b |
||
|
|
28310fd806 |
||
|
|
d93624c815 |
||
|
|
e834d3abdc |
||
|
|
2e7fcc6119 |
||
|
|
786d32096b |
||
|
|
4f82d1a9de |
||
|
|
cbc53a658a |
||
|
|
51fae00001 | ||
|
|
2ab5df8259 |
||
|
|
7f73c93f36 |
||
|
|
053bd08826 | ||
|
|
e0daf0ed13 |
||
|
|
7e84766c51 |
||
|
|
5b528a6aed |
||
|
|
e0d509263b |
||
|
|
bf7bb1190e |
||
|
|
e8de753a8d |
||
|
|
d7da4169c9 |
||
|
|
f7e6160909 |
||
|
|
2cdbe57a4b |
||
|
|
28043011de |
||
|
|
bd79ef8276 |
||
|
|
bce44c64dc |
||
|
|
097d3397a3 |
||
|
|
540e2d9487 |
||
|
|
7d2d590898 |
||
|
|
accd7d3e29 |
||
|
|
f70c356b2a | ||
|
|
2116e16923 | ||
|
|
28187f2718 | ||
|
|
d892da17a1 | ||
|
|
6c79932f94 |
||
|
|
e83888f174 | ||
|
|
c85f727c0e | ||
|
|
872958ba09 |
||
|
|
3e27bb029b | ||
|
|
281bf9c027 | ||
|
|
6501891a2b |
||
|
|
7b36a30cd1 |
||
|
|
7e7ad561ca | ||
|
|
1fbf12709a | ||
|
|
61d3e904b5 | ||
|
|
005afd448c | ||
|
|
7727bdfcc5 | ||
|
|
04b40a6cc5 | ||
|
|
4721fca436 | ||
|
|
b12444c197 | ||
|
|
295957290f | ||
|
|
948b6d9321 | ||
|
|
e52c19533e | ||
|
|
5cda45c29f | ||
|
|
3de7b918ac | ||
|
|
17ce47e772 | ||
|
|
75a27726bd | ||
|
|
317876a0b6 | ||
|
|
bf19aafe6f | ||
|
|
86da1edd6c | ||
|
|
3fc2e39b88 | ||
|
|
ae24c77e02 | ||
|
|
0c61a6db4c | ||
|
|
e2d47d7d32 | ||
|
|
8bcc39ec3b | ||
|
|
3f37f0cb58 | ||
|
|
96a14ce0ff | ||
|
|
c2cd95e7ba | ||
|
|
5abc733383 | ||
|
|
d052403358 | ||
|
|
8520692901 | ||
|
|
6faac571bb | ||
|
|
51adda3a87 | ||
|
|
1ca066c15b | ||
|
|
a8ecf2e4b7 | ||
|
|
7061d2109f | ||
|
|
2541cc2f56 | ||
|
|
aab23b50bf | ||
|
|
ab65bb659a | ||
|
|
c88b84dc84 | ||
|
|
a9fab25784 | ||
|
|
b5dad96026 | ||
|
|
e491ff6e0c | ||
|
|
740ca9e29d | ||
|
|
f2565dda83 | ||
|
|
869e9f2519 | ||
|
|
3f7fd707df | ||
|
|
fba984b489 | ||
|
|
02982000be | ||
|
|
3921bb1f1b | ||
|
|
892aede394 | ||
|
|
0d779c477c | ||
|
|
bca69922ba | ||
|
|
3762fb09f6 | ||
|
|
1d50d4b16d | ||
|
|
025d24eb6c | ||
|
|
5f13d00432 | ||
|
|
012b9293e5 | ||
|
|
a4faa82ac6 | ||
|
|
97ae7f687f | ||
|
|
ce30aaa726 | ||
|
|
e928815109 | ||
|
|
55ef8e6adf | ||
|
|
b820bdc214 | ||
|
|
184ce82bbe | ||
|
|
5eb78e86e4 | ||
|
|
064d71c688 | ||
|
|
b05860dccb | ||
|
|
3ef21aabaf | ||
|
|
e8a6e93ce0 |
||
|
|
41ae5735b9 | ||
|
|
5457c76a4a | ||
|
|
9ddec5ed24 | ||
|
|
e1adfc5179 | ||
|
|
c7abb32c2f | ||
|
|
99d88e9cbf | ||
|
|
3bcefc06e8 | ||
|
|
ffe5bc86af | ||
|
|
f9f8355027 | ||
|
|
2b487d3025 | ||
|
|
185a12a48e |
||
|
|
72a384db3d | ||
|
|
cac3a11000 | ||
|
|
c12aaa46d3 | ||
|
|
89fc26d839 | ||
|
|
62091bfc2a | ||
|
|
91ecfd4d8f | ||
|
|
f54ce7f05f | ||
|
|
1595e76434 | ||
|
|
5d3595eee7 | ||
|
|
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 |
202 changed files with 36890 additions and 13468 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
|
||||
|
|
|
|||
14
.github/workflows/go.yml
vendored
14
.github/workflows/go.yml
vendored
|
|
@ -2,6 +2,9 @@ name: Go
|
|||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -23,13 +26,12 @@ 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
|
||||
go install github.com/beeper/pre-commit-go/bridgelint@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
|
||||
|
|
|
|||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
lock-stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v6
|
||||
- uses: dessant/lock-threads@v5
|
||||
id: lock
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,3 +7,5 @@
|
|||
|
||||
/mautrix-discord
|
||||
/start
|
||||
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -12,9 +12,18 @@ repos:
|
|||
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.5.0
|
||||
hooks:
|
||||
- id: zerolog-ban-msgf
|
||||
- id: zerolog-use-stringer
|
||||
- id: bridgelint
|
||||
language: system
|
||||
verbose: true
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,22 +1,3 @@
|
|||
# v0.7.7 (2026-08-16)
|
||||
|
||||
* Updated Docker image to Alpine 3.24.
|
||||
* Added option to disable DMs to non-friends.
|
||||
* Fixed panic if editing a message fails.
|
||||
* Fixed websocket pings when using bot accounts on Discord.
|
||||
|
||||
# v0.7.6 (2026-02-16)
|
||||
|
||||
* Bumped minimum Go version to 1.25.
|
||||
* Updated Docker image to Alpine 3.23.
|
||||
* Added support for following tombstones.
|
||||
* Added support for disabling link previews in messages sent to Discord using
|
||||
[MSC4095].
|
||||
* Added support for federation thumbnail endpoint when using direct media.
|
||||
* Disabled using `restricted` join rules by default.
|
||||
|
||||
[MSC4095]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095
|
||||
|
||||
# v0.7.5 (2025-07-16)
|
||||
|
||||
* Fixed federation key response when using direct media.
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ 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
|
||||
WORKDIR /data
|
||||
|
||||
CMD ["/docker-run.sh"]
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ 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
|
||||
WORKDIR /data
|
||||
|
||||
CMD ["/docker-run.sh"]
|
||||
|
|
|
|||
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 "$@"
|
||||
|
|
|
|||
579
cmd/authtester/main.go
Normal file
579
cmd/authtester/main.go
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"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/util/exhttp"
|
||||
|
||||
"go.mau.fi/mautrix-discord/pkg/discordauth"
|
||||
"go.mau.fi/mautrix-discord/pkg/discordtransport"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
client, err := discordtransport.CompileTransport(exhttp.SensibleClientSettings, discordtransport.TransportOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth http client: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
machine.APIBase = apiBase
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Preparing Discord auth...")
|
||||
if err = machine.Prepare(ctx); err != nil {
|
||||
return fmt.Errorf("failed to prepare auth machine: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Logging in...")
|
||||
resp, err := prompter.driveAuthMachine(ctx, machine)
|
||||
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
|
||||
|
||||
mfaChallenge *discordauth.LoginMFARequired
|
||||
}
|
||||
|
||||
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 (p *prompter) driveAuthMachine(ctx context.Context, machine *discordauth.AuthMachine) (*discordauth.LoginCompleted, error) {
|
||||
var answer *discordauth.Answer
|
||||
for {
|
||||
prompt, done, err := machine.Advance(ctx, answer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if done != nil {
|
||||
return done, nil
|
||||
}
|
||||
if prompt == nil {
|
||||
return nil, fmt.Errorf("auth machine did not advance")
|
||||
}
|
||||
|
||||
answer, err = p.answerPrompt(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prompter) answerPrompt(ctx context.Context, prompt *discordauth.Prompt) (*discordauth.Answer, error) {
|
||||
switch {
|
||||
case prompt.CredsPrompt != nil:
|
||||
return p.answerCreds(prompt.CredsPrompt)
|
||||
case prompt.Captcha != nil:
|
||||
solution, err := p.SolveCaptcha(ctx, prompt.Captcha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &discordauth.Answer{Solution: solution}, nil
|
||||
case prompt.EmailVerifyPrompt != nil:
|
||||
if err := p.WaitForEmailVerification(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &discordauth.Answer{}, nil
|
||||
case prompt.MFAChallengePrompt != nil:
|
||||
return p.answerMFAMethod(prompt.MFAChallengePrompt)
|
||||
case prompt.MFACodePrompt != nil:
|
||||
return p.answerMFACode(prompt.MFACodePrompt)
|
||||
default:
|
||||
return nil, fmt.Errorf("auth machine returned an empty prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prompter) answerCreds(prompt *discordauth.CredsPrompt) (*discordauth.Answer, error) {
|
||||
if prompt.Reason != "" {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintf(p.out, "Discord rejected those credentials: %s\n", prompt.Reason)
|
||||
}
|
||||
|
||||
login, err := p.promptRequired("Email or phone")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read login: %w", err)
|
||||
}
|
||||
password, err := p.promptSecretRequired("Password")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
|
||||
return &discordauth.Answer{
|
||||
Creds: discordauth.NewCreds(login, password),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func supportedMFAMethods(challenge *discordauth.LoginMFARequired) []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.LoginMFARequired, authType discordauth.AuthenticatorType, code string) *discordauth.MFAContinue {
|
||||
return &discordauth.MFAContinue{
|
||||
Type: authType,
|
||||
MFAContinuation: discordauth.MFAContinuation{
|
||||
MFAState: challenge.MFAState,
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prompter) answerMFAMethod(prompt *discordauth.MFAChallengePrompt) (*discordauth.Answer, error) {
|
||||
challenge := prompt.LoginMFARequired
|
||||
if challenge == nil {
|
||||
return nil, fmt.Errorf("auth machine returned an MFA prompt without a challenge")
|
||||
}
|
||||
|
||||
if prompt.Reason != "" {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintf(p.out, "Discord rejected that MFA response: %s\n", prompt.Reason)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if selected.Type == discordauth.AuthenticatorSMS {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Requesting an MFA SMS code...")
|
||||
}
|
||||
|
||||
p.mfaChallenge = challenge
|
||||
return &discordauth.Answer{
|
||||
PickedMFAType: &selected.Type,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *prompter) answerMFACode(prompt *discordauth.MFACodePrompt) (*discordauth.Answer, error) {
|
||||
if p.mfaChallenge == nil {
|
||||
return nil, fmt.Errorf("auth machine requested an MFA code before an MFA challenge")
|
||||
}
|
||||
|
||||
selected := mfaMethodOption{
|
||||
Type: prompt.Type,
|
||||
}
|
||||
switch prompt.Type {
|
||||
case discordauth.AuthenticatorTOTP:
|
||||
selected.CodePrompt = "TOTP code"
|
||||
case discordauth.AuthenticatorSMS:
|
||||
fmt.Fprintln(p.out, "Discord sent an MFA SMS code.")
|
||||
selected.CodePrompt = "SMS code"
|
||||
case discordauth.AuthenticatorBackup:
|
||||
selected.CodePrompt = "Backup code"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported MFA code prompt type %q", prompt.Type)
|
||||
}
|
||||
|
||||
code, err := p.promptSecretRequired(selected.CodePrompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected.Type == discordauth.AuthenticatorBackup {
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
}
|
||||
|
||||
return &discordauth.Answer{
|
||||
MFAContinue: newMFAContinue(p.mfaChallenge, 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
|
||||
}
|
||||
|
||||
func (p *prompter) WaitForEmailVerification(ctx context.Context) error {
|
||||
fmt.Fprintln(p.out)
|
||||
fmt.Fprintln(p.out, "Discord detected a login from a new location and emailed a")
|
||||
fmt.Fprintln(p.out, "verification link. Open the email, click the link to authorize")
|
||||
fmt.Fprintln(p.out, "this login, then continue.")
|
||||
|
||||
// The return value is irrelevant; we just block until the user confirms.
|
||||
_, err := p.prompt("Press enter once you've verified")
|
||||
return err
|
||||
}
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue