Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1717ddb30f | |||
|
|
19e26674e6 | ||
|
|
daf6b9420c | ||
|
|
fab784bfd8 | ||
|
|
17c1938b4c | ||
|
|
ca9f032234 | ||
|
|
5c4527f1b2 | ||
|
|
11b1ea5aa6 | ||
|
|
d7292a0706 | ||
|
|
9eaf213091 | ||
|
|
c8c00a42bb | ||
|
|
2182c0d38f | ||
|
|
d92d7c4314 | ||
|
|
5c22ed85a7 | ||
|
|
98e5e9de4a | ||
|
|
820951cb6e | ||
|
|
52ebc21d9b | ||
|
|
16469259f7 | ||
|
|
d2988096e4 | ||
|
|
3f7622be19 | ||
|
|
40a6992151 | ||
|
|
111824486b | ||
|
|
d4e7289315 | ||
|
|
e2151defc6 | ||
|
|
30c2cd94a7 | ||
|
|
847d4cb98e | ||
|
|
9fd89cdfc5 | ||
|
|
dc4538aab6 | ||
|
|
a6fca7ce43 | ||
|
|
d69e4e9881 | ||
|
|
ccc6c77911 | ||
|
|
001c88c400 | ||
|
|
d37b5028e1 | ||
|
|
ef093b129f | ||
|
|
84e56c73fa | ||
|
|
5854ad0c14 | ||
|
|
9605992758 | ||
|
|
4d67dbcd00 | ||
|
|
31a75d871f | ||
|
|
b8892ed59f | ||
|
|
65ef2c4ff6 | ||
|
|
4a8e9f5c21 | ||
|
|
4aad353603 | ||
|
|
0e59e2da68 | ||
|
|
5a029367b3 | ||
|
|
f2897d9b14 | ||
|
|
8b61dc5352 | ||
|
|
b330c5836e | ||
|
|
8219516ede | ||
|
|
c01f502e04 | ||
|
|
1e3b854ee1 | ||
|
|
a9df85fdca | ||
|
|
0d148ffad6 | ||
|
|
024577d822 | ||
|
|
449c9264d8 | ||
|
|
a0ee1fd508 | ||
|
|
ce1f401ddc | ||
|
|
2f5b3fcbfb | ||
|
|
035f2a408b | ||
|
|
a126a36249 | ||
|
|
1fef7a0ee2 | ||
|
|
2da2aa47e9 | ||
|
|
a6d9e62b49 | ||
|
|
8d01c30014 | ||
|
|
2a7a2c3895 | ||
|
|
23ae2d314f | ||
|
|
737e4c89e0 | ||
|
|
9402d0d291 | ||
|
|
d0e3d2966a | ||
|
|
a5813a9d78 | ||
|
|
5de499a3b5 | ||
|
|
3f5484c73e | ||
|
|
8035a2d3a1 | ||
|
|
f69c02acb6 | ||
|
|
8c8cfa8f6b | ||
|
|
643d4c6e39 | ||
|
|
c013873d1c | ||
|
|
394c0a05d3 | ||
|
|
2138b6115f | ||
|
|
5b8473b3de | ||
|
|
45359853de | ||
|
|
a51ed70f45 | ||
|
|
d9e1292a9e | ||
|
|
0f35e27d81 | ||
|
|
318d6f3fe6 | ||
|
|
b0a7cbca13 | ||
|
|
308f47e2fa | ||
|
|
2c396e553e | ||
|
|
c710ea18aa | ||
|
|
185f9a8963 | ||
|
|
345391f8b1 | ||
|
|
fb6d89a88f | ||
|
|
acaaa9f0f8 | ||
|
|
2ec3b0ebce | ||
|
|
802ec555d6 | ||
|
|
84a6fbc571 | ||
|
|
0391750fea | ||
|
|
5467ab074d | ||
|
|
ff0a9bcafa | ||
|
|
aef54fcc3b | ||
|
|
dab1aba6e5 | ||
|
|
792ad54b9c | ||
|
|
9b7b60966f | ||
|
|
104ee2da57 | ||
|
|
41d0ffcf3b | ||
|
|
b87421f0fb | ||
|
|
3c4561113b | ||
|
|
3eb5c44be3 | ||
|
|
a67d6d2af7 | ||
|
|
f4284e7b3f | ||
|
|
07785997bf | ||
|
|
62a1d83508 | ||
|
|
57b7be8cbb | ||
|
|
f5ffbe1311 | ||
|
|
be1128fd50 | ||
|
|
b4249488db | ||
|
|
b446d865d0 | ||
|
|
25d07c9c34 | ||
|
|
200c4fc9d0 | ||
|
|
d39499cdcf | ||
|
|
c449696120 | ||
|
|
914b360720 | ||
|
|
11b91dc299 | ||
|
|
b77eea4586 | ||
|
|
8ebad277f5 | ||
|
|
248664f8b0 | ||
|
|
3247709abb | ||
|
|
00465bb715 | ||
|
|
cf640ac83d | ||
|
|
67c8d9237e | ||
|
|
b2d7077e8d | ||
|
|
d5db336eee | ||
|
|
b153e70f2a | ||
|
|
cc30353075 | ||
|
|
4c62fe8b12 | ||
|
|
8c57b7a69b | ||
|
|
a265d03319 | ||
|
|
1c606e97a6 | ||
|
|
e6108cb25d | ||
|
|
d004aea9cb | ||
|
|
0fd88fedea | ||
|
|
1e9099e989 | ||
|
|
52fa4da8b2 | ||
|
|
4393772ccc | ||
|
|
824dea4745 | ||
|
|
07182efddd | ||
|
|
280e01969a | ||
|
|
084cde0162 | ||
|
|
434f27c8b4 | ||
|
|
75181741da | ||
|
|
e85f50633d | ||
|
|
a5f9d6510b | ||
|
|
cf7ae7c4db | ||
|
|
ad8efb864b | ||
|
|
de80a77708 | ||
|
|
1ca06f7731 | ||
|
|
d3613d1ec0 | ||
|
|
6f4c5c1d77 | ||
|
|
d3b6c3bc9f | ||
|
|
7655ff1a64 | ||
|
|
87c90d3f12 | ||
|
|
8100386f88 | ||
|
|
102b1510f8 | ||
|
|
4324b60a2c | ||
|
|
c26de9c7df | ||
|
|
2937c3ea2e | ||
|
|
6738a04715 | ||
|
|
35f534affa | ||
|
|
2e07cbfa0b | ||
|
|
cc2d0ae40d | ||
|
|
9793e00434 | ||
|
|
bd56d33c89 | ||
|
|
a44ceea836 | ||
|
|
f6c4f49bb0 | ||
|
|
14c6ae8c75 | ||
|
|
568e270540 | ||
|
|
3e1d1740f7 | ||
|
|
0e5faa5510 | ||
|
|
f6f6ed29ec | ||
|
|
f247c679de | ||
|
|
aea88ad68f | ||
|
|
7b93d9099d | ||
|
|
3f3c86754d | ||
|
|
049ef48fb0 | ||
|
|
29e0b9fa02 | ||
|
|
f298230dcf | ||
|
|
e3ff8d2269 | ||
|
|
3df81f40d5 | ||
|
|
f0bab64e5b | ||
|
|
1048a41c48 | ||
|
|
e7f73c3ae2 | ||
|
|
7469b2577d | ||
|
|
42c48bfd90 | ||
|
|
533054b8a0 | ||
|
|
ed020c4233 | ||
|
|
587ac68f60 | ||
|
|
a0fb4a45d2 | ||
|
|
58befb3f96 | ||
|
|
4194b4dfd9 | ||
|
|
d465bd2d67 | ||
|
|
693fe49a9a | ||
|
|
ef1142c614 | ||
|
|
ee5ea87e83 | ||
|
|
35d0c209f2 | ||
|
|
dad71dd6c5 | ||
|
|
24b768903a | ||
|
|
16b086f62f | ||
|
|
a7095b1bd4 | ||
|
|
69268f8d92 | ||
|
|
05bc4f9312 | ||
|
|
f5ef87eb83 | ||
|
|
3cdf018c37 | ||
|
|
46115fafd5 | ||
|
|
15d4cf07f9 | ||
|
|
ff052d7f18 | ||
|
|
ef7e77515a | ||
|
|
0deec8b853 | ||
|
|
d42c4722c9 | ||
|
|
ee2ad7527e | ||
|
|
5a40f0a2ab | ||
|
|
c163fba712 | ||
|
|
9c87532d52 | ||
|
|
9b63defbe8 | ||
|
|
4e9e50dbed | ||
|
|
3c52e76e15 | ||
|
|
0e8b845014 | ||
|
|
f8bbcc9080 | ||
|
|
febb28882e | ||
|
|
0403a705b6 | ||
|
|
2440ca4e83 | ||
|
|
39096c9347 | ||
|
|
72d4fb755b | ||
|
|
7bfa885530 | ||
|
|
f7c8e03041 | ||
|
|
d3828f2fb3 | ||
|
|
bccdc67eb2 | ||
|
|
c625ee3ba7 | ||
|
|
17d4b79554 | ||
|
|
6365db46cc | ||
|
|
af52979669 | ||
|
|
ccd29752c7 | ||
|
|
4eba894573 | ||
|
|
71d1689776 | ||
|
|
ce4d05bb11 | ||
|
|
681a5ff2ab | ||
|
|
60c260a471 | ||
|
|
efd22e33b5 | ||
|
|
7b5c057dcf | ||
|
|
a0cc5ec9bc | ||
|
|
77b230f4d8 | ||
|
|
cace8b5939 | ||
|
|
ac7ad471a5 | ||
|
|
a6c3b84db5 | ||
|
|
4676ec98c4 | ||
|
|
541c8e1169 | ||
|
|
69f1793e24 | ||
|
|
eab19f6679 | ||
|
|
839933005c | ||
|
|
a28735beb7 | ||
|
|
5d7a6e7088 | ||
|
|
f9ba906bbd | ||
|
|
41d51ec992 | ||
|
|
6ccf87bc0a | ||
|
|
011c60610a | ||
|
|
669964272e | ||
|
|
943f2dd6f0 | ||
|
|
3e5baa502e | ||
|
|
c336804c7e | ||
|
|
2421cd7817 | ||
|
|
a7864c28d8 | ||
|
|
0dba4fbdd4 | ||
|
|
fac7d79c5e | ||
|
|
f32fd8d904 | ||
|
|
1e81fc6a02 | ||
|
|
80f8bed9b9 | ||
|
|
7cdd1bb9e4 | ||
|
|
a2121347e8 | ||
|
|
85395c0230 | ||
|
|
787ce75dde | ||
|
|
5b715cd9e2 | ||
|
|
a9e03f092c | ||
|
|
466139164c | ||
|
|
e183f5cffa | ||
|
|
e7615ef4be | ||
|
|
694733a4e9 | ||
|
|
6f4c51852c |
13
.github/ISSUE_TEMPLATE/bug.md
vendored
13
.github/ISSUE_TEMPLATE/bug.md
vendored
@@ -1,7 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: If something is definitely wrong in the bridge (rather than just a setup issue),
|
about: If something is definitely wrong in the bridge (rather than just a setup issue),
|
||||||
file a bug report. Remember to include relevant logs.
|
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
|
||||||
labels: bug
|
is strongly recommended.
|
||||||
|
type: Bug
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Remember to include relevant logs, the bridge version and any other details.
|
||||||
|
|
||||||
|
It's always best to ask in the Matrix room first, especially if you aren't sure
|
||||||
|
what details are needed. Issues with insufficient detail will likely just be
|
||||||
|
ignored or closed immediately.
|
||||||
|
-->
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/enhancement.md
vendored
2
.github/ISSUE_TEMPLATE/enhancement.md
vendored
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Enhancement request
|
name: Enhancement request
|
||||||
about: Submit a feature request or other suggestion
|
about: Submit a feature request or other suggestion
|
||||||
labels: enhancement
|
type: Feature
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
13
.github/workflows/go.yml
vendored
13
.github/workflows/go.yml
vendored
@@ -8,14 +8,17 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
go-version: [1.19]
|
go-version: ["1.25", "1.26"]
|
||||||
steps:
|
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Go ${{ matrix.go-version }}
|
steps:
|
||||||
uses: actions/setup-go@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: ${{ matrix.go-version }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
- name: Install libolm
|
- name: Install libolm
|
||||||
run: sudo apt-get install libolm-dev libolm3
|
run: sudo apt-get install libolm-dev libolm3
|
||||||
|
|||||||
29
.github/workflows/stale.yml
vendored
Normal file
29
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: 'Lock old issues'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 21 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
# pull-requests: write
|
||||||
|
# discussions: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: lock-threads
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/lock-threads@v5
|
||||||
|
id: lock
|
||||||
|
with:
|
||||||
|
issue-inactive-days: 90
|
||||||
|
process-only: issues
|
||||||
|
- name: Log processed threads
|
||||||
|
run: |
|
||||||
|
if [ '${{ steps.lock.outputs.issues }}' ]; then
|
||||||
|
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
|
||||||
|
fi
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,5 +1,9 @@
|
|||||||
config.yaml
|
*.yaml
|
||||||
discord
|
!example-config.yaml
|
||||||
logs/
|
!.pre-commit-config.yaml
|
||||||
registration.yaml
|
|
||||||
*.db*
|
*.db*
|
||||||
|
*.log*
|
||||||
|
|
||||||
|
/mautrix-discord
|
||||||
|
/start
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.1.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude_types: [markdown]
|
exclude_types: [markdown]
|
||||||
@@ -9,7 +9,12 @@ repos:
|
|||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
|
|
||||||
- repo: https://github.com/tekwizely/pre-commit-golang
|
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||||
rev: v1.0.0-beta.5
|
rev: v1.0.0-rc.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: go-imports-repo
|
- id: go-imports-repo
|
||||||
- id: go-vet-repo-mod
|
- id: go-vet-repo-mod
|
||||||
|
|
||||||
|
- repo: https://github.com/beeper/pre-commit-go
|
||||||
|
rev: v0.3.1
|
||||||
|
hooks:
|
||||||
|
- id: zerolog-ban-msgf
|
||||||
|
|||||||
205
CHANGELOG.md
205
CHANGELOG.md
@@ -1,3 +1,208 @@
|
|||||||
|
# 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.
|
||||||
|
* Changed `prefix_webhook_messages` option to generate [MSC4144] fallbacks,
|
||||||
|
so that any compatible clients will hide the prefix.
|
||||||
|
* Changed new room creation to hardcode room v11 to avoid v12 rooms being
|
||||||
|
created before proper support for them can be added.
|
||||||
|
|
||||||
|
# v0.7.4 (2025-06-16)
|
||||||
|
|
||||||
|
* Added support for forwarded messages
|
||||||
|
* Added support for [MSC4193] media spoilers (thanks to [@LeaPhant] in [#189]).
|
||||||
|
* Added support for [MSC4190] for MAS-compatible encryption.
|
||||||
|
* Updated Docker image to Alpine 3.22
|
||||||
|
|
||||||
|
[MSC4193]: https://github.com/matrix-org/matrix-spec-proposals/pull/4193
|
||||||
|
[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
|
||||||
|
[@LeaPhant]: https://github.com/mautrix/discord/pull/189
|
||||||
|
[#189]: https://github.com/mautrix/discord/pull/189
|
||||||
|
|
||||||
|
# v0.7.3 (2025-04-16)
|
||||||
|
|
||||||
|
* Added support for sending no-mention replies from Matrix
|
||||||
|
(uses intentional mentions and requires client support).
|
||||||
|
* Added file name to QR image message when logging in to fix rendering in dumb
|
||||||
|
clients that validate the file extension.
|
||||||
|
* Added `id` field to per-message profiles to match [MSC4144].
|
||||||
|
* Fixed guild avatars in per-message profiles (thanks to [@mat-1] in [#172]).
|
||||||
|
* Fixed typo in MSC1767 field name in voice messages (thanks to [@ginnyTheCat] in [#177]).
|
||||||
|
|
||||||
|
[@mat-1]: https://github.com/mat-1
|
||||||
|
[@ginnyTheCat]: https://github.com/ginnyTheCat
|
||||||
|
[#172]: https://github.com/mautrix/discord/pull/172
|
||||||
|
[#177]: https://github.com/mautrix/discord/pull/177
|
||||||
|
[MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144
|
||||||
|
|
||||||
|
# v0.7.2 (2024-12-16)
|
||||||
|
|
||||||
|
* Fixed some headers being set incorrectly.
|
||||||
|
|
||||||
|
# v0.7.1 (2024-11-16)
|
||||||
|
|
||||||
|
* Bumped minimum Go version to 1.22.
|
||||||
|
* Updated Discord version numbers.
|
||||||
|
|
||||||
|
# v0.7.0 (2024-07-16)
|
||||||
|
|
||||||
|
* Bumped minimum Go version to 1.21.
|
||||||
|
* Added support for Matrix v1.11 authenticated media.
|
||||||
|
* This also changes how avatars are sent to Discord when using relay webhooks.
|
||||||
|
To keep avatars working, you must configure `public_address` in the *bridge*
|
||||||
|
section of the config and proxy `/mautrix-discord/avatar/*` from that
|
||||||
|
address to the bridge.
|
||||||
|
* Added `create-portal` command to create individual portals bypassing the
|
||||||
|
bridging mode. When used in combination with the `if-portal-exists` bridging
|
||||||
|
mode, this can be used to bridge individual channels from a guild.
|
||||||
|
* Changed how direct media access works to make it compatible with Discord's
|
||||||
|
signed URL requirement. The new system must be enabled manually, see
|
||||||
|
[docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for info.
|
||||||
|
|
||||||
|
# v0.6.5 (2024-01-16)
|
||||||
|
|
||||||
|
* Fixed adding reply embed to webhook sends if the Matrix room is encrypted.
|
||||||
|
|
||||||
|
# v0.6.4 (2023-11-16)
|
||||||
|
|
||||||
|
* Changed error messages to be sent in a thread if the errored message was in
|
||||||
|
a thread.
|
||||||
|
|
||||||
|
# v0.6.3 (2023-10-16)
|
||||||
|
|
||||||
|
* Fixed op7 reconnects during connection causing the bridge to get stuck
|
||||||
|
disconnected.
|
||||||
|
* Fixed double puppet of recipient joining DM portals when both ends of a DM
|
||||||
|
are using the same bridge.
|
||||||
|
|
||||||
|
# v0.6.2 (2023-09-16)
|
||||||
|
|
||||||
|
* Added support for double puppeting with arbitrary `as_token`s.
|
||||||
|
See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
|
||||||
|
* Adjusted markdown parsing rules to allow inline links in normal messages.
|
||||||
|
* Fixed panic if redacting an attachment fails.
|
||||||
|
* Fixed panic when handling video embeds with no URLs
|
||||||
|
(thanks to [@odrling] in [#110]).
|
||||||
|
|
||||||
|
[@odrling]: https://github.com/odrling
|
||||||
|
[#110]: https://github.com/mautrix/discord/pull/110
|
||||||
|
|
||||||
|
# v0.6.1 (2023-08-16)
|
||||||
|
|
||||||
|
* Bumped minimum Go version to 1.20.
|
||||||
|
* Fixed all logged-in users being invited to existing portal rooms even if they
|
||||||
|
don't have permission to view the channel on Discord.
|
||||||
|
* Fixed gif links not being treated as embeds if the canonical URL is different
|
||||||
|
than the URL in the message body.
|
||||||
|
|
||||||
|
# v0.6.0 (2023-07-16)
|
||||||
|
|
||||||
|
* Added initial support for backfilling threads.
|
||||||
|
* Exposed `Application` flag to displayname template.
|
||||||
|
* Changed `m.emote` bridging to use italics on Discord.
|
||||||
|
* Updated Docker image to Alpine 3.18.
|
||||||
|
* Added limit to parallel media transfers to avoid high memory usage if lots
|
||||||
|
of messages are received at the same time.
|
||||||
|
* Fixed guilds being unbridged if Discord has server issues and temporarily
|
||||||
|
marks a guild as unavailable.
|
||||||
|
* Fixed using `guilds bridge` command without `--entire` flag.
|
||||||
|
* Fixed panic if lottieconverter isn't installed.
|
||||||
|
* Fixed relay webhook secret being leaked in network error messages.
|
||||||
|
|
||||||
|
# v0.5.0 (2023-06-16)
|
||||||
|
|
||||||
|
* Added support for intentional mentions in Matrix (MSC3952).
|
||||||
|
* Added `GlobalName` variable to displayname templates and updated the default
|
||||||
|
template to prefer it over usernames.
|
||||||
|
* Added `Webhook` variable to displayname templates to allow determining if a
|
||||||
|
ghost user is a webhook.
|
||||||
|
* Added guild profiles and webhook profiles as a custom field in Matrix
|
||||||
|
message events.
|
||||||
|
* Added support for bulk message delete from Discord.
|
||||||
|
* Added support for appservice websockets.
|
||||||
|
* Enabled parsing headers (`#`) in Discord markdown.
|
||||||
|
* Messages that consist of a single image link are now bridged as images to
|
||||||
|
closer match Discord.
|
||||||
|
* Stopped bridging incoming typing notifications from users who are logged into
|
||||||
|
the bridge to prevent echoes.
|
||||||
|
|
||||||
|
# v0.4.0 (2023-05-16)
|
||||||
|
|
||||||
|
* Added bridging of friend nicks into DM room names.
|
||||||
|
* Added option to bypass homeserver for Discord media.
|
||||||
|
See [docs](https://docs.mau.fi/bridges/go/discord/direct-media.html) for more info.
|
||||||
|
* Added conversion of replies to embeds when sending messages via webhook.
|
||||||
|
* Added option to disable caching reuploaded media. This may be necessary when
|
||||||
|
using a media repo that doesn't create a unique mxc URI for each upload.
|
||||||
|
* Added option to disable uploading files directly to the Discord CDN
|
||||||
|
(and send as form parts in the message send request instead).
|
||||||
|
* Improved formatting of error messages returned by Discord.
|
||||||
|
* Enabled discordgo info logs by default.
|
||||||
|
* Fixed limited backfill always stopping after 50 messages
|
||||||
|
(thanks to [@odrling] in [#81]).
|
||||||
|
* Fixed startup sync to sync most recent private channels first.
|
||||||
|
* Fixed syncing group DM participants when they change.
|
||||||
|
* Fixed bridging animated emojis in messages.
|
||||||
|
* Stopped handling all message edits from relay webhook to prevent incorrect
|
||||||
|
edits.
|
||||||
|
* Possibly fixed inviting to portal rooms when multiple Matrix users use the
|
||||||
|
bridge.
|
||||||
|
|
||||||
|
[@odrling]: https://github.com/odrling
|
||||||
|
[#81]: https://github.com/mautrix/discord/pull/81
|
||||||
|
|
||||||
|
# v0.3.0 (2023-04-16)
|
||||||
|
|
||||||
|
* Added support for backfilling on room creation and missed messages on startup.
|
||||||
|
* Added options to automatically ratchet/delete megolm sessions to minimize
|
||||||
|
access to old messages.
|
||||||
|
* Added basic support for incoming voice messages.
|
||||||
|
|
||||||
|
# v0.2.0 (2023-03-16)
|
||||||
|
|
||||||
|
* Switched to zerolog for logging.
|
||||||
|
* The basic log config will be migrated automatically, but you may want to
|
||||||
|
tweak it as the options are different.
|
||||||
|
* Added support for logging in with a bot account.
|
||||||
|
The [Authentication docs](https://docs.mau.fi/bridges/go/discord/authentication.html)
|
||||||
|
have been updated with instructions for creating a bot.
|
||||||
|
* Added support for relaying messages for unauthenticated users using a webhook.
|
||||||
|
See [docs](https://docs.mau.fi/bridges/go/discord/relay.html) for instructions.
|
||||||
|
* Added commands to bridge and unbridge channels manually.
|
||||||
|
* Added `ping` command.
|
||||||
|
* Added support for gif stickers from Discord.
|
||||||
|
* Changed mention bridging so mentions for users logged into the bridge use the
|
||||||
|
Matrix user's MXID even if double puppeting is not enabled.
|
||||||
|
* Actually fixed ghost user info not being synced when receiving reactions.
|
||||||
|
* Fixed uncommon bug with sending messages that only occurred after login
|
||||||
|
before restarting the bridge.
|
||||||
|
* Fixed guild name not being synced immediately after joining a new guild.
|
||||||
|
* Fixed variation selectors when bridging emojis to Discord.
|
||||||
|
|
||||||
|
# v0.1.1 (2023-02-16)
|
||||||
|
|
||||||
|
* Started automatically subscribing to bridged guilds. This fixes two problems:
|
||||||
|
* Typing notifications should now work automatically in guilds.
|
||||||
|
* Huge guilds now actually get messages bridged.
|
||||||
|
* Added support for converting animated lottie stickers to raster formats using
|
||||||
|
[lottieconverter](https://github.com/sot-tech/LottieConverter).
|
||||||
|
* Added basic bridging for call start and guild join messages.
|
||||||
|
* Improved markdown parsing to disable more features that don't exist on Discord.
|
||||||
|
* Removed width from inline images (e.g. in the `guilds status` output) to
|
||||||
|
handle non-square images properly.
|
||||||
|
* Fixed ghost user info not being synced when receiving reactions.
|
||||||
|
|
||||||
# v0.1.0 (2023-01-29)
|
# v0.1.0 (2023-01-29)
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1-alpine3.17 AS builder
|
FROM golang:1-alpine3.23 AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
||||||
|
|
||||||
@@ -6,16 +6,17 @@ COPY . /build
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN go build -o /usr/bin/mautrix-discord
|
RUN go build -o /usr/bin/mautrix-discord
|
||||||
|
|
||||||
FROM alpine:3.17
|
FROM alpine:3.23
|
||||||
|
|
||||||
ENV UID=1337 \
|
ENV UID=1337 \
|
||||||
GID=1337
|
GID=1337
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
|
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/example-config.yaml /opt/mautrix-discord/example-config.yaml
|
||||||
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
CMD ["/docker-run.sh"]
|
CMD ["/docker-run.sh"]
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
FROM alpine:3.17
|
FROM alpine:3.23
|
||||||
|
|
||||||
ENV UID=1337 \
|
ENV UID=1337 \
|
||||||
GID=1337
|
GID=1337
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
|
||||||
|
|
||||||
ARG EXECUTABLE=./mautrix-discord
|
ARG EXECUTABLE=./mautrix-discord
|
||||||
COPY $EXECUTABLE /usr/bin/mautrix-discord
|
COPY $EXECUTABLE /usr/bin/mautrix-discord
|
||||||
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
|
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
|
||||||
COPY ./docker-run.sh /docker-run.sh
|
COPY ./docker-run.sh /docker-run.sh
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
CMD ["/docker-run.sh"]
|
CMD ["/docker-run.sh"]
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
FROM golang:1-alpine3.17 AS builder
|
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl
|
|
||||||
|
|
||||||
COPY . /build
|
|
||||||
WORKDIR /build
|
|
||||||
RUN go build -o /usr/bin/mautrix-discord
|
|
||||||
|
|
||||||
# Setup development stack using gow
|
|
||||||
RUN go install github.com/mitranim/gow@latest
|
|
||||||
RUN echo 'gow run /build $@' > /usr/bin/mautrix-discord \
|
|
||||||
&& chmod +x /usr/bin/mautrix-discord
|
|
||||||
VOLUME /data
|
|
||||||
@@ -8,7 +8,8 @@ All setup and usage instructions are located on [docs.mau.fi]. Some quick links:
|
|||||||
|
|
||||||
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord)
|
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord)
|
||||||
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord))
|
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord))
|
||||||
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html)
|
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html),
|
||||||
|
[Relaying with webhooks](https://docs.mau.fi/bridges/go/discord/relay.html)
|
||||||
|
|
||||||
### Features & Roadmap
|
### Features & Roadmap
|
||||||
[ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md)
|
[ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# Features & roadmap
|
# Features & roadmap
|
||||||
* Matrix → Discord
|
* Matrix → Discord
|
||||||
* [x] Message content
|
* [ ] Message content
|
||||||
* [x] Plain text
|
* [x] Plain text
|
||||||
* [x] Formatted messages
|
* [x] Formatted messages
|
||||||
* [x] Media/files
|
* [x] Media/files
|
||||||
* [x] Replies
|
* [x] Replies
|
||||||
* [x] Threads
|
* [x] Threads
|
||||||
|
* [ ] Custom emojis
|
||||||
* [x] Message redactions
|
* [x] Message redactions
|
||||||
* [x] Reactions
|
* [x] Reactions
|
||||||
* [x] Unicode emojis
|
* [x] Unicode emojis
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
* [x] Message deletions
|
* [x] Message deletions
|
||||||
* [x] Reactions
|
* [x] Reactions
|
||||||
* [x] Unicode emojis
|
* [x] Unicode emojis
|
||||||
* [x] Custom emojis (not yet supported on Matrix)
|
* [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
|
||||||
* [x] Avatars
|
* [x] Avatars
|
||||||
* [ ] Presence
|
* [ ] Presence
|
||||||
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
|
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
|
||||||
|
|||||||
258
attachments.go
258
attachments.go
@@ -2,25 +2,34 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"go.mau.fi/util/exsync"
|
||||||
|
"go.mau.fi/util/ffmpeg"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/appservice"
|
"maunium.net/go/mautrix/appservice"
|
||||||
"maunium.net/go/mautrix/crypto/attachment"
|
"maunium.net/go/mautrix/crypto/attachment"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/database"
|
"go.mau.fi/mautrix-discord/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func downloadDiscordAttachment(url string) ([]byte, error) {
|
func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -29,28 +38,48 @@ func downloadDiscordAttachment(url string) ([]byte, error) {
|
|||||||
req.Header.Set(key, value)
|
req.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := cli.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode > 300 {
|
if resp.StatusCode > 300 {
|
||||||
data, _ := io.ReadAll(resp.Body)
|
data, _ := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, data)
|
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
|
||||||
}
|
}
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadDiscordAttachment(url string, data []byte) error {
|
func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error {
|
||||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for key, value := range discordgo.DroidFetchHeaders {
|
for key, value := range discordgo.DroidBaseHeaders {
|
||||||
req.Header.Set(key, value)
|
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 := http.DefaultClient.Do(req)
|
resp, err := cli.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -62,7 +91,7 @@ func uploadDiscordAttachment(url string, data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventContent) ([]byte, error) {
|
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
|
||||||
var file *event.EncryptedFileInfo
|
var file *event.EncryptedFileInfo
|
||||||
rawMXC := content.URL
|
rawMXC := content.URL
|
||||||
|
|
||||||
@@ -76,7 +105,7 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := portal.MainIntent().DownloadBytes(mxc)
|
data, err := intent.DownloadBytes(mxc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -91,23 +120,24 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, attachmentID, mime string) (*database.File, error) {
|
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 := br.DB.File.New()
|
||||||
dbFile.Timestamp = time.Now()
|
dbFile.Timestamp = time.Now()
|
||||||
dbFile.URL = url
|
dbFile.URL = url
|
||||||
dbFile.ID = attachmentID
|
dbFile.ID = meta.AttachmentID
|
||||||
|
dbFile.EmojiName = meta.EmojiName
|
||||||
dbFile.Size = len(data)
|
dbFile.Size = len(data)
|
||||||
dbFile.MimeType = mimetype.Detect(data).String()
|
dbFile.MimeType = mimetype.Detect(data).String()
|
||||||
if mime == "" {
|
if meta.MimeType == "" {
|
||||||
mime = dbFile.MimeType
|
meta.MimeType = dbFile.MimeType
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(mime, "image/") {
|
if strings.HasPrefix(meta.MimeType, "image/") {
|
||||||
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
||||||
dbFile.Width = cfg.Width
|
dbFile.Width = cfg.Width
|
||||||
dbFile.Height = cfg.Height
|
dbFile.Height = cfg.Height
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadMime := mime
|
uploadMime := meta.MimeType
|
||||||
if encrypt {
|
if encrypt {
|
||||||
dbFile.Encrypted = true
|
dbFile.Encrypted = true
|
||||||
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
|
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
|
||||||
@@ -119,17 +149,19 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
|
|||||||
ContentType: uploadMime,
|
ContentType: uploadMime,
|
||||||
}
|
}
|
||||||
if br.Config.Homeserver.AsyncMedia {
|
if br.Config.Homeserver.AsyncMedia {
|
||||||
resp, err := intent.UnstableCreateMXC()
|
resp, err := intent.CreateMXC()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dbFile.MXC = resp.ContentURI
|
dbFile.MXC = resp.ContentURI
|
||||||
req.UnstableMXC = resp.ContentURI
|
req.MXC = resp.ContentURI
|
||||||
req.UploadURL = resp.UploadURL
|
req.UnstableUploadURL = resp.UnstableUploadURL
|
||||||
|
semaWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer semaWg.Done()
|
||||||
_, err = intent.UploadMedia(req)
|
_, err = intent.UploadMedia(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
br.Log.Errorfln("Failed to upload %s: %v", req.UnstableMXC, err)
|
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
|
||||||
dbFile.Delete()
|
dbFile.Delete()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -140,22 +172,182 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
|
|||||||
}
|
}
|
||||||
dbFile.MXC = uploaded.ContentURI
|
dbFile.MXC = uploaded.ContentURI
|
||||||
}
|
}
|
||||||
dbFile.Insert(nil)
|
|
||||||
return dbFile, nil
|
return dbFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, attachmentID, mime string) (*database.File, error) {
|
type AttachmentMeta struct {
|
||||||
dbFile := br.DB.File.Get(url, encrypt)
|
AttachmentID string
|
||||||
if dbFile == nil {
|
MimeType string
|
||||||
data, err := downloadDiscordAttachment(url)
|
EmojiName string
|
||||||
if err != nil {
|
CopyIfMissing bool
|
||||||
return nil, err
|
Converter func([]byte) ([]byte, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbFile, err = br.uploadMatrixAttachment(intent, data, url, encrypt, attachmentID, mime)
|
var NoMeta = AttachmentMeta{}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
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)
|
||||||
}
|
}
|
||||||
return dbFile, nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
40
avatar.go
40
avatar.go
@@ -1,40 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/appservice"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
|
||||||
)
|
|
||||||
|
|
||||||
func uploadAvatar(intent *appservice.IntentAPI, url string) (id.ContentURI, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return id.ContentURI{}, fmt.Errorf("failed to prepare request: %w", err)
|
|
||||||
}
|
|
||||||
for key, value := range discordgo.DroidImageHeaders {
|
|
||||||
req.Header.Set(key, value)
|
|
||||||
}
|
|
||||||
getResp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(getResp.Body)
|
|
||||||
_ = getResp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return id.ContentURI{}, fmt.Errorf("failed to read avatar data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mime := http.DetectContentType(data)
|
|
||||||
resp, err := intent.UploadBytes(data, mime)
|
|
||||||
if err != nil {
|
|
||||||
return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.ContentURI, nil
|
|
||||||
}
|
|
||||||
383
backfill.go
Normal file
383
backfill.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
548
commands.go
548
commands.go
@@ -19,6 +19,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
@@ -30,10 +31,13 @@ import (
|
|||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"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/bridge/commands"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-discord/database"
|
||||||
"go.mau.fi/mautrix-discord/remoteauth"
|
"go.mau.fi/mautrix-discord/remoteauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,14 +48,23 @@ type WrappedCommandEvent struct {
|
|||||||
Portal *Portal
|
Portal *Portal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
|
||||||
|
|
||||||
func (br *DiscordBridge) RegisterCommands() {
|
func (br *DiscordBridge) RegisterCommands() {
|
||||||
proc := br.CommandProcessor.(*commands.Processor)
|
proc := br.CommandProcessor.(*commands.Processor)
|
||||||
proc.AddHandlers(
|
proc.AddHandlers(
|
||||||
cmdLoginToken,
|
cmdLoginToken,
|
||||||
cmdLoginQR,
|
cmdLoginQR,
|
||||||
cmdLogout,
|
cmdLogout,
|
||||||
|
cmdPing,
|
||||||
cmdReconnect,
|
cmdReconnect,
|
||||||
cmdDisconnect,
|
cmdDisconnect,
|
||||||
|
cmdBridge,
|
||||||
|
cmdUnbridge,
|
||||||
|
cmdDeletePortal,
|
||||||
|
cmdCreatePortal,
|
||||||
|
cmdSetRelay,
|
||||||
|
cmdUnsetRelay,
|
||||||
cmdGuilds,
|
cmdGuilds,
|
||||||
cmdRejoinSpace,
|
cmdRejoinSpace,
|
||||||
cmdDeleteAllPortals,
|
cmdDeleteAllPortals,
|
||||||
@@ -78,12 +91,45 @@ var cmdLoginToken = &commands.FullHandler{
|
|||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionAuth,
|
Section: commands.HelpSectionAuth,
|
||||||
Description: "Link the bridge to your Discord account by extracting the access token manually.",
|
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) {
|
func fnLoginToken(ce *WrappedCommandEvent) {
|
||||||
if len(ce.Args) == 0 {
|
if len(ce.Args) != 2 {
|
||||||
ce.Reply("**Usage**: `$cmdprefix login-token <token>`")
|
ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ce.MarkRead()
|
ce.MarkRead()
|
||||||
@@ -92,11 +138,29 @@ func fnLoginToken(ce *WrappedCommandEvent) {
|
|||||||
ce.Reply("You're already logged in")
|
ce.Reply("You're already logged in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := ce.User.Login(ce.Args[0]); err != nil {
|
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)
|
ce.Reply("Error connecting to Discord: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ce.Reply("Successfully logged in as %s#%s", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator)
|
ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdLoginQR = &commands.FullHandler{
|
var cmdLoginQR = &commands.FullHandler{
|
||||||
@@ -165,7 +229,7 @@ func fnLoginQR(ce *WrappedCommandEvent) {
|
|||||||
ce.User.DiscordID = user.UserID
|
ce.User.DiscordID = user.UserID
|
||||||
ce.User.Update()
|
ce.User.Update()
|
||||||
ce.User.Unlock()
|
ce.User.Unlock()
|
||||||
ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
|
ce.Reply("Successfully logged in as @%s", user.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
|
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
|
||||||
@@ -175,9 +239,10 @@ func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := event.MessageEventContent{
|
content := event.MessageEventContent{
|
||||||
MsgType: event.MsgImage,
|
MsgType: event.MsgImage,
|
||||||
Body: code,
|
Body: code,
|
||||||
URL: url.CUString(),
|
FileName: "qr.png",
|
||||||
|
URL: url.CUString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
|
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
|
||||||
@@ -218,7 +283,7 @@ var cmdLogout = &commands.FullHandler{
|
|||||||
|
|
||||||
func fnLogout(ce *WrappedCommandEvent) {
|
func fnLogout(ce *WrappedCommandEvent) {
|
||||||
wasLoggedIn := ce.User.DiscordID != ""
|
wasLoggedIn := ce.User.DiscordID != ""
|
||||||
ce.User.Logout()
|
ce.User.Logout(false)
|
||||||
if wasLoggedIn {
|
if wasLoggedIn {
|
||||||
ce.Reply("Logged out successfully.")
|
ce.Reply("Logged out successfully.")
|
||||||
} else {
|
} else {
|
||||||
@@ -226,6 +291,29 @@ func fnLogout(ce *WrappedCommandEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
var cmdDisconnect = &commands.FullHandler{
|
||||||
Func: wrapCommand(fnDisconnect),
|
Func: wrapCommand(fnDisconnect),
|
||||||
Name: "disconnect",
|
Name: "disconnect",
|
||||||
@@ -271,7 +359,7 @@ var cmdRejoinSpace = &commands.FullHandler{
|
|||||||
Func: wrapCommand(fnRejoinSpace),
|
Func: wrapCommand(fnRejoinSpace),
|
||||||
Name: "rejoin-space",
|
Name: "rejoin-space",
|
||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionUnclassified,
|
Section: HelpSectionPortalManagement,
|
||||||
Description: "Ask the bridge for an invite to a space you left",
|
Description: "Ask the bridge for an invite to a space you left",
|
||||||
Args: "<_guild ID_/main/dms>",
|
Args: "<_guild ID_/main/dms>",
|
||||||
},
|
},
|
||||||
@@ -285,10 +373,10 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
|
|||||||
}
|
}
|
||||||
user := ce.User
|
user := ce.User
|
||||||
if ce.Args[0] == "main" {
|
if ce.Args[0] == "main" {
|
||||||
user.ensureInvited(nil, user.GetSpaceRoom(), false)
|
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())
|
ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
|
||||||
} else if ce.Args[0] == "dms" {
|
} else if ce.Args[0] == "dms" {
|
||||||
user.ensureInvited(nil, user.GetDMSpaceRoom(), false)
|
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())
|
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 {
|
} else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
|
||||||
ce.Reply("Rejoining guild spaces is not yet implemented")
|
ce.Reply("Rejoining guild spaces is not yet implemented")
|
||||||
@@ -298,25 +386,182 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
var cmdGuilds = &commands.FullHandler{
|
||||||
Func: wrapCommand(fnGuilds),
|
Func: wrapCommand(fnGuilds),
|
||||||
Name: "guilds",
|
Name: "guilds",
|
||||||
Aliases: []string{"servers", "guild", "server"},
|
Aliases: []string{"servers", "guild", "server"},
|
||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionUnclassified,
|
Section: HelpSectionPortalManagement,
|
||||||
Description: "Guild bridging management",
|
Description: "Guild bridging management",
|
||||||
Args: "<status/bridge/unbridge> [_guild ID_] [--entire]",
|
Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
|
||||||
},
|
},
|
||||||
RequiresLogin: true,
|
RequiresLogin: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [--entire]`"
|
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [...]`"
|
||||||
|
|
||||||
const fullGuildsHelp = smallGuildsHelp + `
|
const fullGuildsHelp = smallGuildsHelp + `
|
||||||
|
|
||||||
* **help** - View this help message.
|
* **help** - View this help message.
|
||||||
* **status** - View the list of guilds and their bridging status.
|
* **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.
|
* **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.`
|
* **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.`
|
||||||
|
|
||||||
func fnGuilds(ce *WrappedCommandEvent) {
|
func fnGuilds(ce *WrappedCommandEvent) {
|
||||||
@@ -333,6 +578,8 @@ func fnGuilds(ce *WrappedCommandEvent) {
|
|||||||
fnBridgeGuild(ce)
|
fnBridgeGuild(ce)
|
||||||
case "unbridge", "delete":
|
case "unbridge", "delete":
|
||||||
fnUnbridgeGuild(ce)
|
fnUnbridgeGuild(ce)
|
||||||
|
case "bridging-mode", "mode":
|
||||||
|
fnGuildBridgingMode(ce)
|
||||||
case "help":
|
case "help":
|
||||||
ce.Reply(fullGuildsHelp)
|
ce.Reply(fullGuildsHelp)
|
||||||
default:
|
default:
|
||||||
@@ -347,15 +594,11 @@ func fnListGuilds(ce *WrappedCommandEvent) {
|
|||||||
if guild == nil {
|
if guild == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
status := "not bridged"
|
|
||||||
if guild.MXID != "" {
|
|
||||||
status = "bridged"
|
|
||||||
}
|
|
||||||
var avatarHTML string
|
var avatarHTML string
|
||||||
if !guild.AvatarURL.IsEmpty() {
|
if !guild.AvatarURL.IsEmpty() {
|
||||||
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" width="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String())
|
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, status))
|
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 {
|
if len(items) == 0 {
|
||||||
ce.Reply("No guilds found")
|
ce.Reply("No guilds found")
|
||||||
@@ -384,11 +627,245 @@ func fnUnbridgeGuild(ce *WrappedCommandEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// HACK: Before giving up, discover if the user is trying to join a
|
||||||
|
// thread. Then, cause the creation of a portal.
|
||||||
|
// This is for forum channel threads; they don't show up on the
|
||||||
|
// forum channels.
|
||||||
|
ch, err := ce.User.Session.Channel(channelID)
|
||||||
|
if err != nil {
|
||||||
|
ce.Reply("Channel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.Type != discordgo.ChannelTypeGuildPublicThread &&
|
||||||
|
ch.Type != discordgo.ChannelTypeGuildPrivateThread {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ce.ZLog.Debug().Msg("Adding public / private thread as a portal")
|
||||||
|
portal = ce.User.GetPortalByID(channelID, ch.Type)
|
||||||
|
|
||||||
|
}
|
||||||
|
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{
|
var cmdDeleteAllPortals = &commands.FullHandler{
|
||||||
Func: wrapCommand(fnDeleteAllPortals),
|
Func: wrapCommand(fnDeleteAllPortals),
|
||||||
Name: "delete-all-portals",
|
Name: "delete-all-portals",
|
||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionUnclassified,
|
Section: commands.HelpSectionAdmin,
|
||||||
Description: "Delete all portals.",
|
Description: "Delete all portals.",
|
||||||
},
|
},
|
||||||
RequiresAdmin: true,
|
RequiresAdmin: true,
|
||||||
@@ -396,14 +873,15 @@ var cmdDeleteAllPortals = &commands.FullHandler{
|
|||||||
|
|
||||||
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
|
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
|
||||||
portals := ce.Bridge.GetAllPortals()
|
portals := ce.Bridge.GetAllPortals()
|
||||||
if len(portals) == 0 {
|
guilds := ce.Bridge.GetAllGuilds()
|
||||||
|
if len(portals) == 0 && len(guilds) == 0 {
|
||||||
ce.Reply("Didn't find any portals")
|
ce.Reply("Didn't find any portals")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
leave := func(portal *Portal) {
|
leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
|
||||||
if len(portal.MXID) > 0 {
|
if len(mxid) > 0 {
|
||||||
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
|
_, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
|
||||||
Reason: "Deleting portal",
|
Reason: "Deleting portal",
|
||||||
UserID: ce.User.MXID,
|
UserID: ce.User.MXID,
|
||||||
})
|
})
|
||||||
@@ -412,19 +890,23 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) {
|
|||||||
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
|
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
|
||||||
if customPuppet != nil && customPuppet.CustomIntent() != nil {
|
if customPuppet != nil && customPuppet.CustomIntent() != nil {
|
||||||
intent := customPuppet.CustomIntent()
|
intent := customPuppet.CustomIntent()
|
||||||
leave = func(portal *Portal) {
|
leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
|
||||||
if len(portal.MXID) > 0 {
|
if len(mxid) > 0 {
|
||||||
_, _ = intent.LeaveRoom(portal.MXID)
|
_, _ = intent.LeaveRoom(mxid)
|
||||||
_, _ = intent.ForgetRoom(portal.MXID)
|
_, _ = intent.ForgetRoom(mxid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ce.Reply("Found %d portals, deleting...", len(portals))
|
ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
|
||||||
for _, portal := range portals {
|
for _, portal := range portals {
|
||||||
portal.Delete()
|
portal.Delete()
|
||||||
leave(portal)
|
leave(portal.MXID, portal.MainIntent())
|
||||||
}
|
}
|
||||||
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.")
|
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() {
|
go func() {
|
||||||
for _, portal := range portals {
|
for _, portal := range portals {
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ import (
|
|||||||
"maunium.net/go/mautrix/bridge/commands"
|
"maunium.net/go/mautrix/bridge/commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
|
||||||
|
|
||||||
var cmdCommands = &commands.FullHandler{
|
var cmdCommands = &commands.FullHandler{
|
||||||
Func: wrapCommand(fnCommands),
|
Func: wrapCommand(fnCommands),
|
||||||
Name: "commands",
|
Name: "commands",
|
||||||
Aliases: []string{"cmds", "cs"},
|
Aliases: []string{"cmds", "cs"},
|
||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionUnclassified,
|
Section: HelpSectionDiscordBots,
|
||||||
Description: "View parameters of bot interaction commands on Discord",
|
Description: "View parameters of bot interaction commands on Discord",
|
||||||
Args: "search <_query_> OR help <_command_>",
|
Args: "search <_query_> OR help <_command_>",
|
||||||
},
|
},
|
||||||
@@ -46,7 +48,7 @@ var cmdExec = &commands.FullHandler{
|
|||||||
Name: "exec",
|
Name: "exec",
|
||||||
Aliases: []string{"command", "cmd", "c", "exec", "e"},
|
Aliases: []string{"command", "cmd", "c", "exec", "e"},
|
||||||
Help: commands.HelpMeta{
|
Help: commands.HelpMeta{
|
||||||
Section: commands.HelpSectionUnclassified,
|
Section: HelpSectionDiscordBots,
|
||||||
Description: "Run bot interaction commands on Discord",
|
Description: "Run bot interaction commands on Discord",
|
||||||
Args: "<_command_> [_arg=value ..._]",
|
Args: "<_command_> [_arg=value ..._]",
|
||||||
},
|
},
|
||||||
@@ -59,7 +61,7 @@ func (portal *Portal) getCommand(user *User, command string) (*discordgo.Applica
|
|||||||
defer portal.commandsLock.Unlock()
|
defer portal.commandsLock.Unlock()
|
||||||
cmd, ok := portal.commands[command]
|
cmd, ok := portal.commands[command]
|
||||||
if !ok {
|
if !ok {
|
||||||
results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command)
|
results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command, portal.RefererOpt(""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,7 @@ func fnCommands(ce *WrappedCommandEvent) {
|
|||||||
}
|
}
|
||||||
subcmd := strings.ToLower(ce.Args[0])
|
subcmd := strings.ToLower(ce.Args[0])
|
||||||
if subcmd == "search" {
|
if subcmd == "search" {
|
||||||
results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1])
|
results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1], ce.Portal.RefererOpt(""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ce.Reply("Error searching for commands: %v", err)
|
ce.Reply("Error searching for commands: %v", err)
|
||||||
return
|
return
|
||||||
@@ -295,7 +297,7 @@ func fnExec(ce *WrappedCommandEvent) {
|
|||||||
ce.User.pendingInteractionsLock.Lock()
|
ce.User.pendingInteractionsLock.Lock()
|
||||||
ce.User.pendingInteractions[nonce] = ce
|
ce.User.pendingInteractions[nonce] = ce
|
||||||
ce.User.pendingInteractionsLock.Unlock()
|
ce.User.pendingInteractionsLock.Unlock()
|
||||||
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce)
|
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce, ce.Portal.RefererOpt(""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ce.Reply("Error sending interaction: %v", err)
|
ce.Reply("Error sending interaction: %v", err)
|
||||||
ce.User.pendingInteractionsLock.Lock()
|
ce.User.pendingInteractionsLock.Lock()
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ type BridgeConfig struct {
|
|||||||
DisplaynameTemplate string `yaml:"displayname_template"`
|
DisplaynameTemplate string `yaml:"displayname_template"`
|
||||||
ChannelNameTemplate string `yaml:"channel_name_template"`
|
ChannelNameTemplate string `yaml:"channel_name_template"`
|
||||||
GuildNameTemplate string `yaml:"guild_name_template"`
|
GuildNameTemplate string `yaml:"guild_name_template"`
|
||||||
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
|
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
|
||||||
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
|
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
|
||||||
|
|
||||||
PortalMessageBuffer int `yaml:"portal_message_buffer"`
|
PortalMessageBuffer int `yaml:"portal_message_buffer"`
|
||||||
|
|
||||||
|
PublicAddress string `yaml:"public_address"`
|
||||||
|
AvatarProxyKey string `yaml:"avatar_proxy_key"`
|
||||||
|
|
||||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
||||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
MessageStatusEvents bool `yaml:"message_status_events"`
|
||||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
MessageErrorNotices bool `yaml:"message_error_notices"`
|
||||||
@@ -50,19 +53,43 @@ type BridgeConfig struct {
|
|||||||
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
|
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
|
||||||
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
|
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
|
||||||
FederateRooms bool `yaml:"federate_rooms"`
|
FederateRooms bool `yaml:"federate_rooms"`
|
||||||
|
PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
|
||||||
|
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
|
||||||
|
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
|
||||||
|
|
||||||
DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"`
|
Proxy string `yaml:"proxy"`
|
||||||
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
|
|
||||||
LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
|
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"`
|
CommandPrefix string `yaml:"command_prefix"`
|
||||||
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
|
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"`
|
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
|
||||||
|
|
||||||
Provisioning struct {
|
Provisioning struct {
|
||||||
Prefix string `yaml:"prefix"`
|
Prefix string `yaml:"prefix"`
|
||||||
SharedSecret string `yaml:"shared_secret"`
|
SharedSecret string `yaml:"shared_secret"`
|
||||||
|
DebugEndpoints bool `yaml:"debug_endpoints"`
|
||||||
} `yaml:"provisioning"`
|
} `yaml:"provisioning"`
|
||||||
|
|
||||||
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
|
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
|
||||||
@@ -73,6 +100,20 @@ type BridgeConfig struct {
|
|||||||
guildNameTemplate *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 {
|
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
|
||||||
return bc.ResendBridgeInfo
|
return bc.ResendBridgeInfo
|
||||||
}
|
}
|
||||||
@@ -135,6 +176,10 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|||||||
|
|
||||||
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
|
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
|
||||||
|
|
||||||
|
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
|
||||||
|
return bc.DoublePuppetConfig
|
||||||
|
}
|
||||||
|
|
||||||
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
|
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
|
||||||
return bc.Encryption
|
return bc.Encryption
|
||||||
}
|
}
|
||||||
@@ -153,9 +198,19 @@ func (bc BridgeConfig) FormatUsername(userID string) string {
|
|||||||
return buffer.String()
|
return buffer.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) 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
|
var buffer strings.Builder
|
||||||
_ = bc.displaynameTemplate.Execute(&buffer, user)
|
_ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
|
||||||
|
User: user,
|
||||||
|
Webhook: webhook,
|
||||||
|
Application: application,
|
||||||
|
})
|
||||||
return buffer.String()
|
return buffer.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type Config struct {
|
|||||||
|
|
||||||
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
||||||
_, homeserver, _ := userID.Parse()
|
_, homeserver, _ := userID.Parse()
|
||||||
_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
|
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
|
||||||
|
|
||||||
return hasSecret
|
return hasSecret
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
// Copyright (C) 2023 Tulir Asokan
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -17,9 +17,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
up "go.mau.fi/util/configupgrade"
|
||||||
|
"go.mau.fi/util/random"
|
||||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||||
"maunium.net/go/mautrix/util"
|
"maunium.net/go/mautrix/federation"
|
||||||
up "maunium.net/go/mautrix/util/configupgrade"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func DoUpgrade(helper *up.Helper) {
|
func DoUpgrade(helper *up.Helper) {
|
||||||
@@ -29,8 +30,22 @@ func DoUpgrade(helper *up.Helper) {
|
|||||||
helper.Copy(up.Str, "bridge", "displayname_template")
|
helper.Copy(up.Str, "bridge", "displayname_template")
|
||||||
helper.Copy(up.Str, "bridge", "channel_name_template")
|
helper.Copy(up.Str, "bridge", "channel_name_template")
|
||||||
helper.Copy(up.Str, "bridge", "guild_name_template")
|
helper.Copy(up.Str, "bridge", "guild_name_template")
|
||||||
helper.Copy(up.Bool, "bridge", "private_chat_portal_meta")
|
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.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.Int, "bridge", "portal_message_buffer")
|
||||||
helper.Copy(up.Bool, "bridge", "delivery_receipts")
|
helper.Copy(up.Bool, "bridge", "delivery_receipts")
|
||||||
helper.Copy(up.Bool, "bridge", "message_status_events")
|
helper.Copy(up.Bool, "bridge", "message_status_events")
|
||||||
@@ -45,6 +60,25 @@ func DoUpgrade(helper *up.Helper) {
|
|||||||
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
|
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
|
||||||
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
|
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
|
||||||
helper.Copy(up.Bool, "bridge", "federate_rooms")
|
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.Map, "bridge", "double_puppet_server_map")
|
||||||
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
|
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
|
||||||
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
|
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
|
||||||
@@ -53,25 +87,45 @@ func DoUpgrade(helper *up.Helper) {
|
|||||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
|
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
|
||||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
|
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.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", "allow")
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "default")
|
helper.Copy(up.Bool, "bridge", "encryption", "default")
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "require")
|
helper.Copy(up.Bool, "bridge", "encryption", "require")
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
|
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", "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", "receive")
|
||||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
|
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
|
||||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
|
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
|
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
|
||||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
|
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
|
||||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
|
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")
|
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
|
||||||
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
|
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
|
||||||
sharedSecret := util.RandomString(64)
|
sharedSecret := random.String(64)
|
||||||
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
|
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
|
||||||
} else {
|
} else {
|
||||||
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
|
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.Map, "bridge", "permissions")
|
||||||
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
|
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
|
||||||
|
|||||||
215
custompuppet.go
215
custompuppet.go
@@ -1,175 +1,72 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha512"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
|
||||||
"maunium.net/go/mautrix/appservice"
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNoCustomMXID = errors.New("no custom mxid set")
|
|
||||||
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
|
|
||||||
)
|
|
||||||
|
|
||||||
func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
|
|
||||||
_, homeserver, err := mxid.Parse()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
|
|
||||||
if !found {
|
|
||||||
if homeserver == br.AS.HomeserverDomain {
|
|
||||||
homeserverURL = br.AS.HomeserverURL
|
|
||||||
} else if br.Config.Bridge.DoublePuppetAllowDiscovery {
|
|
||||||
resp, err := mautrix.DiscoverClientAPI(homeserver)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
homeserverURL = resp.Homeserver.BaseURL
|
|
||||||
br.Log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Logger = br.AS.Log.Sub(mxid.String())
|
|
||||||
client.Client = br.AS.HTTPClient
|
|
||||||
client.DefaultHTTPRetries = br.AS.DefaultHTTPRetries
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) clearCustomMXID() {
|
|
||||||
puppet.CustomMXID = ""
|
|
||||||
puppet.AccessToken = ""
|
|
||||||
puppet.customIntent = nil
|
|
||||||
puppet.customUser = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
|
|
||||||
if puppet.CustomMXID == "" {
|
|
||||||
return nil, ErrNoCustomMXID
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := puppet.bridge.newDoublePuppetClient(puppet.CustomMXID, puppet.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ia := puppet.bridge.AS.NewIntentAPI("custom")
|
|
||||||
ia.Client = client
|
|
||||||
ia.Localpart, _, _ = puppet.CustomMXID.Parse()
|
|
||||||
ia.UserID = puppet.CustomMXID
|
|
||||||
ia.IsCustomPuppet = true
|
|
||||||
return ia, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
|
|
||||||
if puppet.CustomMXID == "" {
|
|
||||||
puppet.clearCustomMXID()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
intent, err := puppet.newCustomIntent()
|
|
||||||
if err != nil {
|
|
||||||
puppet.clearCustomMXID()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := intent.Whoami()
|
|
||||||
if err != nil {
|
|
||||||
if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !puppet.tryRelogin(err, "initializing double puppeting")) {
|
|
||||||
puppet.clearCustomMXID()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
intent.AccessToken = puppet.AccessToken
|
|
||||||
} else if resp.UserID != puppet.CustomMXID {
|
|
||||||
puppet.clearCustomMXID()
|
|
||||||
return ErrMismatchingMXID
|
|
||||||
}
|
|
||||||
|
|
||||||
puppet.customIntent = intent
|
|
||||||
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) tryRelogin(cause error, action string) bool {
|
|
||||||
if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
|
|
||||||
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
|
|
||||||
if err != nil {
|
|
||||||
puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
|
|
||||||
puppet.AccessToken = accessToken
|
|
||||||
puppet.Update()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
|
|
||||||
_, homeserver, _ := mxid.Parse()
|
|
||||||
puppet.log.Debugfln("Logging into %s with shared secret", mxid)
|
|
||||||
loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]
|
|
||||||
client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
|
|
||||||
}
|
|
||||||
req := mautrix.ReqLogin{
|
|
||||||
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
|
|
||||||
DeviceID: "Discord Bridge",
|
|
||||||
InitialDeviceDisplayName: "Discord Bridge",
|
|
||||||
}
|
|
||||||
if loginSecret == "appservice" {
|
|
||||||
client.AccessToken = puppet.bridge.AS.Registration.AppToken
|
|
||||||
req.Type = mautrix.AuthTypeAppservice
|
|
||||||
} else {
|
|
||||||
mac := hmac.New(sha512.New, []byte(loginSecret))
|
|
||||||
mac.Write([]byte(mxid))
|
|
||||||
req.Password = hex.EncodeToString(mac.Sum(nil))
|
|
||||||
req.Type = mautrix.AuthTypePassword
|
|
||||||
}
|
|
||||||
resp, err := client.Login(&req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return resp.AccessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
|
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
|
||||||
prevCustomMXID := puppet.CustomMXID
|
|
||||||
puppet.CustomMXID = mxid
|
puppet.CustomMXID = mxid
|
||||||
puppet.AccessToken = accessToken
|
puppet.AccessToken = accessToken
|
||||||
|
puppet.Update()
|
||||||
err := puppet.StartCustomMXID(false)
|
err := puppet.StartCustomMXID(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if prevCustomMXID != "" {
|
|
||||||
delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
|
|
||||||
}
|
|
||||||
if puppet.CustomMXID != "" {
|
|
||||||
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
|
|
||||||
}
|
|
||||||
puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID)
|
|
||||||
puppet.Update()
|
|
||||||
// TODO leave rooms with default puppet
|
// TODO leave rooms with default puppet
|
||||||
return nil
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import (
|
|||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
"maunium.net/go/maulogger/v2"
|
"maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/database/upgrades"
|
"go.mau.fi/mautrix-discord/database/upgrades"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ type Database struct {
|
|||||||
Message *MessageQuery
|
Message *MessageQuery
|
||||||
Thread *ThreadQuery
|
Thread *ThreadQuery
|
||||||
Reaction *ReactionQuery
|
Reaction *ReactionQuery
|
||||||
Emoji *EmojiQuery
|
|
||||||
Guild *GuildQuery
|
Guild *GuildQuery
|
||||||
Role *RoleQuery
|
Role *RoleQuery
|
||||||
File *FileQuery
|
File *FileQuery
|
||||||
@@ -54,10 +52,6 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
|
|||||||
db: db,
|
db: db,
|
||||||
log: log.Sub("Reaction"),
|
log: log.Sub("Reaction"),
|
||||||
}
|
}
|
||||||
db.Emoji = &EmojiQuery{
|
|
||||||
db: db,
|
|
||||||
log: log.Sub("Emoji"),
|
|
||||||
}
|
|
||||||
db.Guild = &GuildQuery{
|
db.Guild = &GuildQuery{
|
||||||
db: db,
|
db: db,
|
||||||
log: log.Sub("Guild"),
|
log: log.Sub("Guild"),
|
||||||
@@ -73,9 +67,10 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func strPtr(val string) *string {
|
func strPtr[T ~string](val T) *string {
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &val
|
valStr := string(val)
|
||||||
|
return &valStr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
log "maunium.net/go/maulogger/v2"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmojiQuery struct {
|
|
||||||
db *Database
|
|
||||||
log log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
emojiSelect = "SELECT discord_id, discord_name, matrix_url FROM emoji"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (eq *EmojiQuery) New() *Emoji {
|
|
||||||
return &Emoji{
|
|
||||||
db: eq.db,
|
|
||||||
log: eq.log,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (eq *EmojiQuery) GetByDiscordID(discordID string) *Emoji {
|
|
||||||
query := emojiSelect + " WHERE discord_id=$1"
|
|
||||||
return eq.get(query, discordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (eq *EmojiQuery) GetByMatrixURL(matrixURL id.ContentURI) *Emoji {
|
|
||||||
query := emojiSelect + " WHERE matrix_url=$1"
|
|
||||||
return eq.get(query, matrixURL.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (eq *EmojiQuery) get(query string, args ...interface{}) *Emoji {
|
|
||||||
return eq.New().Scan(eq.db.QueryRow(query, args...))
|
|
||||||
}
|
|
||||||
|
|
||||||
type Emoji struct {
|
|
||||||
db *Database
|
|
||||||
log log.Logger
|
|
||||||
|
|
||||||
DiscordID string
|
|
||||||
DiscordName string
|
|
||||||
|
|
||||||
MatrixURL id.ContentURI
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Emoji) Scan(row dbutil.Scannable) *Emoji {
|
|
||||||
var matrixURL sql.NullString
|
|
||||||
|
|
||||||
err := row.Scan(&e.DiscordID, &e.DiscordName, &matrixURL)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
e.log.Errorln("Database scan failed:", err)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
e.MatrixURL, _ = id.ParseContentURI(matrixURL.String)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Emoji) Insert() {
|
|
||||||
query := "INSERT INTO emoji" +
|
|
||||||
" (discord_id, discord_name, matrix_url)" +
|
|
||||||
" VALUES ($1, $2, $3);"
|
|
||||||
|
|
||||||
_, err := e.db.Exec(query, e.DiscordID, e.DiscordName, e.MatrixURL.String())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warnfln("Failed to insert emoji %s: %v", e.DiscordID, err)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Emoji) Delete() {
|
|
||||||
query := "DELETE FROM emoji WHERE discord_id=$1"
|
|
||||||
|
|
||||||
_, err := e.db.Exec(query, e.DiscordID)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warnfln("Failed to delete emoji %s: %v", e.DiscordID, err)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Emoji) APIName() string {
|
|
||||||
if e.DiscordID != "" && e.DiscordName != "" {
|
|
||||||
return e.DiscordName + ":" + e.DiscordID
|
|
||||||
} else if e.DiscordName != "" {
|
|
||||||
return e.DiscordName
|
|
||||||
}
|
|
||||||
return e.DiscordID
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/crypto/attachment"
|
"maunium.net/go/mautrix/crypto/attachment"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileQuery struct {
|
type FileQuery struct {
|
||||||
@@ -20,10 +19,10 @@ type FileQuery struct {
|
|||||||
|
|
||||||
// language=postgresql
|
// language=postgresql
|
||||||
const (
|
const (
|
||||||
fileSelect = "SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
|
fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
|
||||||
fileInsert = `
|
fileInsert = `
|
||||||
INSERT INTO discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,15 +38,21 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
|
|||||||
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
|
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 {
|
type File struct {
|
||||||
db *Database
|
db *Database
|
||||||
log log.Logger
|
log log.Logger
|
||||||
|
|
||||||
URL string
|
URL string
|
||||||
Encrypted bool
|
Encrypted bool
|
||||||
|
MXC id.ContentURI
|
||||||
|
|
||||||
ID string
|
ID string
|
||||||
MXC id.ContentURI
|
EmojiName string
|
||||||
|
|
||||||
Size int
|
Size int
|
||||||
Width int
|
Width int
|
||||||
@@ -55,16 +60,15 @@ type File struct {
|
|||||||
MimeType string
|
MimeType string
|
||||||
|
|
||||||
DecryptionInfo *attachment.EncryptedFile
|
DecryptionInfo *attachment.EncryptedFile
|
||||||
|
Timestamp time.Time
|
||||||
Timestamp time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *File) Scan(row dbutil.Scannable) *File {
|
func (f *File) Scan(row dbutil.Scannable) *File {
|
||||||
var fileID, decryptionInfo sql.NullString
|
var fileID, emojiName, decryptionInfo sql.NullString
|
||||||
var width, height sql.NullInt32
|
var width, height sql.NullInt32
|
||||||
var timestamp int64
|
var timestamp int64
|
||||||
var mxc string
|
var mxc string
|
||||||
err := row.Scan(&f.URL, &f.Encrypted, &fileID, &mxc, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp)
|
err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
f.log.Errorln("Database scan failed:", err)
|
f.log.Errorln("Database scan failed:", err)
|
||||||
@@ -73,7 +77,8 @@ func (f *File) Scan(row dbutil.Scannable) *File {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.ID = fileID.String
|
f.ID = fileID.String
|
||||||
f.Timestamp = time.UnixMilli(timestamp)
|
f.EmojiName = emojiName.String
|
||||||
|
f.Timestamp = time.UnixMilli(timestamp).UTC()
|
||||||
f.Width = int(width.Int32)
|
f.Width = int(width.Int32)
|
||||||
f.Height = int(height.Int32)
|
f.Height = int(height.Int32)
|
||||||
f.MXC, err = id.ParseContentURI(mxc)
|
f.MXC, err = id.ParseContentURI(mxc)
|
||||||
@@ -114,7 +119,7 @@ func (f *File) Insert(txn dbutil.Execable) {
|
|||||||
decryptionInfoStr.String = string(decryptionInfo)
|
decryptionInfoStr.String = string(decryptionInfo)
|
||||||
}
|
}
|
||||||
_, err := txn.Exec(fileInsert,
|
_, err := txn.Exec(fileInsert,
|
||||||
f.URL, f.Encrypted, strPtr(f.ID), f.MXC.String(), f.Size,
|
f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
|
||||||
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
|
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
|
||||||
decryptionInfoStr, f.Timestamp.UnixMilli(),
|
decryptionInfoStr, f.Timestamp.UnixMilli(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,20 +3,84 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 {
|
type GuildQuery struct {
|
||||||
db *Database
|
db *Database
|
||||||
log log.Logger
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, auto_bridge_channels FROM guild"
|
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (gq *GuildQuery) New() *Guild {
|
func (gq *GuildQuery) New() *Guild {
|
||||||
@@ -67,13 +131,13 @@ type Guild struct {
|
|||||||
AvatarURL id.ContentURI
|
AvatarURL id.ContentURI
|
||||||
AvatarSet bool
|
AvatarSet bool
|
||||||
|
|
||||||
AutoBridgeChannels bool
|
BridgingMode GuildBridgingMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
|
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
|
||||||
var mxid sql.NullString
|
var mxid sql.NullString
|
||||||
var avatarURL string
|
var avatarURL string
|
||||||
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.AutoBridgeChannels)
|
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
g.log.Errorln("Database scan failed:", err)
|
g.log.Errorln("Database scan failed:", err)
|
||||||
@@ -82,6 +146,9 @@ func (g *Guild) Scan(row dbutil.Scannable) *Guild {
|
|||||||
|
|
||||||
return nil
|
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.MXID = id.RoomID(mxid.String)
|
||||||
g.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
g.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
||||||
return g
|
return g
|
||||||
@@ -96,10 +163,10 @@ func (g *Guild) mxidPtr() *id.RoomID {
|
|||||||
|
|
||||||
func (g *Guild) Insert() {
|
func (g *Guild) Insert() {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, auto_bridge_channels)
|
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)
|
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.AutoBridgeChannels)
|
_, 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 {
|
if err != nil {
|
||||||
g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
|
g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
@@ -108,10 +175,10 @@ func (g *Guild) Insert() {
|
|||||||
|
|
||||||
func (g *Guild) Update() {
|
func (g *Guild) Update() {
|
||||||
query := `
|
query := `
|
||||||
UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, auto_bridge_channels=$8
|
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
|
WHERE dcid=$9
|
||||||
`
|
`
|
||||||
_, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.AutoBridgeChannels, g.ID)
|
_, 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 {
|
if err != nil {
|
||||||
g.log.Warnfln("Failed to update %s: %v", g.ID, err)
|
g.log.Warnfln("Failed to update %s: %v", g.ID, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
20
database/json.go
Normal file
20
database/json.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backported from mautrix/go-util@e5cb5e96d15cb87ffe6e5970c2f90ee47980e715.
|
||||||
|
|
||||||
|
// JSONPtr is a convenience function for wrapping a pointer to a value in the JSON utility, but removing typed nils
|
||||||
|
// (i.e. preventing nils from turning into the string "null" in the database).
|
||||||
|
func JSONPtr[T any](val *T) dbutil.JSON {
|
||||||
|
return dbutil.JSON{Data: UntypedNil(val)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UntypedNil[T any](val *T) any {
|
||||||
|
if val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
@@ -7,10 +7,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageQuery struct {
|
type MessageQuery struct {
|
||||||
@@ -19,7 +18,7 @@ type MessageQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
messageSelect = "SELECT dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message"
|
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 {
|
func (mq *MessageQuery) New() *Message {
|
||||||
@@ -46,17 +45,17 @@ func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
|
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
|
||||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id ASC"
|
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))
|
return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
|
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
|
||||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id ASC LIMIT 1"
|
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))
|
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
|
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
|
||||||
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 AND dc_edit_index=0 ORDER BY dc_attachment_id DESC LIMIT 1"
|
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))
|
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +65,15 @@ func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message {
|
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 AND dc_edit_index=0 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
|
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))
|
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) {
|
func (mq *MessageQuery) DeleteAll(key PortalKey) {
|
||||||
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
|
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
|
||||||
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
|
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
|
||||||
@@ -90,19 +94,51 @@ func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
|
|||||||
return mq.New().Scan(row)
|
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 {
|
type Message struct {
|
||||||
db *Database
|
db *Database
|
||||||
log log.Logger
|
log log.Logger
|
||||||
|
|
||||||
DiscordID string
|
DiscordID string
|
||||||
AttachmentID string
|
AttachmentID string
|
||||||
EditIndex int
|
Channel PortalKey
|
||||||
Channel PortalKey
|
SenderID string
|
||||||
SenderID string
|
Timestamp time.Time
|
||||||
Timestamp time.Time
|
EditTimestamp time.Time
|
||||||
ThreadID string
|
ThreadID string
|
||||||
|
|
||||||
MXID id.EventID
|
MXID id.EventID
|
||||||
|
SenderMXID id.UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) DiscordProtoChannelID() string {
|
func (m *Message) DiscordProtoChannelID() string {
|
||||||
@@ -114,9 +150,9 @@ func (m *Message) DiscordProtoChannelID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) Scan(row dbutil.Scannable) *Message {
|
func (m *Message) Scan(row dbutil.Scannable) *Message {
|
||||||
var ts int64
|
var ts, editTS int64
|
||||||
|
|
||||||
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.EditIndex, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &m.ThreadID, &m.MXID)
|
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 err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
m.log.Errorln("Database scan failed:", err)
|
m.log.Errorln("Database scan failed:", err)
|
||||||
@@ -127,7 +163,10 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ts != 0 {
|
if ts != 0 {
|
||||||
m.Timestamp = time.UnixMilli(ts)
|
m.Timestamp = time.UnixMilli(ts).UTC()
|
||||||
|
}
|
||||||
|
if editTS != 0 {
|
||||||
|
m.EditTimestamp = time.Unix(0, editTS).UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
@@ -135,39 +174,47 @@ func (m *Message) Scan(row dbutil.Scannable) *Message {
|
|||||||
|
|
||||||
const messageInsertQuery = `
|
const messageInsertQuery = `
|
||||||
INSERT INTO message (
|
INSERT INTO message (
|
||||||
dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid
|
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)
|
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)", "%s", 1)
|
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1)
|
||||||
|
|
||||||
type MessagePart struct {
|
type MessagePart struct {
|
||||||
AttachmentID string
|
AttachmentID string
|
||||||
MXID id.EventID
|
MXID id.EventID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) MassInsert(msgs []MessagePart) {
|
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 {
|
if len(msgs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d)"
|
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)"
|
||||||
if m.db.Dialect == dbutil.SQLite {
|
if m.db.Dialect == dbutil.SQLite {
|
||||||
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
|
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
|
||||||
}
|
}
|
||||||
params := make([]interface{}, 7+len(msgs)*2)
|
params := make([]interface{}, 8+len(msgs)*2)
|
||||||
placeholders := make([]string, len(msgs))
|
placeholders := make([]string, len(msgs))
|
||||||
params[0] = m.DiscordID
|
params[0] = m.DiscordID
|
||||||
params[1] = m.EditIndex
|
params[1] = m.Channel.ChannelID
|
||||||
params[2] = m.Channel.ChannelID
|
params[2] = m.Channel.Receiver
|
||||||
params[3] = m.Channel.Receiver
|
params[3] = m.SenderID
|
||||||
params[4] = m.SenderID
|
params[4] = m.Timestamp.UnixMilli()
|
||||||
params[5] = m.Timestamp.UnixMilli()
|
params[5] = m.editTimestampVal()
|
||||||
params[6] = m.ThreadID
|
params[6] = m.ThreadID
|
||||||
|
params[7] = m.SenderMXID.String()
|
||||||
for i, msg := range msgs {
|
for i, msg := range msgs {
|
||||||
params[7+i*2] = msg.AttachmentID
|
params[8+i*2] = msg.AttachmentID
|
||||||
params[7+i*2+1] = msg.MXID
|
params[8+i*2+1] = msg.MXID
|
||||||
placeholders[i] = fmt.Sprintf(valueStringFormat, 7+i*2+1, 7+i*2+2)
|
placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
|
||||||
}
|
}
|
||||||
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
|
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,8 +225,8 @@ func (m *Message) MassInsert(msgs []MessagePart) {
|
|||||||
|
|
||||||
func (m *Message) Insert() {
|
func (m *Message) Insert() {
|
||||||
_, err := m.db.Exec(messageInsertQuery,
|
_, err := m.db.Exec(messageInsertQuery,
|
||||||
m.DiscordID, m.AttachmentID, m.EditIndex, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
|
m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
|
||||||
m.Timestamp.UnixMilli(), m.ThreadID, m.MXID)
|
m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
|
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
|
||||||
@@ -187,6 +234,20 @@ func (m *Message) Insert() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
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"
|
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)
|
_, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// language=postgresql
|
// language=postgresql
|
||||||
const (
|
const (
|
||||||
portalSelect = `
|
portalSelect = `
|
||||||
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
||||||
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
|
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
|
||||||
encrypted, in_space, first_event_id
|
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
|
||||||
FROM portal
|
FROM portal
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
@@ -68,6 +66,10 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
|
|||||||
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
|
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 {
|
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
|
||||||
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
|
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
|
||||||
}
|
}
|
||||||
@@ -109,28 +111,32 @@ type Portal struct {
|
|||||||
|
|
||||||
MXID id.RoomID
|
MXID id.RoomID
|
||||||
|
|
||||||
PlainName string
|
PlainName string
|
||||||
Name string
|
Name string
|
||||||
NameSet bool
|
NameSet bool
|
||||||
Topic string
|
FriendNick bool
|
||||||
TopicSet bool
|
Topic string
|
||||||
Avatar string
|
TopicSet bool
|
||||||
AvatarURL id.ContentURI
|
Avatar string
|
||||||
AvatarSet bool
|
AvatarURL id.ContentURI
|
||||||
Encrypted bool
|
AvatarSet bool
|
||||||
InSpace id.RoomID
|
Encrypted bool
|
||||||
|
InSpace id.RoomID
|
||||||
|
|
||||||
FirstEventID id.EventID
|
FirstEventID id.EventID
|
||||||
|
|
||||||
|
RelayWebhookID string
|
||||||
|
RelayWebhookSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
|
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
|
||||||
var otherUserID, guildID, parentID, mxid, firstEventID sql.NullString
|
var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
|
||||||
var chanType int32
|
var chanType int32
|
||||||
var avatarURL string
|
var avatarURL string
|
||||||
|
|
||||||
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
|
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
|
||||||
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
|
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
|
||||||
&p.Encrypted, &p.InSpace, &firstEventID)
|
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
@@ -148,6 +154,8 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
|
|||||||
p.Type = discordgo.ChannelType(chanType)
|
p.Type = discordgo.ChannelType(chanType)
|
||||||
p.FirstEventID = id.EventID(firstEventID.String)
|
p.FirstEventID = id.EventID(firstEventID.String)
|
||||||
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
|
||||||
|
p.RelayWebhookID = relayWebhookID.String
|
||||||
|
p.RelayWebhookSecret = relayWebhookSecret.String
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
@@ -155,14 +163,14 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
|
|||||||
func (p *Portal) Insert() {
|
func (p *Portal) Insert() {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
|
||||||
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
|
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
|
||||||
encrypted, in_space, first_event_id)
|
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)
|
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,
|
_, 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)),
|
strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
|
||||||
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
|
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())
|
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
|
p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
|
||||||
@@ -174,14 +182,16 @@ func (p *Portal) Update() {
|
|||||||
query := `
|
query := `
|
||||||
UPDATE portal
|
UPDATE portal
|
||||||
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
|
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, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13,
|
plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
|
||||||
encrypted=$14, in_space=$15, first_event_id=$16
|
avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
|
||||||
WHERE dcid=$17 AND receiver=$18
|
relay_webhook_id=$18, relay_webhook_secret=$19
|
||||||
|
WHERE dcid=$20 AND receiver=$21
|
||||||
`
|
`
|
||||||
_, err := p.db.Exec(query,
|
_, err := p.db.Exec(query,
|
||||||
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
|
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
|
||||||
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
|
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
|
||||||
p.Encrypted, p.InSpace, p.FirstEventID.String(),
|
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)
|
p.Key.ChannelID, p.Key.Receiver)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
|
puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
|
||||||
" custom_mxid, access_token, next_batch" +
|
" contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" +
|
||||||
" FROM puppet "
|
" FROM puppet "
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,6 +72,15 @@ type Puppet struct {
|
|||||||
AvatarURL id.ContentURI
|
AvatarURL id.ContentURI
|
||||||
AvatarSet bool
|
AvatarSet bool
|
||||||
|
|
||||||
|
ContactInfoSet bool
|
||||||
|
|
||||||
|
GlobalName string
|
||||||
|
Username string
|
||||||
|
Discriminator string
|
||||||
|
IsBot bool
|
||||||
|
IsWebhook bool
|
||||||
|
IsApplication bool
|
||||||
|
|
||||||
CustomMXID id.UserID
|
CustomMXID id.UserID
|
||||||
AccessToken string
|
AccessToken string
|
||||||
NextBatch string
|
NextBatch string
|
||||||
@@ -82,8 +90,8 @@ func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
|
|||||||
var avatarURL string
|
var avatarURL string
|
||||||
var customMXID, accessToken, nextBatch sql.NullString
|
var customMXID, accessToken, nextBatch sql.NullString
|
||||||
|
|
||||||
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet,
|
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet,
|
||||||
&customMXID, &accessToken, &nextBatch)
|
&p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
@@ -104,11 +112,16 @@ func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
|
|||||||
|
|
||||||
func (p *Puppet) Insert() {
|
func (p *Puppet) Insert() {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO puppet (id, name, name_set, avatar, avatar_url, avatar_set, custom_mxid, access_token, next_batch)
|
INSERT INTO puppet (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
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,
|
_, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
|
||||||
strPtr(string(p.CustomMXID)), strPtr(p.AccessToken), strPtr(p.NextBatch))
|
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
|
||||||
|
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
|
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
|
||||||
@@ -118,13 +131,18 @@ func (p *Puppet) Insert() {
|
|||||||
|
|
||||||
func (p *Puppet) Update() {
|
func (p *Puppet) Update() {
|
||||||
query := `
|
query := `
|
||||||
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5,
|
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6,
|
||||||
custom_mxid=$6, access_token=$7, next_batch=$8
|
global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12,
|
||||||
WHERE id=$9
|
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,
|
_, err := p.db.Exec(
|
||||||
strPtr(string(p.CustomMXID)), strPtr(p.AccessToken), strPtr(p.NextBatch),
|
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),
|
||||||
|
p.ID,
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warnfln("Failed to update %s: %v", p.ID, err)
|
p.log.Warnfln("Failed to update %s: %v", p.ID, err)
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReactionQuery struct {
|
type ReactionQuery struct {
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
log "maunium.net/go/maulogger/v2"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
log "maunium.net/go/maulogger/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoleQuery struct {
|
type RoleQuery struct {
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ThreadQuery struct {
|
type ThreadQuery struct {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- v0 -> v12: Latest revision
|
-- v0 -> v24 (compatible with v19+): Latest revision
|
||||||
|
|
||||||
CREATE TABLE guild (
|
CREATE TABLE guild (
|
||||||
dcid TEXT PRIMARY KEY,
|
dcid TEXT PRIMARY KEY,
|
||||||
@@ -10,7 +10,7 @@ CREATE TABLE guild (
|
|||||||
avatar_url TEXT NOT NULL,
|
avatar_url TEXT NOT NULL,
|
||||||
avatar_set BOOLEAN NOT NULL,
|
avatar_set BOOLEAN NOT NULL,
|
||||||
|
|
||||||
auto_bridge_channels BOOLEAN NOT NULL
|
bridging_mode INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE portal (
|
CREATE TABLE portal (
|
||||||
@@ -29,6 +29,7 @@ CREATE TABLE portal (
|
|||||||
plain_name TEXT NOT NULL,
|
plain_name TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
name_set BOOLEAN NOT NULL,
|
name_set BOOLEAN NOT NULL,
|
||||||
|
friend_nick BOOLEAN NOT NULL,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
topic_set BOOLEAN NOT NULL,
|
topic_set BOOLEAN NOT NULL,
|
||||||
avatar TEXT NOT NULL,
|
avatar TEXT NOT NULL,
|
||||||
@@ -39,6 +40,9 @@ CREATE TABLE portal (
|
|||||||
|
|
||||||
first_event_id TEXT NOT NULL,
|
first_event_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
relay_webhook_id TEXT,
|
||||||
|
relay_webhook_secret TEXT,
|
||||||
|
|
||||||
PRIMARY KEY (dcid, receiver),
|
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_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
|
CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
|
||||||
@@ -59,11 +63,20 @@ CREATE TABLE thread (
|
|||||||
CREATE TABLE puppet (
|
CREATE TABLE puppet (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
name_set BOOLEAN NOT NULL,
|
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||||
avatar TEXT NOT NULL,
|
avatar TEXT NOT NULL,
|
||||||
avatar_url TEXT NOT NULL,
|
avatar_url TEXT NOT NULL,
|
||||||
avatar_set BOOLEAN 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,
|
custom_mxid TEXT,
|
||||||
access_token TEXT,
|
access_token TEXT,
|
||||||
@@ -79,7 +92,8 @@ CREATE TABLE "user" (
|
|||||||
space_room TEXT,
|
space_room TEXT,
|
||||||
dm_space_room TEXT,
|
dm_space_room TEXT,
|
||||||
|
|
||||||
read_state_version INTEGER NOT NULL DEFAULT 0
|
read_state_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
heartbeat_session jsonb
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE user_portal (
|
CREATE TABLE user_portal (
|
||||||
@@ -94,18 +108,19 @@ CREATE TABLE user_portal (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE message (
|
CREATE TABLE message (
|
||||||
dcid TEXT,
|
dcid TEXT,
|
||||||
dc_attachment_id TEXT,
|
dc_attachment_id TEXT,
|
||||||
dc_edit_index INTEGER,
|
dc_chan_id TEXT,
|
||||||
dc_chan_id TEXT,
|
dc_chan_receiver TEXT,
|
||||||
dc_chan_receiver TEXT,
|
dc_sender TEXT NOT NULL,
|
||||||
dc_sender TEXT NOT NULL,
|
timestamp BIGINT NOT NULL,
|
||||||
timestamp BIGINT NOT NULL,
|
dc_edit_timestamp BIGINT NOT NULL,
|
||||||
dc_thread_id TEXT NOT NULL,
|
dc_thread_id TEXT NOT NULL,
|
||||||
|
|
||||||
mxid TEXT NOT NULL UNIQUE,
|
mxid TEXT NOT NULL UNIQUE,
|
||||||
|
sender_mxid TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
|
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
|
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -117,19 +132,12 @@ CREATE TABLE reaction (
|
|||||||
dc_emoji_name TEXT,
|
dc_emoji_name TEXT,
|
||||||
dc_thread_id TEXT NOT NULL,
|
dc_thread_id TEXT NOT NULL,
|
||||||
|
|
||||||
dc_first_attachment_id TEXT NOT NULL,
|
dc_first_attachment_id TEXT NOT NULL,
|
||||||
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
mxid TEXT NOT NULL UNIQUE,
|
mxid TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
|
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
|
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 emoji (
|
|
||||||
discord_id TEXT PRIMARY KEY,
|
|
||||||
discord_name TEXT,
|
|
||||||
matrix_url TEXT
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE role (
|
CREATE TABLE role (
|
||||||
@@ -154,18 +162,19 @@ CREATE TABLE role (
|
|||||||
CREATE TABLE discord_file (
|
CREATE TABLE discord_file (
|
||||||
url TEXT,
|
url TEXT,
|
||||||
encrypted BOOLEAN,
|
encrypted BOOLEAN,
|
||||||
|
mxc TEXT NOT NULL,
|
||||||
|
|
||||||
id TEXT,
|
id TEXT,
|
||||||
mxc TEXT NOT NULL,
|
emoji_name TEXT,
|
||||||
|
|
||||||
size BIGINT NOT NULL,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
mime_type TEXT NOT NULL,
|
|
||||||
|
|
||||||
|
size BIGINT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
decryption_info jsonb,
|
decryption_info jsonb,
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
timestamp BIGINT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (url, encrypted)
|
PRIMARY KEY (url, encrypted)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);
|
||||||
|
|||||||
4
database/upgrades/13-merge-emoji-and-file.postgres.sql
Normal file
4
database/upgrades/13-merge-emoji-and-file.postgres.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- 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;
|
||||||
24
database/upgrades/13-merge-emoji-and-file.sqlite.sql
Normal file
24
database/upgrades/13-merge-emoji-and-file.sqlite.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- 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;
|
||||||
7
database/upgrades/14-guild-bridging-mode.sql
Normal file
7
database/upgrades/14-guild-bridging-mode.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- 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;
|
||||||
3
database/upgrades/15-portal-relay-webhook.sql
Normal file
3
database/upgrades/15-portal-relay-webhook.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 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;
|
||||||
3
database/upgrades/16-add-contact-info.sql
Normal file
3
database/upgrades/16-add-contact-info.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 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;
|
||||||
2
database/upgrades/17-dm-portal-friend-nick.sql
Normal file
2
database/upgrades/17-dm-portal-friend-nick.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- v17: Store whether DM portal name is a friend nickname
|
||||||
|
ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false;
|
||||||
4
database/upgrades/18-extra-ghost-metadata.sql
Normal file
4
database/upgrades/18-extra-ghost-metadata.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- 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;
|
||||||
15
database/upgrades/19-message-edit-ts.postgres.sql
Normal file
15
database/upgrades/19-message-edit-ts.postgres.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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;
|
||||||
48
database/upgrades/19-message-edit-ts.sqlite.sql
Normal file
48
database/upgrades/19-message-edit-ts.sqlite.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- 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;
|
||||||
2
database/upgrades/20-message-sender-mxid.sql
Normal file
2
database/upgrades/20-message-sender-mxid.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- v20 (compatible with v19+): Store message sender Matrix user ID
|
||||||
|
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';
|
||||||
3
database/upgrades/21-more-puppet-info.sql
Normal file
3
database/upgrades/21-more-puppet-info.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 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;
|
||||||
26
database/upgrades/22-file-cache-duplicate-mxc.sql
Normal file
26
database/upgrades/22-file-cache-duplicate-mxc.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- 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);
|
||||||
2
database/upgrades/23-puppet-is-application.sql
Normal file
2
database/upgrades/23-puppet-is-application.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- v23 (compatible with v19+): Store is application status for puppets
|
||||||
|
ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false;
|
||||||
2
database/upgrades/24-user-heartbeat-session.sql
Normal file
2
database/upgrades/24-user-heartbeat-session.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- v24 (compatible with v19+): Add persisted heartbeat sessions
|
||||||
|
ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb;
|
||||||
@@ -19,7 +19,7 @@ package upgrades
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
"go.mau.fi/util/dbutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Table dbutil.UpgradeTable
|
var Table dbutil.UpgradeTable
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserQuery struct {
|
type UserQuery struct {
|
||||||
@@ -22,18 +22,18 @@ func (uq *UserQuery) New() *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
|
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
|
||||||
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1`
|
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE mxid=$1`
|
||||||
return uq.New().Scan(uq.db.QueryRow(query, userID))
|
return uq.New().Scan(uq.db.QueryRow(query, userID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uq *UserQuery) GetByID(id string) *User {
|
func (uq *UserQuery) GetByID(id string) *User {
|
||||||
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1`
|
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE dcid=$1`
|
||||||
return uq.New().Scan(uq.db.QueryRow(query, id))
|
return uq.New().Scan(uq.db.QueryRow(query, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uq *UserQuery) GetAllWithToken() []*User {
|
func (uq *UserQuery) GetAllWithToken() []*User {
|
||||||
query := `
|
query := `
|
||||||
SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version
|
SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session
|
||||||
FROM "user" WHERE discord_token IS NOT NULL
|
FROM "user" WHERE discord_token IS NOT NULL
|
||||||
`
|
`
|
||||||
rows, err := uq.db.Query(query)
|
rows, err := uq.db.Query(query)
|
||||||
@@ -55,19 +55,20 @@ type User struct {
|
|||||||
db *Database
|
db *Database
|
||||||
log log.Logger
|
log log.Logger
|
||||||
|
|
||||||
MXID id.UserID
|
MXID id.UserID
|
||||||
DiscordID string
|
DiscordID string
|
||||||
DiscordToken string
|
DiscordToken string
|
||||||
ManagementRoom id.RoomID
|
ManagementRoom id.RoomID
|
||||||
SpaceRoom id.RoomID
|
SpaceRoom id.RoomID
|
||||||
DMSpaceRoom id.RoomID
|
DMSpaceRoom id.RoomID
|
||||||
|
HeartbeatSession *discordgo.HeartbeatSession
|
||||||
|
|
||||||
ReadStateVersion int
|
ReadStateVersion int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) Scan(row dbutil.Scannable) *User {
|
func (u *User) Scan(row dbutil.Scannable) *User {
|
||||||
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
|
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
|
||||||
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion)
|
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
u.log.Errorln("Database scan failed:", err)
|
u.log.Errorln("Database scan failed:", err)
|
||||||
@@ -84,8 +85,8 @@ func (u *User) Scan(row dbutil.Scannable) *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) Insert() {
|
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)`
|
query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
|
||||||
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion)
|
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
|
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
@@ -93,8 +94,8 @@ func (u *User) Insert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) Update() {
|
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`
|
query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6, heartbeat_session=$7 WHERE mxid=$8`
|
||||||
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID)
|
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession), u.MXID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
|
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
"maunium.net/go/mautrix/util/dbutil"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,7 +30,7 @@ func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
|
|||||||
l.Errorln("Error scanning user portal:", err)
|
l.Errorln("Error scanning user portal:", err)
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
up.Timestamp = time.UnixMilli(ts)
|
up.Timestamp = time.UnixMilli(ts).UTC()
|
||||||
return &up
|
return &up
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,24 @@ func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
|
|||||||
return ups
|
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 {
|
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)
|
rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
663
directmedia.go
Normal file
663
directmedia.go
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
// 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/media/thumbnail/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
|
||||||
|
federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet)
|
||||||
|
mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
|
||||||
|
mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
|
||||||
|
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/")
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
287
directmedia_id.go
Normal file
287
directmedia_id.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
19
discord.go
19
discord.go
@@ -18,30 +18,35 @@ func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
|
|||||||
return false
|
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)
|
member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
|
||||||
if errors.Is(err, discordgo.ErrStateNotFound) {
|
if errors.Is(err, discordgo.ErrStateNotFound) {
|
||||||
user.log.Debugfln("Fetching own membership in %s to check own roles", channel.GuildID)
|
log.Debug().Msg("Fetching own membership in guild to check roles")
|
||||||
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
|
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user.log.Warnfln("Failed to get own membership in %s from server to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err)
|
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
|
||||||
} else {
|
} else {
|
||||||
err = user.Session.State.MemberAdd(member)
|
err = user.Session.State.MemberAdd(member)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user.log.Warnfln("Failed to add own membership in %s to cache: %v", channel.GuildID, err)
|
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
user.log.Warnfln("Failed to get own membership in %s from cache to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err)
|
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
|
||||||
}
|
}
|
||||||
err = user.Session.State.ChannelAdd(channel)
|
err = user.Session.State.ChannelAdd(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user.log.Warnfln("Failed to add channel %s/%s to cache: %v", channel.GuildID, channel.ID, err)
|
log.Warn().Err(err).Msg("Failed to add channel to cache")
|
||||||
}
|
}
|
||||||
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
|
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user.log.Warnfln("Failed to get permissions in %s/%s to determine if it's bridgeable: %v", channel.GuildID, channel.ID, err)
|
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
user.log.Debugfln("Computed permissions in %s/%s: %d (view channel: %t)", channel.GuildID, channel.ID, perms, perms&discordgo.PermissionViewChannel > 0)
|
log.Debug().
|
||||||
|
Int64("permissions", perms).
|
||||||
|
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
|
||||||
|
Msg("Computed permissions in channel")
|
||||||
return perms&discordgo.PermissionViewChannel > 0
|
return perms&discordgo.PermissionViewChannel > 0
|
||||||
}
|
}
|
||||||
|
|||||||
79
emoji.go
79
emoji.go
@@ -1,79 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/appservice"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
|
|
||||||
dbEmoji := portal.bridge.DB.Emoji.GetByDiscordID(emojiID)
|
|
||||||
|
|
||||||
if dbEmoji == nil {
|
|
||||||
data, mimeType, err := portal.downloadDiscordEmoji(emojiID, animated)
|
|
||||||
if err != nil {
|
|
||||||
portal.log.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err)
|
|
||||||
return id.ContentURI{}
|
|
||||||
}
|
|
||||||
|
|
||||||
uri, err := portal.uploadMatrixEmoji(portal.MainIntent(), data, mimeType)
|
|
||||||
if err != nil {
|
|
||||||
portal.log.Warnfln("Failed to upload discord emoji %s to homeserver: %v", emojiID, err)
|
|
||||||
return id.ContentURI{}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbEmoji = portal.bridge.DB.Emoji.New()
|
|
||||||
dbEmoji.DiscordID = emojiID
|
|
||||||
dbEmoji.DiscordName = name
|
|
||||||
dbEmoji.MatrixURL = uri
|
|
||||||
dbEmoji.Insert()
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbEmoji.MatrixURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
|
|
||||||
var url string
|
|
||||||
var mimeType string
|
|
||||||
|
|
||||||
if animated {
|
|
||||||
// This url requests a gif, so that's what we set the mimetype to.
|
|
||||||
url = discordgo.EndpointEmojiAnimated(id)
|
|
||||||
mimeType = "image/gif"
|
|
||||||
} else {
|
|
||||||
// This url requests a png, so that's what we set the mimetype to.
|
|
||||||
url = discordgo.EndpointEmoji(id)
|
|
||||||
mimeType = "image/png"
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mimeType, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", discordgo.DroidBrowserUserAgent)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mimeType, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
return data, mimeType, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
|
|
||||||
uploaded, err := intent.UploadBytes(data, mimeType)
|
|
||||||
if err != nil {
|
|
||||||
return id.ContentURI{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploaded.ContentURI, nil
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,13 @@ homeserver:
|
|||||||
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
|
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
|
||||||
async_media: false
|
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.
|
# Application service host/registration related details.
|
||||||
# Changing these values requires regeneration of the registration.
|
# Changing these values requires regeneration of the registration.
|
||||||
appservice:
|
appservice:
|
||||||
@@ -77,11 +84,14 @@ bridge:
|
|||||||
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
|
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
|
||||||
# Available variables:
|
# Available variables:
|
||||||
# .ID - Internal user ID
|
# .ID - Internal user ID
|
||||||
# .Username - User's displayname on Discord
|
# .Username - Legacy display/username on Discord
|
||||||
|
# .GlobalName - New displayname on Discord
|
||||||
# .Discriminator - The 4 numbers after the name on Discord
|
# .Discriminator - The 4 numbers after the name on Discord
|
||||||
# .Bot - Whether the user is a bot
|
# .Bot - Whether the user is a bot
|
||||||
# .System - Whether the user is an official system user
|
# .System - Whether the user is an official system user
|
||||||
displayname_template: '{{.Username}}#{{.Discriminator}}{{if .Bot}} (bot){{end}}'
|
# .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).
|
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
|
||||||
# Available variables:
|
# Available variables:
|
||||||
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
|
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
|
||||||
@@ -94,9 +104,18 @@ bridge:
|
|||||||
# Available variables:
|
# Available variables:
|
||||||
# .Name - Guild name
|
# .Name - Guild name
|
||||||
guild_name_template: '{{.Name}}'
|
guild_name_template: '{{.Name}}'
|
||||||
# Should the bridge explicitly set the avatar and room name for DM portal rooms?
|
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
||||||
# This is implicitly enabled in encrypted rooms.
|
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
||||||
private_chat_portal_meta: false
|
# 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
|
portal_message_buffer: 128
|
||||||
|
|
||||||
@@ -111,7 +130,7 @@ bridge:
|
|||||||
message_error_notices: true
|
message_error_notices: true
|
||||||
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
|
# 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.
|
# This can avoid unnecessary invite events in guild rooms when members are synced in.
|
||||||
restricted_rooms: true
|
restricted_rooms: false
|
||||||
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
|
# 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).
|
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
|
||||||
autojoin_thread_on_open: true
|
autojoin_thread_on_open: true
|
||||||
@@ -140,6 +159,55 @@ bridge:
|
|||||||
# Whether or not created rooms should have federation enabled.
|
# Whether or not created rooms should have federation enabled.
|
||||||
# If false, created portal rooms will never be federated.
|
# If false, created portal rooms will never be federated.
|
||||||
federate_rooms: true
|
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
|
# Servers to always allow double puppeting from
|
||||||
double_puppet_server_map:
|
double_puppet_server_map:
|
||||||
example.com: https://example.com
|
example.com: https://example.com
|
||||||
@@ -167,6 +235,30 @@ bridge:
|
|||||||
# Optional extra text sent when joining a management room.
|
# Optional extra text sent when joining a management room.
|
||||||
additional_help: ""
|
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.
|
# End-to-bridge encryption support options.
|
||||||
#
|
#
|
||||||
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
||||||
@@ -177,12 +269,41 @@ bridge:
|
|||||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||||
default: false
|
default: false
|
||||||
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
|
# 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
|
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 encryption, drop any unencrypted messages.
|
||||||
require: false
|
require: false
|
||||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
# 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.
|
# You must use a client that supports requesting keys from other users to use this feature.
|
||||||
allow_key_sharing: false
|
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?
|
# What level of device verification should be required from users?
|
||||||
#
|
#
|
||||||
# Valid levels:
|
# Valid levels:
|
||||||
@@ -218,6 +339,10 @@ bridge:
|
|||||||
# default.
|
# default.
|
||||||
messages: 100
|
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
|
# Settings for provisioning API
|
||||||
provisioning:
|
provisioning:
|
||||||
# Prefix for the provisioning API paths.
|
# Prefix for the provisioning API paths.
|
||||||
@@ -225,6 +350,8 @@ bridge:
|
|||||||
# Shared secret for authentication. If set to "generate", a random secret will be generated,
|
# 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.
|
# or if set to "disable", the provisioning API will be disabled.
|
||||||
shared_secret: generate
|
shared_secret: generate
|
||||||
|
# Enable debug API at /debug with provisioning authentication.
|
||||||
|
debug_endpoints: false
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
# Permissions for using the bridge.
|
||||||
# Permitted values:
|
# Permitted values:
|
||||||
@@ -240,12 +367,15 @@ bridge:
|
|||||||
"example.com": user
|
"example.com": user
|
||||||
"@admin:example.com": admin
|
"@admin:example.com": admin
|
||||||
|
|
||||||
|
# Logging config. See https://github.com/tulir/zeroconfig for details.
|
||||||
logging:
|
logging:
|
||||||
directory: ./logs
|
min_level: debug
|
||||||
file_name_format: '{{.Date}}-{{.Index}}.log'
|
writers:
|
||||||
file_date_format: "2006-01-02"
|
- type: stdout
|
||||||
file_mode: 384
|
format: pretty-colored
|
||||||
timestamp_format: Jan _2, 2006 15:04:05
|
- type: file
|
||||||
print_level: debug
|
format: json
|
||||||
print_json: false
|
filename: ./logs/mautrix-discord.log
|
||||||
file_json: false
|
max_size: 100
|
||||||
|
max_backups: 10
|
||||||
|
compress: true
|
||||||
|
|||||||
152
formatter.go
152
formatter.go
@@ -1,5 +1,5 @@
|
|||||||
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
// Copyright (C) 2023 Tulir Asokan
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -21,57 +21,107 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"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/event"
|
||||||
"maunium.net/go/mautrix/format"
|
"maunium.net/go/mautrix/format"
|
||||||
"maunium.net/go/mautrix/format/mdext"
|
"maunium.net/go/mautrix/format/mdext"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/variationselector"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var discordExtensions = goldmark.WithExtensions(mdext.SimpleSpoiler, mdext.DiscordUnderline, &DiscordEveryone{})
|
// 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(`\\(__[^_]|\*\*[^*])`)
|
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
|
||||||
|
|
||||||
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string) string {
|
func escapeReplacement(s string) string {
|
||||||
text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string {
|
return s[:2] + `\` + s[2:]
|
||||||
return s[:2] + `\` + s[2:]
|
}
|
||||||
})
|
|
||||||
|
|
||||||
mdRenderer := goldmark.New(
|
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
|
||||||
goldmark.WithParser(mdext.ParserWithoutFeatures(
|
// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
|
||||||
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
|
type indentableParagraphParser struct {
|
||||||
)),
|
parser.BlockParser
|
||||||
format.Extensions, format.HTMLOptions, discordExtensions,
|
}
|
||||||
goldmark.WithExtensions(&DiscordTag{portal}),
|
|
||||||
)
|
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
|
var buf strings.Builder
|
||||||
err := mdRenderer.Convert([]byte(text), &buf)
|
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 {
|
if err != nil {
|
||||||
panic(fmt.Errorf("markdown parser errored: %w", err))
|
panic(fmt.Errorf("markdown parser errored: %w", err))
|
||||||
}
|
}
|
||||||
return format.UnwrapSingleParagraph(buf.String())
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatterContextUserKey = "fi.mau.discord.user"
|
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
|
||||||
const formatterContextPortalKey = "fi.mau.discord.portal"
|
return format.UnwrapSingleParagraph(portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(text, allowInlineLinks))
|
||||||
|
}
|
||||||
|
|
||||||
func pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
|
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 {
|
if len(mxid) == 0 {
|
||||||
return displayname
|
return displayname
|
||||||
}
|
}
|
||||||
user := ctx.ReturnData[formatterContextUserKey].(*User)
|
|
||||||
if mxid[0] == '#' {
|
if mxid[0] == '#' {
|
||||||
alias, err := user.bridge.Bot.ResolveAlias(id.RoomAlias(mxid))
|
alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return displayname
|
return displayname
|
||||||
}
|
}
|
||||||
mxid = alias.RoomID.String()
|
mxid = alias.RoomID.String()
|
||||||
}
|
}
|
||||||
if mxid[0] == '!' {
|
if mxid[0] == '!' {
|
||||||
portal := user.bridge.GetPortalByMXID(id.RoomID(mxid))
|
portal := br.GetPortalByMXID(id.RoomID(mxid))
|
||||||
if portal != nil {
|
if portal != nil {
|
||||||
if eventID == "" {
|
if eventID == "" {
|
||||||
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
|
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
|
||||||
@@ -82,7 +132,7 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
|
|||||||
//} else {
|
//} else {
|
||||||
// // TODO is mentioning private channels possible at all?
|
// // TODO is mentioning private channels possible at all?
|
||||||
//}
|
//}
|
||||||
} else if msg := user.bridge.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
|
} else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
|
||||||
guildID := portal.GuildID
|
guildID := portal.GuildID
|
||||||
if guildID == "" {
|
if guildID == "" {
|
||||||
guildID = "@me"
|
guildID = "@me"
|
||||||
@@ -91,23 +141,33 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if mxid[0] == '@' {
|
} else if mxid[0] == '@' {
|
||||||
parsedID, ok := user.bridge.ParsePuppetMXID(id.UserID(mxid))
|
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 {
|
if ok {
|
||||||
|
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
|
||||||
return fmt.Sprintf("<@%s>", parsedID)
|
return fmt.Sprintf("<@%s>", parsedID)
|
||||||
}
|
}
|
||||||
mentionedUser := user.bridge.GetUserByMXID(id.UserID(mxid))
|
mentionedUser := br.GetUserByMXID(id.UserID(mxid))
|
||||||
if mentionedUser != nil && mentionedUser.DiscordID != "" {
|
if mentionedUser != nil && mentionedUser.DiscordID != "" {
|
||||||
|
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
|
||||||
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
|
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return displayname
|
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,
|
// Discord links start with http:// or https://, contain at least two characters afterwards,
|
||||||
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
|
// 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
|
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
|
||||||
var discordLinkRegex = regexp.MustCompile(`https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`)
|
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
|
||||||
|
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
|
||||||
|
|
||||||
var discordMarkdownEscaper = strings.NewReplacer(
|
var discordMarkdownEscaper = strings.NewReplacer(
|
||||||
`\`, `\\`,
|
`\`, `\\`,
|
||||||
@@ -117,6 +177,7 @@ var discordMarkdownEscaper = strings.NewReplacer(
|
|||||||
"`", "\\`",
|
"`", "\\`",
|
||||||
`|`, `\|`,
|
`|`, `\|`,
|
||||||
`<`, `\<`,
|
`<`, `\<`,
|
||||||
|
`#`, `\#`,
|
||||||
)
|
)
|
||||||
|
|
||||||
func escapeDiscordMarkdown(s string) string {
|
func escapeDiscordMarkdown(s string) string {
|
||||||
@@ -160,19 +221,40 @@ var matrixHTMLParser = &format.HTMLParser{
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("||%s||", 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 init() {
|
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
|
||||||
matrixHTMLParser.PillConverter = pillConverter
|
allowedMentions := &discordgo.MessageAllowedMentions{
|
||||||
}
|
Parse: []discordgo.AllowedMentionType{},
|
||||||
|
Users: []string{},
|
||||||
func (portal *Portal) parseMatrixHTML(user *User, content *event.MessageEventContent) string {
|
RepliedUser: true,
|
||||||
|
}
|
||||||
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
|
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
|
||||||
ctx := format.NewContext()
|
ctx := format.NewContext()
|
||||||
ctx.ReturnData[formatterContextUserKey] = user
|
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
|
||||||
ctx.ReturnData[formatterContextPortalKey] = portal
|
ctx.ReturnData[formatterContextPortalKey] = portal
|
||||||
return variationselector.Remove(matrixHTMLParser.Parse(content.FormattedBody, ctx))
|
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
|
||||||
|
if content.Mentions != nil {
|
||||||
|
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
|
||||||
|
}
|
||||||
|
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
|
||||||
} else {
|
} else {
|
||||||
return variationselector.Remove(escapeDiscordMarkdown(content.Body))
|
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, so
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordEveryone struct{}
|
type discordEveryone struct{}
|
||||||
|
|
||||||
func (e *DiscordEveryone) Extend(m goldmark.Markdown) {
|
var ExtDiscordEveryone = &discordEveryone{}
|
||||||
|
|
||||||
|
func (e *discordEveryone) Extend(m goldmark.Markdown) {
|
||||||
m.Parser().AddOptions(parser.WithInlineParsers(
|
m.Parser().AddOptions(parser.WithInlineParsers(
|
||||||
util.Prioritized(defaultDiscordEveryoneParser, 600),
|
util.Prioritized(defaultDiscordEveryoneParser, 600),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -30,14 +30,15 @@ import (
|
|||||||
"github.com/yuin/goldmark/renderer"
|
"github.com/yuin/goldmark/renderer"
|
||||||
"github.com/yuin/goldmark/text"
|
"github.com/yuin/goldmark/text"
|
||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/database"
|
"go.mau.fi/mautrix-discord/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
type astDiscordTag struct {
|
type astDiscordTag struct {
|
||||||
ast.BaseInline
|
ast.BaseInline
|
||||||
id int64
|
portal *Portal
|
||||||
|
id int64
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ ast.Node = (*astDiscordTag)(nil)
|
var _ ast.Node = (*astDiscordTag)(nil)
|
||||||
@@ -143,7 +144,10 @@ func (s *discordTagParser) Trigger() []byte {
|
|||||||
return []byte{'<'}
|
return []byte{'<'}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parserContextPortal = parser.NewContextKey()
|
||||||
|
|
||||||
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||||
|
portal := pc.Get(parserContextPortal).(*Portal)
|
||||||
//before := block.PrecendingCharacter()
|
//before := block.PrecendingCharacter()
|
||||||
line, _ := block.PeekLine()
|
line, _ := block.PeekLine()
|
||||||
match := discordTagRegex.FindSubmatch(line)
|
match := discordTagRegex.FindSubmatch(line)
|
||||||
@@ -157,7 +161,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tag := astDiscordTag{id: id}
|
tag := astDiscordTag{id: id, portal: portal}
|
||||||
tagName := string(match[1])
|
tagName := string(match[1])
|
||||||
switch {
|
switch {
|
||||||
case tagName == "@":
|
case tagName == "@":
|
||||||
@@ -189,7 +193,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
|
|||||||
case strings.HasPrefix(tagName, ":"):
|
case strings.HasPrefix(tagName, ":"):
|
||||||
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
|
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
|
||||||
case strings.HasPrefix(tagName, "a:"):
|
case strings.HasPrefix(tagName, "a:"):
|
||||||
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag}
|
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -199,9 +203,9 @@ func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
|
|||||||
// nothing to do
|
// nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordTagHTMLRenderer struct {
|
type discordTagHTMLRenderer struct{}
|
||||||
portal *Portal
|
|
||||||
}
|
var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
|
||||||
|
|
||||||
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
reg.Register(astKindDiscordTag, r.renderDiscordMention)
|
reg.Register(astKindDiscordTag, r.renderDiscordMention)
|
||||||
@@ -259,32 +263,47 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
|
|||||||
}
|
}
|
||||||
switch node := n.(type) {
|
switch node := n.(type) {
|
||||||
case *astDiscordUserMention:
|
case *astDiscordUserMention:
|
||||||
puppet := r.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10))
|
var mxid id.UserID
|
||||||
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name)
|
var name string
|
||||||
|
if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
|
||||||
|
mxid = puppet.MXID
|
||||||
|
name = puppet.Name
|
||||||
|
}
|
||||||
|
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
|
||||||
|
mxid = user.MXID
|
||||||
|
if name == "" {
|
||||||
|
name = user.MXID.Localpart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), name)
|
||||||
return
|
return
|
||||||
case *astDiscordRoleMention:
|
case *astDiscordRoleMention:
|
||||||
role := r.portal.bridge.DB.Role.GetByID(r.portal.GuildID, strconv.FormatInt(node.id, 10))
|
role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
|
||||||
if role != nil {
|
if role != nil {
|
||||||
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
|
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case *astDiscordChannelMention:
|
case *astDiscordChannelMention:
|
||||||
portal := r.portal.bridge.GetExistingPortalByID(database.PortalKey{
|
portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
|
||||||
ChannelID: strconv.FormatInt(node.id, 10),
|
ChannelID: strconv.FormatInt(node.id, 10),
|
||||||
Receiver: "",
|
Receiver: "",
|
||||||
})
|
})
|
||||||
if portal != nil {
|
if portal != nil {
|
||||||
if portal.MXID != "" {
|
if portal.MXID != "" {
|
||||||
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s?via=%s">%s</a>`, portal.MXID, portal.bridge.AS.HomeserverDomain, portal.Name)
|
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name)
|
||||||
} else {
|
} else {
|
||||||
_, _ = w.WriteString(portal.Name)
|
_, _ = w.WriteString(portal.Name)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case *astDiscordCustomEmoji:
|
case *astDiscordCustomEmoji:
|
||||||
reactionMXC := r.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
|
reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
|
||||||
if !reactionMXC.IsEmpty() {
|
if !reactionMXC.IsEmpty() {
|
||||||
_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
|
attrs := "data-mx-emoticon"
|
||||||
|
if node.animated {
|
||||||
|
attrs += " data-mau-animated-emoji"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `<img %[3]s src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name, attrs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case *astDiscordTimestamp:
|
case *astDiscordTimestamp:
|
||||||
@@ -299,9 +318,9 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
|
|||||||
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
|
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
|
||||||
fullRFC := ts.Format(fullDatetimeFormat)
|
fullRFC := ts.Format(fullDatetimeFormat)
|
||||||
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
|
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
|
||||||
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted)
|
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s" data-discord-style="%c"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, node.style, formatted)
|
||||||
}
|
}
|
||||||
stringifiable, ok := n.(mautrix.Stringifiable)
|
stringifiable, ok := n.(fmt.Stringer)
|
||||||
if ok {
|
if ok {
|
||||||
_, _ = w.WriteString(stringifiable.String())
|
_, _ = w.WriteString(stringifiable.String())
|
||||||
} else {
|
} else {
|
||||||
@@ -310,15 +329,15 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordTag struct {
|
type discordTag struct{}
|
||||||
Portal *Portal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *DiscordTag) Extend(m goldmark.Markdown) {
|
var ExtDiscordTag = &discordTag{}
|
||||||
|
|
||||||
|
func (e *discordTag) Extend(m goldmark.Markdown) {
|
||||||
m.Parser().AddOptions(parser.WithInlineParsers(
|
m.Parser().AddOptions(parser.WithInlineParsers(
|
||||||
util.Prioritized(defaultDiscordTagParser, 600),
|
util.Prioritized(defaultDiscordTagParser, 600),
|
||||||
))
|
))
|
||||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
util.Prioritized(&discordTagHTMLRenderer{e.Portal}, 600),
|
util.Prioritized(defaultDiscordTagHTMLRenderer, 600),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
45
go.mod
45
go.mod
@@ -1,37 +1,46 @@
|
|||||||
module go.mau.fi/mautrix-discord
|
module go.mau.fi/mautrix-discord
|
||||||
|
|
||||||
go 1.18
|
go 1.25.0
|
||||||
|
|
||||||
|
toolchain go1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.26.1
|
github.com/bwmarrin/discordgo v0.27.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.1
|
github.com/gabriel-vasile/mimetype v1.4.9
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/lib/pq v1.10.7
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.16
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/yuin/goldmark v1.5.3
|
github.com/yuin/goldmark v1.7.12
|
||||||
maunium.net/go/maulogger/v2 v2.3.2
|
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb
|
||||||
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.14 // 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
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rs/zerolog v1.28.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/gjson v1.14.4 // indirect
|
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
golang.org/x/crypto v0.5.0 // indirect
|
go.mau.fi/zeroconfig v0.1.2 // indirect
|
||||||
golang.org/x/net v0.5.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/sys v0.4.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
maunium.net/go/mauflag v1.0.0 // indirect
|
maunium.net/go/mauflag v1.0.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399
|
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f
|
||||||
|
|||||||
106
go.sum
106
go.sum
@@ -1,81 +1,79 @@
|
|||||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||||
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399 h1:3GZhhiyeXo/r40NmaQddBpCfosSSIrSrqZBLXJWrtYc=
|
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||||
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f h1:A+SRmETpSnFixbP1x6u7sQdoi8cOuYfL5bkDJy9F/Pg=
|
||||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
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/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 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
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/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
|
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||||
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs=
|
||||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw=
|
||||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
|
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||||
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
|
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||||
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 h1:7KoJL/7eozYlu4GW2jADHO+Qhm8WL45Afcm7A45BivM=
|
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
|
||||||
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM=
|
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
log "maunium.net/go/maulogger/v2"
|
log "maunium.net/go/maulogger/v2"
|
||||||
|
"maunium.net/go/maulogger/v2/maulogadapt"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
@@ -204,6 +205,7 @@ func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
|
|||||||
Preset: "private_chat",
|
Preset: "private_chat",
|
||||||
InitialState: initialState,
|
InitialState: initialState,
|
||||||
CreationContent: creationContent,
|
CreationContent: creationContent,
|
||||||
|
RoomVersion: "11",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
guild.log.Warnln("Failed to create room:", err)
|
guild.log.Warnln("Failed to create room:", err)
|
||||||
@@ -219,13 +221,14 @@ func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
|
|||||||
guild.bridge.guildsLock.Unlock()
|
guild.bridge.guildsLock.Unlock()
|
||||||
guild.log.Infoln("Matrix room created:", guild.MXID)
|
guild.log.Infoln("Matrix room created:", guild.MXID)
|
||||||
|
|
||||||
user.ensureInvited(nil, guild.MXID, false)
|
user.ensureInvited(nil, guild.MXID, false, true)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
|
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
|
||||||
if meta.Unavailable {
|
if meta.Unavailable {
|
||||||
|
guild.log.Debugfln("Ignoring unavailable guild update")
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
changed := false
|
changed := false
|
||||||
@@ -235,6 +238,7 @@ func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.G
|
|||||||
guild.UpdateBridgeInfo()
|
guild.UpdateBridgeInfo()
|
||||||
guild.Update()
|
guild.Update()
|
||||||
}
|
}
|
||||||
|
source.ensureInvited(nil, guild.MXID, false, false)
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +273,15 @@ func (guild *Guild) UpdateAvatar(iconID string) bool {
|
|||||||
guild.Avatar = iconID
|
guild.Avatar = iconID
|
||||||
guild.AvatarURL = id.ContentURI{}
|
guild.AvatarURL = id.ContentURI{}
|
||||||
if guild.Avatar != "" {
|
if guild.Avatar != "" {
|
||||||
var err error
|
// TODO direct media support
|
||||||
guild.AvatarURL, err = uploadAvatar(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID))
|
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 {
|
if err != nil {
|
||||||
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", guild.Avatar, err)
|
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
guild.AvatarURL = copied.MXC
|
||||||
}
|
}
|
||||||
if guild.MXID != "" {
|
if guild.MXID != "" {
|
||||||
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
|
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
|
||||||
@@ -292,14 +299,14 @@ func (guild *Guild) cleanup() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
intent := guild.bridge.Bot
|
intent := guild.bridge.Bot
|
||||||
if guild.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] {
|
if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
|
||||||
err := intent.BeeperDeleteRoom(guild.MXID)
|
err := intent.BeeperDeleteRoom(guild.MXID)
|
||||||
if err == nil || errors.Is(err, mautrix.MNotFound) {
|
if err != nil && !errors.Is(err, mautrix.MNotFound) {
|
||||||
return
|
guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
|
||||||
}
|
}
|
||||||
guild.log.Warnfln("Failed to delete %s using hungryserv yeet endpoint, falling back to normal behavior: %v", guild.MXID, err)
|
return
|
||||||
}
|
}
|
||||||
guild.bridge.cleanupRoom(intent, guild.MXID, false, guild.log)
|
guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (guild *Guild) RemoveMXID() {
|
func (guild *Guild) RemoveMXID() {
|
||||||
@@ -312,6 +319,17 @@ func (guild *Guild) RemoveMXID() {
|
|||||||
guild.MXID = ""
|
guild.MXID = ""
|
||||||
guild.AvatarSet = false
|
guild.AvatarSet = false
|
||||||
guild.NameSet = false
|
guild.NameSet = false
|
||||||
guild.AutoBridgeChannels = false
|
guild.BridgingMode = database.GuildBridgeNothing
|
||||||
guild.Update()
|
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()
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
35
main.go
35
main.go
@@ -18,12 +18,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"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"
|
||||||
"maunium.net/go/mautrix/bridge/commands"
|
"maunium.net/go/mautrix/bridge/commands"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
"maunium.net/go/mautrix/util/configupgrade"
|
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/config"
|
"go.mau.fi/mautrix-discord/config"
|
||||||
"go.mau.fi/mautrix-discord/database"
|
"go.mau.fi/mautrix-discord/database"
|
||||||
@@ -46,6 +50,7 @@ type DiscordBridge struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
DB *database.Database
|
DB *database.Database
|
||||||
|
|
||||||
|
DMA *DirectMediaAPI
|
||||||
provisioning *ProvisioningAPI
|
provisioning *ProvisioningAPI
|
||||||
|
|
||||||
usersByMXID map[id.UserID]*User
|
usersByMXID map[id.UserID]*User
|
||||||
@@ -71,6 +76,9 @@ type DiscordBridge struct {
|
|||||||
puppets map[string]*Puppet
|
puppets map[string]*Puppet
|
||||||
puppetsByCustomMXID map[id.UserID]*Puppet
|
puppetsByCustomMXID map[id.UserID]*Puppet
|
||||||
puppetsLock sync.Mutex
|
puppetsLock sync.Mutex
|
||||||
|
|
||||||
|
attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
|
||||||
|
parallelAttachmentSemaphore *semaphore.Weighted
|
||||||
}
|
}
|
||||||
|
|
||||||
func (br *DiscordBridge) GetExampleConfig() string {
|
func (br *DiscordBridge) GetExampleConfig() string {
|
||||||
@@ -88,15 +96,23 @@ func (br *DiscordBridge) GetConfigPtr() interface{} {
|
|||||||
func (br *DiscordBridge) Init() {
|
func (br *DiscordBridge) Init() {
|
||||||
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
|
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
|
||||||
br.RegisterCommands()
|
br.RegisterCommands()
|
||||||
|
br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
|
||||||
|
|
||||||
|
matrixHTMLParser.PillConverter = br.pillConverter
|
||||||
|
|
||||||
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
|
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
|
||||||
discordLog = br.Log.Sub("Discord")
|
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (br *DiscordBridge) Start() {
|
func (br *DiscordBridge) Start() {
|
||||||
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
|
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
|
||||||
br.provisioning = newProvisioningAPI(br)
|
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()
|
go br.startUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,13 +179,18 @@ func main() {
|
|||||||
|
|
||||||
puppets: make(map[string]*Puppet),
|
puppets: make(map[string]*Puppet),
|
||||||
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
|
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
|
||||||
|
|
||||||
|
attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
|
||||||
|
parallelAttachmentSemaphore: semaphore.NewWeighted(3),
|
||||||
}
|
}
|
||||||
br.Bridge = bridge.Bridge{
|
br.Bridge = bridge.Bridge{
|
||||||
Name: "mautrix-discord",
|
Name: "mautrix-discord",
|
||||||
URL: "https://github.com/mautrix/discord",
|
URL: "https://github.com/mautrix/discord",
|
||||||
Description: "A Matrix-Discord puppeting bridge.",
|
Description: "A Matrix-Discord puppeting bridge.",
|
||||||
Version: "0.1.0",
|
Version: "0.7.6",
|
||||||
ProtocolName: "Discord",
|
ProtocolName: "Discord",
|
||||||
|
BeeperServiceName: "discordgo",
|
||||||
|
BeeperNetworkName: "discord",
|
||||||
|
|
||||||
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
|
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
|
||||||
|
|
||||||
|
|||||||
779
portal_convert.go
Normal file
779
portal_convert.go
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
// 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 (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/appservice"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/format"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-discord/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConvertedMessage struct {
|
||||||
|
AttachmentID string
|
||||||
|
|
||||||
|
Type event.Type
|
||||||
|
Content *event.MessageEventContent
|
||||||
|
Extra map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
|
||||||
|
return &event.MessageEventContent{
|
||||||
|
Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
|
||||||
|
MsgType: event.MsgNotice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DiscordStickerSize = 160
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordFile(ctx context.Context, typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
|
||||||
|
meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
|
||||||
|
if typeName == "sticker" && content.Info.MimeType == "application/json" {
|
||||||
|
meta.Converter = portal.bridge.convertLottie
|
||||||
|
}
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
|
||||||
|
return portal.createMediaFailedMessage(err)
|
||||||
|
}
|
||||||
|
if typeName == "sticker" && content.Info.MimeType == "application/json" {
|
||||||
|
content.Info.MimeType = dbFile.MimeType
|
||||||
|
}
|
||||||
|
content.Info.Size = dbFile.Size
|
||||||
|
if content.Info.Width == 0 && content.Info.Height == 0 {
|
||||||
|
content.Info.Width = dbFile.Width
|
||||||
|
content.Info.Height = dbFile.Height
|
||||||
|
}
|
||||||
|
if dbFile.DecryptionInfo != nil {
|
||||||
|
content.File = &event.EncryptedFileInfo{
|
||||||
|
EncryptedFile: *dbFile.DecryptionInfo,
|
||||||
|
URL: dbFile.MXC.CUString(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.URL = dbFile.MXC.CUString()
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
|
||||||
|
if content.Info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if content.Info.Width == 0 && content.Info.Height == 0 {
|
||||||
|
content.Info.Width = DiscordStickerSize
|
||||||
|
content.Info.Height = DiscordStickerSize
|
||||||
|
} else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
|
||||||
|
if content.Info.Width > content.Info.Height {
|
||||||
|
content.Info.Height /= content.Info.Width / DiscordStickerSize
|
||||||
|
content.Info.Width = DiscordStickerSize
|
||||||
|
} else if content.Info.Width < content.Info.Height {
|
||||||
|
content.Info.Width /= content.Info.Height / DiscordStickerSize
|
||||||
|
content.Info.Height = DiscordStickerSize
|
||||||
|
} else {
|
||||||
|
content.Info.Width = DiscordStickerSize
|
||||||
|
content.Info.Height = DiscordStickerSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.StickerItem) *ConvertedMessage {
|
||||||
|
var mime string
|
||||||
|
switch sticker.FormatType {
|
||||||
|
case discordgo.StickerFormatTypePNG:
|
||||||
|
mime = "image/png"
|
||||||
|
case discordgo.StickerFormatTypeAPNG:
|
||||||
|
mime = "image/apng"
|
||||||
|
case discordgo.StickerFormatTypeLottie:
|
||||||
|
mime = "application/json"
|
||||||
|
case discordgo.StickerFormatTypeGIF:
|
||||||
|
mime = "image/gif"
|
||||||
|
default:
|
||||||
|
zerolog.Ctx(ctx).Warn().
|
||||||
|
Int("sticker_format", int(sticker.FormatType)).
|
||||||
|
Str("sticker_id", sticker.ID).
|
||||||
|
Msg("Unknown sticker format")
|
||||||
|
}
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
Body: sticker.Name, // TODO find description from somewhere?
|
||||||
|
Info: &event.FileInfo{
|
||||||
|
MimeType: mime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mxc := portal.bridge.DMA.StickerMXC(sticker.ID, sticker.FormatType)
|
||||||
|
// TODO add config option to use direct media even for lottie stickers
|
||||||
|
if mxc.IsEmpty() && mime != "application/json" {
|
||||||
|
content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
|
||||||
|
} else {
|
||||||
|
content.URL = mxc.CUString()
|
||||||
|
}
|
||||||
|
portal.cleanupConvertedStickerInfo(content)
|
||||||
|
return &ConvertedMessage{
|
||||||
|
AttachmentID: sticker.ID,
|
||||||
|
Type: event.EventSticker,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, messageID string, att *discordgo.MessageAttachment) *ConvertedMessage {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
Body: att.Filename,
|
||||||
|
Info: &event.FileInfo{
|
||||||
|
Height: att.Height,
|
||||||
|
MimeType: att.ContentType,
|
||||||
|
Width: att.Width,
|
||||||
|
|
||||||
|
// This gets overwritten later after the file is uploaded to the homeserver
|
||||||
|
Size: att.Size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var extra = make(map[string]any)
|
||||||
|
|
||||||
|
if strings.HasPrefix(att.Filename, "SPOILER_") {
|
||||||
|
extra["page.codeberg.everypizza.msc4193.spoiler"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if att.Description != "" {
|
||||||
|
content.Body = att.Description
|
||||||
|
content.FileName = att.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
|
||||||
|
case "audio":
|
||||||
|
content.MsgType = event.MsgAudio
|
||||||
|
if att.Waveform != nil {
|
||||||
|
// TODO convert waveform
|
||||||
|
extra["org.matrix.msc1767.audio"] = map[string]any{
|
||||||
|
"duration": int(att.DurationSeconds * 1000),
|
||||||
|
}
|
||||||
|
extra["org.matrix.msc3245.voice"] = map[string]any{}
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
content.MsgType = event.MsgImage
|
||||||
|
case "video":
|
||||||
|
content.MsgType = event.MsgVideo
|
||||||
|
default:
|
||||||
|
content.MsgType = event.MsgFile
|
||||||
|
}
|
||||||
|
mxc := portal.bridge.DMA.AttachmentMXC(portal.Key.ChannelID, messageID, att)
|
||||||
|
if mxc.IsEmpty() {
|
||||||
|
content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
|
||||||
|
} else {
|
||||||
|
content.URL = mxc.CUString()
|
||||||
|
}
|
||||||
|
return &ConvertedMessage{
|
||||||
|
AttachmentID: att.ID,
|
||||||
|
Type: event.EventMessage,
|
||||||
|
Content: content,
|
||||||
|
Extra: extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
|
||||||
|
attachmentID := fmt.Sprintf("video_%s", embed.URL)
|
||||||
|
var proxyURL string
|
||||||
|
if embed.Video != nil {
|
||||||
|
proxyURL = embed.Video.ProxyURL
|
||||||
|
} else if embed.Thumbnail != nil {
|
||||||
|
proxyURL = embed.Thumbnail.ProxyURL
|
||||||
|
} else {
|
||||||
|
zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
|
||||||
|
return &ConvertedMessage{
|
||||||
|
AttachmentID: attachmentID,
|
||||||
|
Type: event.EventMessage,
|
||||||
|
Content: &event.MessageEventContent{
|
||||||
|
Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
|
||||||
|
MsgType: event.MsgNotice,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
|
||||||
|
return &ConvertedMessage{
|
||||||
|
AttachmentID: attachmentID,
|
||||||
|
Type: event.EventMessage,
|
||||||
|
Content: portal.createMediaFailedMessage(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
Body: embed.URL,
|
||||||
|
Info: &event.FileInfo{
|
||||||
|
MimeType: dbFile.MimeType,
|
||||||
|
Size: dbFile.Size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if embed.Video != nil {
|
||||||
|
content.MsgType = event.MsgVideo
|
||||||
|
content.Info.Width = embed.Video.Width
|
||||||
|
content.Info.Height = embed.Video.Height
|
||||||
|
} else {
|
||||||
|
content.MsgType = event.MsgImage
|
||||||
|
content.Info.Width = embed.Thumbnail.Width
|
||||||
|
content.Info.Height = embed.Thumbnail.Height
|
||||||
|
}
|
||||||
|
if content.Info.Width == 0 && content.Info.Height == 0 {
|
||||||
|
content.Info.Width = dbFile.Width
|
||||||
|
content.Info.Height = dbFile.Height
|
||||||
|
}
|
||||||
|
if dbFile.DecryptionInfo != nil {
|
||||||
|
content.File = &event.EncryptedFileInfo{
|
||||||
|
EncryptedFile: *dbFile.DecryptionInfo,
|
||||||
|
URL: dbFile.MXC.CUString(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.URL = dbFile.MXC.CUString()
|
||||||
|
}
|
||||||
|
extra := map[string]any{}
|
||||||
|
if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
|
||||||
|
extra["info"] = map[string]any{
|
||||||
|
"fi.mau.discord.gifv": true,
|
||||||
|
"fi.mau.gif": true,
|
||||||
|
"fi.mau.loop": true,
|
||||||
|
"fi.mau.autoplay": true,
|
||||||
|
"fi.mau.hide_controls": true,
|
||||||
|
"fi.mau.no_audio": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ConvertedMessage{
|
||||||
|
AttachmentID: attachmentID,
|
||||||
|
Type: event.EventMessage,
|
||||||
|
Content: content,
|
||||||
|
Extra: extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordMessage(ctx context.Context, puppet *Puppet, intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
|
||||||
|
predictedLength := len(msg.Attachments) + len(msg.StickerItems)
|
||||||
|
if msg.Content != "" {
|
||||||
|
predictedLength++
|
||||||
|
}
|
||||||
|
parts := make([]*ConvertedMessage, 0, predictedLength)
|
||||||
|
if textPart := portal.convertDiscordTextMessage(ctx, intent, msg); textPart != nil {
|
||||||
|
parts = append(parts, textPart)
|
||||||
|
}
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
handledIDs := make(map[string]struct{})
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
if _, handled := handledIDs[att.ID]; handled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handledIDs[att.ID] = struct{}{}
|
||||||
|
log := log.With().Str("attachment_id", att.ID).Logger()
|
||||||
|
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sticker := range msg.StickerItems {
|
||||||
|
if _, handled := handledIDs[sticker.ID]; handled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handledIDs[sticker.ID] = struct{}{}
|
||||||
|
log := log.With().Str("sticker_id", sticker.ID).Logger()
|
||||||
|
if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, embed := range msg.Embeds {
|
||||||
|
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
|
||||||
|
if getEmbedType(msg, embed) != EmbedVideo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Discord deduplicates embeds by URL. It makes things easier for us too.
|
||||||
|
if _, handled := handledIDs[embed.URL]; handled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handledIDs[embed.URL] = struct{}{}
|
||||||
|
log := log.With().
|
||||||
|
Str("computed_embed_type", "video").
|
||||||
|
Str("embed_type", string(embed.Type)).
|
||||||
|
Int("embed_index", i).
|
||||||
|
Logger()
|
||||||
|
part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
|
||||||
|
if part != nil {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 && msg.Thread != nil {
|
||||||
|
parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
puppet.addWebhookMeta(part, msg)
|
||||||
|
puppet.addMemberMeta(part, msg)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
|
||||||
|
if msg.Member == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.Extra == nil {
|
||||||
|
part.Extra = make(map[string]any)
|
||||||
|
}
|
||||||
|
var avatarURL id.ContentURI
|
||||||
|
var discordAvatarURL string
|
||||||
|
if msg.Member.Avatar != "" {
|
||||||
|
var err error
|
||||||
|
avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Member.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
puppet.log.Warn().Err(err).
|
||||||
|
Str("avatar_id", msg.Member.Avatar).
|
||||||
|
Msg("Failed to reupload guild user avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
|
||||||
|
"nick": msg.Member.Nick,
|
||||||
|
"avatar_id": msg.Member.Avatar,
|
||||||
|
"avatar_url": discordAvatarURL,
|
||||||
|
"avatar_mxc": avatarURL.String(),
|
||||||
|
}
|
||||||
|
if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
|
||||||
|
perMessageProfile := map[string]any{
|
||||||
|
"id": fmt.Sprintf("%s_%s", msg.GuildID, msg.Author.ID),
|
||||||
|
"displayname": msg.Member.Nick,
|
||||||
|
"avatar_url": avatarURL.String(),
|
||||||
|
}
|
||||||
|
if msg.Member.Nick == "" {
|
||||||
|
perMessageProfile["displayname"] = puppet.Name
|
||||||
|
}
|
||||||
|
if avatarURL.IsEmpty() {
|
||||||
|
perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
|
||||||
|
}
|
||||||
|
part.Extra["com.beeper.per_message_profile"] = perMessageProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
|
||||||
|
if msg.WebhookID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.Extra == nil {
|
||||||
|
part.Extra = make(map[string]any)
|
||||||
|
}
|
||||||
|
var avatarURL id.ContentURI
|
||||||
|
if msg.Author.Avatar != "" {
|
||||||
|
var err error
|
||||||
|
avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
puppet.log.Warn().Err(err).
|
||||||
|
Str("avatar_id", msg.Author.Avatar).
|
||||||
|
Msg("Failed to reupload webhook avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
|
||||||
|
"id": msg.WebhookID,
|
||||||
|
"name": msg.Author.Username,
|
||||||
|
"avatar_id": msg.Author.Avatar,
|
||||||
|
"avatar_url": msg.Author.AvatarURL(""),
|
||||||
|
"avatar_mxc": avatarURL.String(),
|
||||||
|
}
|
||||||
|
profileID := sha256.Sum256(fmt.Appendf(nil, "%s:%s", msg.Author.Username, msg.Author.Avatar))
|
||||||
|
hasFallback := false
|
||||||
|
if msg.ApplicationID == "" &&
|
||||||
|
puppet.bridge.Config.Bridge.PrefixWebhookMessages &&
|
||||||
|
(part.Content.MsgType == event.MsgText || part.Content.MsgType == event.MsgNotice || (part.Content.FileName != "" && part.Content.FileName != part.Content.Body)) {
|
||||||
|
part.Content.EnsureHasHTML()
|
||||||
|
part.Content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, part.Content.Body)
|
||||||
|
part.Content.FormattedBody = fmt.Sprintf("<strong data-mx-profile-fallback>%s: </strong>%s", html.EscapeString(msg.Author.Username), part.Content.FormattedBody)
|
||||||
|
hasFallback = true
|
||||||
|
}
|
||||||
|
part.Extra["com.beeper.per_message_profile"] = map[string]any{
|
||||||
|
"id": hex.EncodeToString(profileID[:]),
|
||||||
|
"avatar_url": avatarURL.String(),
|
||||||
|
"displayname": msg.Author.Username,
|
||||||
|
"has_fallback": hasFallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
|
||||||
|
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
|
||||||
|
embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt=""> <span>%s</span></p>`
|
||||||
|
embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
|
||||||
|
embedHTMLAuthorLink = `<a href="%s">%s</a>`
|
||||||
|
embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
|
||||||
|
embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
|
||||||
|
embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
|
||||||
|
embedHTMLFieldName = `<th>%s</th>`
|
||||||
|
embedHTMLFieldValue = `<td>%s</td>`
|
||||||
|
embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
|
||||||
|
embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
|
||||||
|
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
|
||||||
|
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt=""> <span>%s</span>%s</sub></p>`
|
||||||
|
embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
|
||||||
|
embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
|
||||||
|
embedHTMLDate = `<time datetime="%s">%s</time>`
|
||||||
|
embedFooterDateSeparator = ` • `
|
||||||
|
)
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordRichEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
var htmlParts []string
|
||||||
|
if embed.Author != nil {
|
||||||
|
var authorHTML string
|
||||||
|
authorNameHTML := html.EscapeString(embed.Author.Name)
|
||||||
|
if embed.Author.URL != "" {
|
||||||
|
authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
|
||||||
|
}
|
||||||
|
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
|
||||||
|
if embed.Author.ProxyIconURL != "" {
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
|
||||||
|
} else {
|
||||||
|
authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
htmlParts = append(htmlParts, authorHTML)
|
||||||
|
}
|
||||||
|
if embed.Title != "" {
|
||||||
|
var titleHTML string
|
||||||
|
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
|
||||||
|
if embed.URL != "" {
|
||||||
|
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
|
||||||
|
} else {
|
||||||
|
titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
|
||||||
|
}
|
||||||
|
htmlParts = append(htmlParts, titleHTML)
|
||||||
|
}
|
||||||
|
if embed.Description != "" {
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
|
||||||
|
}
|
||||||
|
for i := 0; i < len(embed.Fields); i++ {
|
||||||
|
item := embed.Fields[i]
|
||||||
|
if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
|
||||||
|
splitItems := []*discordgo.MessageEmbedField{item}
|
||||||
|
if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
|
||||||
|
splitItems = append(splitItems, embed.Fields[i+1])
|
||||||
|
i++
|
||||||
|
if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
|
||||||
|
splitItems = append(splitItems, embed.Fields[i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headerParts := make([]string, len(splitItems))
|
||||||
|
contentParts := make([]string, len(splitItems))
|
||||||
|
for j, splitItem := range splitItems {
|
||||||
|
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
|
||||||
|
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
|
||||||
|
}
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
|
||||||
|
} else {
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
|
||||||
|
strconv.FormatBool(item.Inline),
|
||||||
|
portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
|
||||||
|
portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if embed.Image != nil {
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to reupload image in embed")
|
||||||
|
} else {
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var embedDateHTML string
|
||||||
|
if embed.Timestamp != "" {
|
||||||
|
formattedTime := embed.Timestamp
|
||||||
|
parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
|
||||||
|
} else {
|
||||||
|
formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
|
||||||
|
}
|
||||||
|
embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
|
||||||
|
}
|
||||||
|
if embed.Footer != nil {
|
||||||
|
var footerHTML string
|
||||||
|
var datePart string
|
||||||
|
if embedDateHTML != "" {
|
||||||
|
datePart = embedFooterDateSeparator + embedDateHTML
|
||||||
|
}
|
||||||
|
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
|
||||||
|
if embed.Footer.ProxyIconURL != "" {
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
|
||||||
|
} else {
|
||||||
|
footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
htmlParts = append(htmlParts, footerHTML)
|
||||||
|
} else if embed.Timestamp != "" {
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(htmlParts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
compiledHTML := strings.Join(htmlParts, "")
|
||||||
|
if embed.Color != 0 {
|
||||||
|
compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
|
||||||
|
} else {
|
||||||
|
compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
|
||||||
|
}
|
||||||
|
return compiledHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
type BeeperLinkPreview struct {
|
||||||
|
mautrix.RespPreviewURL
|
||||||
|
MatchedURL string `json:"matched_url"`
|
||||||
|
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordLinkEmbedImage(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
|
||||||
|
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if width != 0 || height != 0 {
|
||||||
|
preview.ImageWidth = width
|
||||||
|
preview.ImageHeight = height
|
||||||
|
} else {
|
||||||
|
preview.ImageWidth = dbFile.Width
|
||||||
|
preview.ImageHeight = dbFile.Height
|
||||||
|
}
|
||||||
|
preview.ImageSize = dbFile.Size
|
||||||
|
preview.ImageType = dbFile.MimeType
|
||||||
|
if dbFile.Encrypted {
|
||||||
|
preview.ImageEncryption = &event.EncryptedFileInfo{
|
||||||
|
EncryptedFile: *dbFile.DecryptionInfo,
|
||||||
|
URL: dbFile.MXC.CUString(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preview.ImageURL = dbFile.MXC.CUString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordLinkEmbedToBeeper(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
|
||||||
|
var preview BeeperLinkPreview
|
||||||
|
preview.MatchedURL = embed.URL
|
||||||
|
preview.Title = embed.Title
|
||||||
|
preview.Description = embed.Description
|
||||||
|
if embed.Image != nil {
|
||||||
|
portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
|
||||||
|
} else if embed.Thumbnail != nil {
|
||||||
|
portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
|
||||||
|
}
|
||||||
|
return &preview
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgInteractionTemplateHTML = `<blockquote>
|
||||||
|
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
|
||||||
|
</blockquote>`
|
||||||
|
|
||||||
|
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
|
||||||
|
|
||||||
|
type BridgeEmbedType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EmbedUnknown BridgeEmbedType = iota
|
||||||
|
EmbedRich
|
||||||
|
EmbedLinkPreview
|
||||||
|
EmbedVideo
|
||||||
|
)
|
||||||
|
|
||||||
|
func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
|
||||||
|
// Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
|
||||||
|
// so this is a hacky way to detect those.
|
||||||
|
return embed.Video != nil && embed.Video.ProxyURL == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
|
||||||
|
switch embed.Type {
|
||||||
|
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
|
||||||
|
return EmbedLinkPreview
|
||||||
|
case discordgo.EmbedTypeVideo:
|
||||||
|
if isActuallyLinkPreview(embed) {
|
||||||
|
return EmbedLinkPreview
|
||||||
|
}
|
||||||
|
return EmbedVideo
|
||||||
|
case discordgo.EmbedTypeGifv:
|
||||||
|
return EmbedVideo
|
||||||
|
case discordgo.EmbedTypeImage:
|
||||||
|
if msg != nil && isPlainGifMessage(msg) {
|
||||||
|
return EmbedVideo
|
||||||
|
} else if embed.Image == nil && embed.Thumbnail != nil {
|
||||||
|
return EmbedLinkPreview
|
||||||
|
}
|
||||||
|
return EmbedRich
|
||||||
|
case discordgo.EmbedTypeRich:
|
||||||
|
return EmbedRich
|
||||||
|
default:
|
||||||
|
return EmbedUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlainGifMessage(msg *discordgo.Message) bool {
|
||||||
|
if len(msg.Embeds) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
embed := msg.Embeds[0]
|
||||||
|
isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
|
||||||
|
isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == ""
|
||||||
|
contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
|
||||||
|
return contentIsOnlyURL && (isGifVideo || isGifImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
|
||||||
|
var matrixMentions event.Mentions
|
||||||
|
for _, mention := range msg.Mentions {
|
||||||
|
puppet := portal.bridge.GetPuppetByID(mention.ID)
|
||||||
|
if syncGhosts {
|
||||||
|
puppet.UpdateInfo(nil, mention, nil)
|
||||||
|
}
|
||||||
|
user := portal.bridge.GetUserByID(mention.ID)
|
||||||
|
if user != nil {
|
||||||
|
matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
|
||||||
|
} else {
|
||||||
|
matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(matrixMentions.UserIDs)
|
||||||
|
matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
|
||||||
|
if msg.MentionEveryone {
|
||||||
|
matrixMentions.Room = true
|
||||||
|
}
|
||||||
|
return &matrixMentions
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardTemplateHTML = `<blockquote>
|
||||||
|
<p>↷ Forwarded</p>
|
||||||
|
%s
|
||||||
|
<p>%s</p>
|
||||||
|
</blockquote>`
|
||||||
|
|
||||||
|
func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
if msg.Type == discordgo.MessageTypeCall {
|
||||||
|
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgEmote,
|
||||||
|
Body: "started a call",
|
||||||
|
}}
|
||||||
|
} else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
|
||||||
|
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgEmote,
|
||||||
|
Body: "joined the server",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
var htmlParts []string
|
||||||
|
if msg.Interaction != nil {
|
||||||
|
puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
|
||||||
|
puppet.UpdateInfo(nil, msg.Interaction.User, nil)
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
|
||||||
|
}
|
||||||
|
if msg.Content != "" && !isPlainGifMessage(msg) {
|
||||||
|
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, true))
|
||||||
|
} else if msg.MessageReference != nil &&
|
||||||
|
msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
|
||||||
|
len(msg.MessageSnapshots) > 0 &&
|
||||||
|
msg.MessageSnapshots[0].Message != nil {
|
||||||
|
forwardedHTML := portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true)
|
||||||
|
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
|
||||||
|
origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
|
||||||
|
forwardedFromPortal := portal.bridge.GetExistingPortalByID(database.NewPortalKey(msg.MessageReference.ChannelID, ""))
|
||||||
|
if forwardedFromPortal != nil {
|
||||||
|
origMessage := portal.bridge.DB.Message.GetFirstByDiscordID(forwardedFromPortal.Key, msg.MessageReference.MessageID)
|
||||||
|
if origMessage != nil {
|
||||||
|
origLink = fmt.Sprintf(
|
||||||
|
`<a href="%s">#%s • %s</a>`,
|
||||||
|
forwardedFromPortal.MXID.EventURI(origMessage.MXID, portal.bridge.AS.HomeserverDomain),
|
||||||
|
forwardedFromPortal.PlainName,
|
||||||
|
msgTSText,
|
||||||
|
)
|
||||||
|
} else if forwardedFromPortal.MXID != "" {
|
||||||
|
origLink = fmt.Sprintf(
|
||||||
|
`<a href="%s">#%s</a> • %s`,
|
||||||
|
forwardedFromPortal.MXID.URI(portal.bridge.AS.HomeserverDomain),
|
||||||
|
forwardedFromPortal.PlainName,
|
||||||
|
msgTSText,
|
||||||
|
)
|
||||||
|
} else if forwardedFromPortal.PlainName != "" {
|
||||||
|
origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.PlainName, msgTSText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlParts = append(htmlParts, fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink))
|
||||||
|
}
|
||||||
|
previews := make([]*BeeperLinkPreview, 0)
|
||||||
|
for i, embed := range msg.Embeds {
|
||||||
|
if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
with := log.With().
|
||||||
|
Str("embed_type", string(embed.Type)).
|
||||||
|
Int("embed_index", i)
|
||||||
|
switch getEmbedType(msg, embed) {
|
||||||
|
case EmbedRich:
|
||||||
|
log := with.Str("computed_embed_type", "rich").Logger()
|
||||||
|
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
|
||||||
|
case EmbedLinkPreview:
|
||||||
|
log := with.Str("computed_embed_type", "link preview").Logger()
|
||||||
|
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
|
||||||
|
case EmbedVideo:
|
||||||
|
// Ignore video embeds, they're handled as separate messages
|
||||||
|
default:
|
||||||
|
log := with.Logger()
|
||||||
|
log.Warn().Msg("Unknown embed type in message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Components) > 0 {
|
||||||
|
htmlParts = append(htmlParts, msgComponentTemplateHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(htmlParts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fullHTML := strings.Join(htmlParts, "\n")
|
||||||
|
if !msg.MentionEveryone {
|
||||||
|
fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := format.HTMLToContent(fullHTML)
|
||||||
|
extraContent := map[string]any{
|
||||||
|
"com.beeper.linkpreviews": previews,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-discord/database"
|
||||||
"go.mau.fi/mautrix-discord/remoteauth"
|
"go.mau.fi/mautrix-discord/remoteauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +72,13 @@ func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
|
|||||||
r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost)
|
r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost)
|
||||||
r.HandleFunc("/v1/guilds/{guildID}", p.guildsUnbridge).Methods(http.MethodDelete)
|
r.HandleFunc("/v1/guilds/{guildID}", p.guildsUnbridge).Methods(http.MethodDelete)
|
||||||
|
|
||||||
|
if p.bridge.Config.Bridge.Provisioning.DebugEndpoints {
|
||||||
|
p.log.Debugln("Enabling debug API at /debug")
|
||||||
|
r := p.bridge.AS.Router.PathPrefix("/debug").Subrouter()
|
||||||
|
r.Use(p.authMiddleware)
|
||||||
|
r.PathPrefix("/pprof").Handler(http.DefaultServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +235,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
msg = "User wasn't logged in."
|
msg = "User wasn't logged in."
|
||||||
}
|
}
|
||||||
user.Logout()
|
user.Logout(false)
|
||||||
jsonResponse(w, http.StatusOK, Response{true, msg})
|
jsonResponse(w, http.StatusOK, Response{true, msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +336,6 @@ func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
|
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
|
||||||
user.DiscordID = discordUser.UserID
|
|
||||||
user.Update()
|
|
||||||
|
|
||||||
if err = user.Login(discordUser.Token); err != nil {
|
if err = user.Login(discordUser.Token); err != nil {
|
||||||
log.Errorln("Failed to connect after logging in:", err)
|
log.Errorln("Failed to connect after logging in:", err)
|
||||||
@@ -429,11 +436,12 @@ func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type guildEntry struct {
|
type guildEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL id.ContentURI `json:"avatar_url"`
|
AvatarURL id.ContentURI `json:"avatar_url"`
|
||||||
MXID id.RoomID `json:"mxid"`
|
MXID id.RoomID `json:"mxid"`
|
||||||
AutoBridge bool `json:"auto_bridge_channels"`
|
AutoBridge bool `json:"auto_bridge_channels"`
|
||||||
|
BridgingMode string `json:"bridging_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type respGuildsList struct {
|
type respGuildsList struct {
|
||||||
@@ -451,11 +459,12 @@ func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resp.Guilds = append(resp.Guilds, guildEntry{
|
resp.Guilds = append(resp.Guilds, guildEntry{
|
||||||
ID: guild.ID,
|
ID: guild.ID,
|
||||||
Name: guild.PlainName,
|
Name: guild.PlainName,
|
||||||
AvatarURL: guild.AvatarURL,
|
AvatarURL: guild.AvatarURL,
|
||||||
MXID: guild.MXID,
|
MXID: guild.MXID,
|
||||||
AutoBridge: guild.AutoBridgeChannels,
|
AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
|
||||||
|
BridgingMode: guild.BridgingMode.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +535,7 @@ func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request)
|
|||||||
Error: "Guild not found",
|
Error: "Guild not found",
|
||||||
ErrCode: mautrix.MNotFound.ErrCode,
|
ErrCode: mautrix.MNotFound.ErrCode,
|
||||||
})
|
})
|
||||||
} else if !guild.AutoBridgeChannels && guild.MXID == "" {
|
} else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
jsonResponse(w, http.StatusNotFound, Error{
|
||||||
Error: "That guild is not bridged",
|
Error: "That guild is not bridged",
|
||||||
ErrCode: ErrCodeGuildNotBridged,
|
ErrCode: ErrCodeGuildNotBridged,
|
||||||
|
|||||||
142
puppet.go
142
puppet.go
@@ -3,12 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
log "maunium.net/go/maulogger/v2"
|
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/appservice"
|
"maunium.net/go/mautrix/appservice"
|
||||||
"maunium.net/go/mautrix/bridge"
|
"maunium.net/go/mautrix/bridge"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -20,7 +21,7 @@ type Puppet struct {
|
|||||||
*database.Puppet
|
*database.Puppet
|
||||||
|
|
||||||
bridge *DiscordBridge
|
bridge *DiscordBridge
|
||||||
log log.Logger
|
log zerolog.Logger
|
||||||
|
|
||||||
MXID id.UserID
|
MXID id.UserID
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
|
|||||||
return &Puppet{
|
return &Puppet{
|
||||||
Puppet: dbPuppet,
|
Puppet: dbPuppet,
|
||||||
bridge: br,
|
bridge: br,
|
||||||
log: br.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
|
log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(),
|
||||||
|
|
||||||
MXID: br.FormatPuppetMXID(dbPuppet.ID),
|
MXID: br.FormatPuppetMXID(dbPuppet.ID),
|
||||||
}
|
}
|
||||||
@@ -194,7 +195,7 @@ func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
|
func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
|
||||||
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info)
|
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication)
|
||||||
if puppet.Name == newName && puppet.NameSet {
|
if puppet.Name == newName && puppet.NameSet {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -202,10 +203,10 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
|
|||||||
puppet.NameSet = false
|
puppet.NameSet = false
|
||||||
err := puppet.DefaultIntent().SetDisplayName(newName)
|
err := puppet.DefaultIntent().SetDisplayName(newName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
puppet.log.Warnln("Failed to update displayname:", err)
|
puppet.log.Warn().Err(err).Msg("Failed to update displayname")
|
||||||
} else {
|
} else {
|
||||||
go puppet.updatePortalMeta(func(portal *Portal) {
|
go puppet.updatePortalMeta(func(portal *Portal) {
|
||||||
if portal.UpdateNameDirect(puppet.Name) {
|
if portal.UpdateNameDirect(puppet.Name, false) {
|
||||||
portal.Update()
|
portal.Update()
|
||||||
portal.UpdateBridgeInfo()
|
portal.UpdateBridgeInfo()
|
||||||
}
|
}
|
||||||
@@ -215,20 +216,51 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
|
||||||
|
var downloadURL string
|
||||||
|
if guildID == "" {
|
||||||
|
if strings.HasPrefix(avatarID, "a_") {
|
||||||
|
downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
|
||||||
|
} else {
|
||||||
|
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if strings.HasPrefix(avatarID, "a_") {
|
||||||
|
downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
|
||||||
|
} else {
|
||||||
|
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url := br.DMA.AvatarMXC(guildID, userID, avatarID)
|
||||||
|
if !url.IsEmpty() {
|
||||||
|
return url, downloadURL, nil
|
||||||
|
}
|
||||||
|
copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
|
||||||
|
AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return id.ContentURI{}, downloadURL, err
|
||||||
|
}
|
||||||
|
return copied.MXC, downloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
|
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
|
||||||
if puppet.Avatar == info.Avatar && puppet.AvatarSet {
|
avatarID := info.Avatar
|
||||||
|
if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars {
|
||||||
|
avatarID = ""
|
||||||
|
}
|
||||||
|
if puppet.Avatar == avatarID && puppet.AvatarSet {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
avatarChanged := info.Avatar != puppet.Avatar
|
avatarChanged := avatarID != puppet.Avatar
|
||||||
puppet.Avatar = info.Avatar
|
puppet.Avatar = avatarID
|
||||||
puppet.AvatarSet = false
|
puppet.AvatarSet = false
|
||||||
puppet.AvatarURL = id.ContentURI{}
|
puppet.AvatarURL = id.ContentURI{}
|
||||||
|
|
||||||
// TODO should we just use discord's default avatars for users with no avatar?
|
|
||||||
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
|
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
|
||||||
url, err := uploadAvatar(puppet.DefaultIntent(), info.AvatarURL(""))
|
url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
puppet.log.Warnfln("Failed to reupload user avatar %s: %v", puppet.Avatar, err)
|
puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
puppet.AvatarURL = url
|
puppet.AvatarURL = url
|
||||||
@@ -236,7 +268,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
|
|||||||
|
|
||||||
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
|
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
puppet.log.Warnln("Failed to update avatar:", err)
|
puppet.log.Warn().Err(err).Msg("Failed to update avatar")
|
||||||
} else {
|
} else {
|
||||||
go puppet.updatePortalMeta(func(portal *Portal) {
|
go puppet.updatePortalMeta(func(portal *Portal) {
|
||||||
if portal.UpdateAvatarFromPuppet(puppet) {
|
if portal.UpdateAvatarFromPuppet(puppet) {
|
||||||
@@ -249,7 +281,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
|
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) {
|
||||||
puppet.syncLock.Lock()
|
puppet.syncLock.Lock()
|
||||||
defer puppet.syncLock.Unlock()
|
defer puppet.syncLock.Unlock()
|
||||||
|
|
||||||
@@ -258,23 +290,97 @@ func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
puppet.log.Debugfln("Fetching info through %s to update", source.DiscordID)
|
puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet")
|
||||||
info, err = source.Session.User(puppet.ID)
|
info, err = source.Session.User(puppet.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
puppet.log.Errorfln("Failed to fetch info through %s: %v", source.DiscordID, err)
|
puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := puppet.DefaultIntent().EnsureRegistered()
|
err := puppet.DefaultIntent().EnsureRegistered()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
puppet.log.Errorln("Failed to ensure registered:", err)
|
puppet.log.Error().Err(err).Msg("Failed to ensure registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
changed := false
|
changed := false
|
||||||
|
if message != nil {
|
||||||
|
if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook {
|
||||||
|
puppet.log.Debug().
|
||||||
|
Str("message_id", message.ID).
|
||||||
|
Str("webhook_id", message.WebhookID).
|
||||||
|
Msg("Found webhook ID in message, marking ghost as a webhook")
|
||||||
|
puppet.IsWebhook = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if message.ApplicationID != "" && !puppet.IsApplication {
|
||||||
|
puppet.log.Debug().
|
||||||
|
Str("message_id", message.ID).
|
||||||
|
Str("application_id", message.ApplicationID).
|
||||||
|
Msg("Found application ID in message, marking ghost as an application")
|
||||||
|
puppet.IsApplication = true
|
||||||
|
puppet.IsWebhook = false
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = puppet.UpdateContactInfo(info) || changed
|
||||||
changed = puppet.UpdateName(info) || changed
|
changed = puppet.UpdateName(info) || changed
|
||||||
changed = puppet.UpdateAvatar(info) || changed
|
changed = puppet.UpdateAvatar(info) || changed
|
||||||
if changed {
|
if changed {
|
||||||
puppet.Update()
|
puppet.Update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool {
|
||||||
|
changed := false
|
||||||
|
if puppet.Username != info.Username {
|
||||||
|
puppet.Username = info.Username
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if puppet.GlobalName != info.GlobalName {
|
||||||
|
puppet.GlobalName = info.GlobalName
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if puppet.Discriminator != info.Discriminator {
|
||||||
|
puppet.Discriminator = info.Discriminator
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if puppet.IsBot != info.Bot {
|
||||||
|
puppet.IsBot = info.Bot
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet {
|
||||||
|
puppet.ContactInfoSet = false
|
||||||
|
puppet.ResendContactInfo()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (puppet *Puppet) ResendContactInfo() {
|
||||||
|
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discordUsername := puppet.Username
|
||||||
|
if puppet.Discriminator != "0" {
|
||||||
|
discordUsername += "#" + puppet.Discriminator
|
||||||
|
}
|
||||||
|
contactInfo := map[string]any{
|
||||||
|
"com.beeper.bridge.identifiers": []string{
|
||||||
|
fmt.Sprintf("discord:%s", discordUsername),
|
||||||
|
},
|
||||||
|
"com.beeper.bridge.remote_id": puppet.ID,
|
||||||
|
"com.beeper.bridge.service": puppet.bridge.BeeperServiceName,
|
||||||
|
"com.beeper.bridge.network": puppet.bridge.BeeperNetworkName,
|
||||||
|
"com.beeper.bridge.is_network_bot": puppet.IsBot,
|
||||||
|
}
|
||||||
|
if puppet.IsWebhook {
|
||||||
|
contactInfo["com.beeper.bridge.identifiers"] = []string{}
|
||||||
|
}
|
||||||
|
err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
|
||||||
|
if err != nil {
|
||||||
|
puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
|
||||||
|
} else {
|
||||||
|
puppet.ContactInfoSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
77
thread.go
77
thread.go
@@ -1,10 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/database"
|
"go.mau.fi/mautrix-discord/database"
|
||||||
@@ -14,7 +17,8 @@ type Thread struct {
|
|||||||
*database.Thread
|
*database.Thread
|
||||||
Parent *Portal
|
Parent *Portal
|
||||||
|
|
||||||
creationNoticeLock sync.Mutex
|
creationNoticeLock sync.Mutex
|
||||||
|
initialBackfillAttempted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread {
|
func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread {
|
||||||
@@ -74,19 +78,84 @@ func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *
|
|||||||
return thread
|
return thread
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (br *DiscordBridge) threadFound(ctx context.Context, source *User, rootMessage *database.Message, id string, metadata *discordgo.Channel) {
|
||||||
|
thread := br.GetThreadByID(id, rootMessage)
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
log.Debug().Msg("Marked message as thread root")
|
||||||
|
if thread.CreationNoticeMXID == "" {
|
||||||
|
thread.Parent.sendThreadCreationNotice(ctx, thread)
|
||||||
|
}
|
||||||
|
// TODO member_ids_preview is probably not guaranteed to contain the source user
|
||||||
|
if source != nil && metadata != nil && slices.Contains(metadata.MemberIDsPreview, source.DiscordID) && !source.IsInPortal(thread.ID) {
|
||||||
|
source.MarkInPortal(database.UserPortal{
|
||||||
|
DiscordID: thread.ID,
|
||||||
|
Type: database.UserPortalTypeThread,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
if metadata.MessageCount > 0 {
|
||||||
|
go thread.maybeInitialBackfill(source)
|
||||||
|
} else {
|
||||||
|
thread.initialBackfillAttempted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (thread *Thread) maybeInitialBackfill(source *User) {
|
||||||
|
if thread.initialBackfillAttempted || thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thread.Parent.forwardBackfillLock.Lock()
|
||||||
|
if thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) != nil {
|
||||||
|
thread.Parent.forwardBackfillLock.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thread.Parent.forwardBackfillInitial(source, thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (thread *Thread) RefererOpt() discordgo.RequestOption {
|
||||||
|
return discordgo.WithThreadReferer(thread.Parent.GuildID, thread.ParentID, thread.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (thread *Thread) Join(user *User) {
|
func (thread *Thread) Join(user *User) {
|
||||||
if user.IsInPortal(thread.ID) {
|
if user.IsInPortal(thread.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.log.Debugfln("Joining thread %s@%s", thread.ID, thread.ParentID)
|
log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
|
||||||
err := user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu)
|
log.Debug().Msg("Joining thread")
|
||||||
|
|
||||||
|
var doBackfill, backfillStarted bool
|
||||||
|
if !thread.initialBackfillAttempted && thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread > 0 {
|
||||||
|
thread.Parent.forwardBackfillLock.Lock()
|
||||||
|
lastMessage := thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID)
|
||||||
|
if lastMessage != nil {
|
||||||
|
thread.Parent.forwardBackfillLock.Unlock()
|
||||||
|
} else {
|
||||||
|
doBackfill = true
|
||||||
|
defer func() {
|
||||||
|
if !backfillStarted {
|
||||||
|
thread.Parent.forwardBackfillLock.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if user.Session.IsUser {
|
||||||
|
err = user.Session.ThreadJoin(thread.ID, discordgo.WithLocationParam(discordgo.ThreadJoinLocationContextMenu), thread.RefererOpt())
|
||||||
|
} else {
|
||||||
|
err = user.Session.ThreadJoin(thread.ID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user.log.Errorfln("Error joining thread %s@%s: %v", thread.ID, thread.ParentID, err)
|
log.Error().Err(err).Msg("Error joining thread")
|
||||||
} else {
|
} else {
|
||||||
user.MarkInPortal(database.UserPortal{
|
user.MarkInPortal(database.UserPortal{
|
||||||
DiscordID: thread.ID,
|
DiscordID: thread.ID,
|
||||||
Type: database.UserPortalTypeThread,
|
Type: database.UserPortalTypeThread,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
})
|
})
|
||||||
|
if doBackfill {
|
||||||
|
go thread.Parent.forwardBackfillInitial(user, thread)
|
||||||
|
backfillStarted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user