79 Commits

Author SHA1 Message Date
Tulir Asokan
19e26674e6 Bump version to v0.7.6 2026-02-16 15:49:46 +02:00
Tulir Asokan
daf6b9420c Add support for federation thumbnail endpoint 2026-02-15 21:48:10 +02:00
Tulir Asokan
fab784bfd8 Add new fields to uploads 2026-02-15 14:51:14 +02:00
Tulir Asokan
17c1938b4c Bump minimum Go version to 1.25 2026-02-15 14:48:14 +02:00
Tulir Asokan
ca9f032234 docker: fix working directory and update to Alpine 3.23 2026-02-12 16:29:35 +02:00
Tulir Asokan
5c4527f1b2 Disable restricted rooms by default 2026-01-21 19:09:42 +02:00
Skip R.
11b1ea5aa6 bump discordgo (#206) 2025-11-25 21:16:51 +02:00
Skip R.
d7292a0706 bump discordgo and add support for heartbeat sessions (#203) 2025-11-19 14:33:26 -08:00
Skip R.
9eaf213091 user: send errUserNotLoggedIn if we can't bridge event from logged-out user (#204) 2025-11-19 11:10:08 -08:00
Skip R.
c8c00a42bb Bump discordgo (#200) 2025-10-30 08:06:21 -07:00
Skip R.
2182c0d38f Only send CONNECTED bridge state on READY or RESUMED (#199) 2025-10-28 08:39:43 -07:00
Tulir Asokan
d92d7c4314 Install lottieconverter from Alpine repos 2025-08-19 17:44:20 +03:00
Tulir Asokan
5c22ed85a7 Bump minimum Go version to 1.24 2025-08-17 00:02:45 +03:00
Tulir Asokan
98e5e9de4a Revert "Allow v12 rooms to be created"
This reverts commit d2988096e4.

The bridge can handle v12 rooms fine, but creation requires additional considerations

Fixes #193
2025-08-17 00:02:28 +03:00
Tulir Asokan
820951cb6e Add support for disabling link previews via MSC4095 2025-08-10 23:47:39 +03:00
Tulir Asokan
52ebc21d9b Update mautrix-go 2025-08-10 23:28:01 +03:00
Tulir Asokan
16469259f7 Update issue templates 2025-07-18 17:30:19 +03:00
Tulir Asokan
d2988096e4 Allow v12 rooms to be created 2025-07-18 17:30:19 +03:00
Tulir Asokan
3f7622be19 Add support for following tombstones 2025-07-18 17:30:19 +03:00
Tulir Asokan
40a6992151 Bump version to v0.7.5 2025-07-16 11:45:58 +03:00
Tulir Asokan
111824486b Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:43:53 +03:00
Tulir Asokan
d4e7289315 Update prefix_webhook_messages option to use MSC4144 fallbacks 2025-07-01 00:59:17 +03:00
Tulir Asokan
e2151defc6 Update mautrix-go to fix federation key response
[skip cd]
2025-06-29 19:15:05 +03:00
Tulir Asokan
30c2cd94a7 Bump version to v0.7.4 2025-06-16 18:53:05 +03:00
Tulir Asokan
847d4cb98e Update Docker image to Alpine 3.22
Closes #183
2025-06-08 00:58:00 +03:00
Tulir Asokan
9fd89cdfc5 Add support for forwarded messages
Fixes #170
Closes #182
2025-06-08 00:49:16 +03:00
LeaPhant
dc4538aab6 Add support for MSC4193 media spoilers (#189) 2025-06-08 00:27:29 +03:00
Tulir Asokan
a6fca7ce43 Add channel is bridgeable check to channel update handler 2025-06-08 00:24:19 +03:00
Tulir Asokan
d69e4e9881 Update mautrix-go to rename cross-room reply field 2025-06-08 00:15:45 +03:00
Tulir Asokan
ccc6c77911 Update mautrix-go to enable MSC4190. Closes #181 2025-05-03 22:13:06 +03:00
Tulir Asokan
001c88c400 Bump version to v0.7.3 2025-04-16 14:09:51 +03:00
Ping Chen
d37b5028e1 portal: fix isPlainGifMessage to get link preview working (#179) 2025-04-09 20:37:25 +09:00
Tulir Asokan
ef093b129f dependencies: update discordgo 2025-03-20 17:43:03 +02:00
batuhan
84e56c73fa portal: add com.beeper.room_type.v2 to m.bridge events (#178) 2025-03-20 16:23:00 +02:00
ginnyTheCat
5854ad0c14 portal: fix typo in msc1767 field name (#177) 2025-03-20 16:22:37 +02:00
Tulir Asokan
9605992758 portal: add id field for per-message profiles
Closes #176
2025-03-20 16:21:38 +02:00
Tulir Asokan
4d67dbcd00 client: only load main page for users 2025-02-22 20:09:40 +02:00
Tulir Asokan
31a75d871f login: add filename when sending QR image 2025-02-22 20:09:36 +02:00
Tulir Asokan
b8892ed59f dependencies: update 2025-02-22 20:04:47 +02:00
Tulir Asokan
65ef2c4ff6 portal: add support for no-mention replies 2025-02-22 19:48:16 +02:00
mat
4a8e9f5c21 portal: fix per-message profiles for guild-specific avatars (#172) 2025-02-09 02:34:44 +02:00
Tulir Asokan
4aad353603 Bump version to v0.7.2 2024-12-16 16:07:46 +02:00
Tulir Asokan
0e59e2da68 dependencies: update 2024-12-16 16:06:33 +02:00
Tulir Asokan
5a029367b3 dependencies: update 2024-11-29 20:22:31 +02:00
Tulir Asokan
f2897d9b14 client: load version number dynamically 2024-11-29 20:15:04 +02:00
Tulir Asokan
8b61dc5352 config: add support for using a proxy 2024-11-29 20:15:00 +02:00
Tulir Asokan
b330c5836e client: set referers properly 2024-11-29 20:14:52 +02:00
Tulir Asokan
8219516ede dependencies: update discordgo 2024-11-22 00:29:29 +02:00
Tulir Asokan
c01f502e04 Bump version to v0.7.1 2024-11-16 18:06:31 +02:00
Tulir Asokan
1e3b854ee1 dependencies: update golang.org/x deps and bump minimum go version 2024-11-15 13:11:13 +02:00
Tulir Asokan
a9df85fdca portal: add missing fi.mau.gif field to gifvs 2024-11-14 22:57:09 +02:00
Tulir Asokan
0d148ffad6 ci: lock closed issues automatically after 90 days 2024-11-13 15:17:33 +02:00
Tulir Asokan
024577d822 user: catch 40002 responses 2024-11-13 15:15:36 +02:00
Tulir Asokan
449c9264d8 dependencies: update discordgo 2024-11-13 14:58:00 +02:00
Tulir Asokan
a0ee1fd508 .github: update bug report template 2024-11-13 14:12:52 +02:00
Tulir Asokan
ce1f401ddc Bump version to v0.7.0 2024-07-16 11:28:58 +03:00
Tulir Asokan
2f5b3fcbfb Don't use mxid in mention pills 2024-07-15 19:26:09 +03:00
Tulir Asokan
035f2a408b Add support for authenticated media 2024-07-12 20:09:07 +03:00
Tulir Asokan
a126a36249 Add create-portal command 2024-06-24 21:43:11 +03:00
Tulir Asokan
1fef7a0ee2 Create category space if necessary when creating channel room 2024-06-24 21:36:38 +03:00
Tulir Asokan
2da2aa47e9 Always use guild room for join rule 2024-06-24 21:28:45 +03:00
Tulir Asokan
a6d9e62b49 Add support for MSC3916 endpoints for direct media 2024-05-31 21:45:50 +03:00
Tulir Asokan
8d01c30014 Fix finding client to fetch messages through 2024-02-18 23:41:25 +02:00
Tulir Asokan
2a7a2c3895 Parse expiry from URL 2024-02-18 23:41:25 +02:00
Tulir Asokan
23ae2d314f Update changelog 2024-02-18 23:26:03 +02:00
Tulir Asokan
737e4c89e0 Update minimum Go version 2024-02-18 23:12:35 +02:00
Tulir Asokan
9402d0d291 Fix mass inserting messages 2024-02-18 23:11:31 +02:00
Tulir Asokan
d0e3d2966a Redo direct media access with URL refreshing (#135) 2024-02-18 23:10:19 +02:00
Tulir Asokan
a5813a9d78 Bump version to v0.6.5 2024-01-16 16:19:53 +02:00
Tulir Asokan
5de499a3b5 Reuse existing getEvent function 2024-01-06 11:11:39 +02:00
Tulir Asokan
3f5484c73e Add support for encrypted events in webhook replies
Obviously won't help if the encryption hardening options are enabled,
because the point of those is to prevent the bridge from decrypting old
messages.

Fixes #131
2024-01-06 11:09:31 +02:00
Tulir Asokan
8035a2d3a1 Update actions and run on both supported Go versions
[skip cd]
2023-12-28 17:26:36 +01:00
Sumner Evans
f69c02acb6 pre-commit: ban Msgf() from zerolog
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-12-24 11:26:41 -07:00
Tulir Asokan
8c8cfa8f6b Update dependencies 2023-12-24 20:25:05 +02:00
Toni Spets
643d4c6e39 Expose debug API with pprof
Runs along the provisioning API with same authentication.
2023-12-05 12:11:57 +02:00
Tulir Asokan
c013873d1c Bump version to v0.6.4 2023-11-16 15:29:23 +02:00
Tulir Asokan
394c0a05d3 Update dependencies 2023-11-16 15:27:12 +02:00
Tulir Asokan
2138b6115f Update .gitignore 2023-11-08 17:45:43 +02:00
Tulir Asokan
5b8473b3de Send error messages in thread if applicable 2023-11-08 17:45:17 +02:00
37 changed files with 1843 additions and 377 deletions

View File

@@ -1,7 +1,16 @@
---
name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs.
labels: bug
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
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.
-->

View File

@@ -1,6 +1,6 @@
---
name: Enhancement request
about: Submit a feature request or other suggestion
labels: enhancement
type: Feature
---

View File

@@ -5,13 +5,20 @@ on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: "1.20"
go-version: ${{ matrix.go-version }}
cache: true
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3

29
.github/workflows/stale.yml vendored Normal file
View 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

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
*.db*
*.log*
/mautrix-discord
/start

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@@ -13,3 +13,8 @@ repos:
hooks:
- id: go-imports-repo
- id: go-vet-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.3.1
hooks:
- id: zerolog-ban-msgf

View File

@@ -1,3 +1,84 @@
# 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

View File

@@ -1,6 +1,4 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.18 AS builder
FROM golang:1-alpine3.23 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -8,19 +6,17 @@ COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
FROM alpine:3.18
FROM alpine:3.23
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View File

@@ -1,19 +1,15 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM alpine:3.18
FROM alpine:3.23
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \
zlib libpng giflib libstdc++ libgcc
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
ARG EXECUTABLE=./mautrix-discord
COPY $EXECUTABLE /usr/bin/mautrix-discord
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY ./docker-run.sh /docker-run.sh
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View File

@@ -1,18 +0,0 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.18 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
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

View File

@@ -29,7 +29,7 @@ import (
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -38,7 +38,7 @@ func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
req.Header.Set(key, value)
}
resp, err := http.DefaultClient.Do(req)
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
@@ -65,16 +65,21 @@ func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
}
}
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))
if err != nil {
return err
}
for key, value := range discordgo.DroidFetchHeaders {
for key, value := range discordgo.DroidBaseHeaders {
req.Header.Set(key, value)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Referer", "https://discord.com/")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := http.DefaultClient.Do(req)
resp, err := cli.Do(req)
if err != nil {
return err
}
@@ -295,7 +300,7 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
}()
var data []byte
data, onceErr = downloadDiscordAttachment(url, br.MediaConfig.UploadSize)
data, onceErr = downloadDiscordAttachment(http.DefaultClient, url, br.MediaConfig.UploadSize)
if onceErr != nil {
return
}
@@ -323,19 +328,17 @@ func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, ur
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
var url, mimeType, ext string
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"
ext = "gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
ext = "png"
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Emoji(emojiID, ext)
if !mxc.IsEmpty() {
return mxc
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,

View File

@@ -117,7 +117,7 @@ func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User,
}
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
if err != nil {
return nil, false, err
}
@@ -151,6 +151,9 @@ func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User,
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
}
@@ -180,7 +183,7 @@ func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User,
}
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "")
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

View File

@@ -62,6 +62,7 @@ func (br *DiscordBridge) RegisterCommands() {
cmdBridge,
cmdUnbridge,
cmdDeletePortal,
cmdCreatePortal,
cmdSetRelay,
cmdUnsetRelay,
cmdGuilds,
@@ -238,9 +239,10 @@ func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
}
content := event.MessageEventContent{
MsgType: event.MsgImage,
Body: code,
URL: url.CUString(),
MsgType: event.MsgImage,
Body: code,
FileName: "qr.png",
URL: url.CUString(),
}
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
@@ -467,7 +469,7 @@ func fnSetRelay(ce *WrappedCommandEvent) {
return
}
case "create":
perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID)
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")
@@ -482,7 +484,7 @@ func fnSetRelay(ce *WrappedCommandEvent) {
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, "")
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)
@@ -757,7 +759,7 @@ func fnBridge(ce *WrappedCommandEvent) {
portal.updateRoomName()
portal.updateRoomAvatar()
portal.updateRoomTopic()
portal.updateSpace()
portal.updateSpace(ce.User)
portal.UpdateBridgeInfo()
state, err := portal.MainIntent().State(portal.MXID)
if err != nil {
@@ -785,6 +787,45 @@ var cmdUnbridge = &commands.FullHandler{
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",

View File

@@ -61,7 +61,7 @@ func (portal *Portal) getCommand(user *User, command string) (*discordgo.Applica
defer portal.commandsLock.Unlock()
cmd, ok := portal.commands[command]
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 {
return nil, err
}
@@ -247,7 +247,7 @@ func fnCommands(ce *WrappedCommandEvent) {
}
subcmd := strings.ToLower(ce.Args[0])
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 {
ce.Reply("Error searching for commands: %v", err)
return
@@ -297,7 +297,7 @@ func fnExec(ce *WrappedCommandEvent) {
ce.User.pendingInteractionsLock.Lock()
ce.User.pendingInteractions[nonce] = ce
ce.User.pendingInteractionsLock.Unlock()
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce)
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce, ce.Portal.RefererOpt(""))
if err != nil {
ce.Reply("Error sending interaction: %v", err)
ce.User.pendingInteractionsLock.Lock()

View File

@@ -25,7 +25,6 @@ import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type BridgeConfig struct {
@@ -38,6 +37,9 @@ type BridgeConfig struct {
PortalMessageBuffer int `yaml:"portal_message_buffer"`
PublicAddress string `yaml:"public_address"`
AvatarProxyKey string `yaml:"avatar_proxy_key"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
@@ -55,8 +57,10 @@ type BridgeConfig struct {
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
CacheMedia string `yaml:"cache_media"`
MediaPatterns MediaPatterns `yaml:"media_patterns"`
Proxy string `yaml:"proxy"`
CacheMedia string `yaml:"cache_media"`
DirectMedia DirectMedia `yaml:"direct_media"`
AnimatedSticker struct {
Target string `yaml:"target"`
@@ -83,8 +87,9 @@ type BridgeConfig struct {
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
@@ -95,111 +100,12 @@ type BridgeConfig struct {
guildNameTemplate *template.Template `yaml:"-"`
}
type MediaPatterns struct {
Enabled bool `yaml:"enabled"`
TplAttachments string `yaml:"attachments"`
TplEmojis string `yaml:"emojis"`
TplStickers string `yaml:"stickers"`
TplAvatars string `yaml:"avatars"`
attachments *template.Template `yaml:"-"`
emojis *template.Template `yaml:"-"`
stickers *template.Template `yaml:"-"`
avatars *template.Template `yaml:"-"`
}
type umMediaPatterns MediaPatterns
func (mp *MediaPatterns) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umMediaPatterns)(mp))
if err != nil {
return err
}
tpl := template.New("media_patterns")
pairs := []struct {
ptr **template.Template
name string
template string
}{
{&mp.attachments, "attachments", mp.TplAttachments},
{&mp.emojis, "emojis", mp.TplEmojis},
{&mp.stickers, "stickers", mp.TplStickers},
{&mp.avatars, "avatars", mp.TplAvatars},
}
for _, pair := range pairs {
if pair.template == "" {
continue
}
*pair.ptr, err = tpl.New(pair.name).Parse(pair.template)
if err != nil {
return err
}
}
return nil
}
type attachmentParams struct {
ChannelID string
AttachmentID string
FileName string
}
type emojiStickerParams struct {
ID string
Ext string
}
type avatarParams struct {
UserID string
AvatarID string
Ext string
}
func (mp *MediaPatterns) execute(tpl *template.Template, params any) id.ContentURI {
if tpl == nil || !mp.Enabled {
return id.ContentURI{}
}
var out strings.Builder
err := tpl.Execute(&out, params)
if err != nil {
panic(err)
}
uri, err := id.ParseContentURI(out.String())
if err != nil {
panic(err)
}
return uri
}
func (mp *MediaPatterns) Attachment(channelID, attachmentID, filename string) id.ContentURI {
return mp.execute(mp.attachments, attachmentParams{
ChannelID: channelID,
AttachmentID: attachmentID,
FileName: filename,
})
}
func (mp *MediaPatterns) Emoji(emojiID, ext string) id.ContentURI {
return mp.execute(mp.emojis, emojiStickerParams{
ID: emojiID,
Ext: ext,
})
}
func (mp *MediaPatterns) Sticker(stickerID, ext string) id.ContentURI {
return mp.execute(mp.stickers, emojiStickerParams{
ID: stickerID,
Ext: ext,
})
}
func (mp *MediaPatterns) Avatar(userID, avatarID, ext string) id.ContentURI {
return mp.execute(mp.avatars, avatarParams{
UserID: userID,
AvatarID: avatarID,
Ext: ext,
})
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 {

View File

@@ -20,13 +20,12 @@ import (
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/federation"
)
func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
helper.Copy(up.Str|up.Null, "homeserver", "public_address")
helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channel_name_template")
@@ -41,6 +40,12 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
}
helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit")
helper.Copy(up.Str|up.Null, "bridge", "public_address")
if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" {
helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key")
} else {
helper.Copy(up.Str, "bridge", "avatar_proxy_key")
}
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "delivery_receipts")
helper.Copy(up.Bool, "bridge", "message_status_events")
@@ -58,12 +63,18 @@ func DoUpgrade(helper *up.Helper) {
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.Bool, "bridge", "media_patterns", "enabled")
helper.Copy(up.Str|up.Null, "bridge", "proxy")
helper.Copy(up.Str, "bridge", "cache_media")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "attachments")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "emojis")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "stickers")
helper.Copy(up.Str|up.Null, "bridge", "media_patterns", "avatars")
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")
@@ -88,6 +99,7 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "msc4190")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
@@ -113,6 +125,7 @@ func DoUpgrade(helper *up.Helper) {
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
helper.Copy(up.Map, "bridge", "permissions")
//helper.Copy(up.Bool, "bridge", "relay", "enabled")

20
database/json.go Normal file
View 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
}

View File

@@ -107,7 +107,7 @@ func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
params[0] = key.ChannelID
params[1] = key.Receiver
for i, msg := range msgs {
baseIndex := 2 + i*7
baseIndex := 2 + i*8
params[baseIndex] = msg.DiscordID
params[baseIndex+1] = msg.AttachmentID
params[baseIndex+2] = msg.SenderID

View File

@@ -1,4 +1,4 @@
-- v0 -> v23 (compatible with v19+): Latest revision
-- v0 -> v24 (compatible with v19+): Latest revision
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
@@ -92,7 +92,8 @@ CREATE TABLE "user" (
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 (

View File

@@ -0,0 +1,2 @@
-- v24 (compatible with v19+): Add persisted heartbeat sessions
ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb;

View File

@@ -3,6 +3,7 @@ package database
import (
"database/sql"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
@@ -21,18 +22,18 @@ func (uq *UserQuery) New() *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))
}
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))
}
func (uq *UserQuery) GetAllWithToken() []*User {
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
`
rows, err := uq.db.Query(query)
@@ -54,19 +55,20 @@ type User struct {
db *Database
log log.Logger
MXID id.UserID
DiscordID string
DiscordToken string
ManagementRoom id.RoomID
SpaceRoom id.RoomID
DMSpaceRoom id.RoomID
MXID id.UserID
DiscordID string
DiscordToken string
ManagementRoom id.RoomID
SpaceRoom id.RoomID
DMSpaceRoom id.RoomID
HeartbeatSession *discordgo.HeartbeatSession
ReadStateVersion int
}
func (u *User) Scan(row dbutil.Scannable) *User {
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion)
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession})
if err != nil {
if err != sql.ErrNoRows {
u.log.Errorln("Database scan failed:", err)
@@ -83,8 +85,8 @@ func (u *User) Scan(row dbutil.Scannable) *User {
}
func (u *User) Insert() {
query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion)
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, JSONPtr(u.HeartbeatSession))
if err != nil {
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
panic(err)
@@ -92,8 +94,8 @@ func (u *User) Insert() {
}
func (u *User) Update() {
query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7`
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID)
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, JSONPtr(u.HeartbeatSession), u.MXID)
if err != nil {
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
panic(err)

View File

@@ -7,6 +7,7 @@ import (
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
const (
@@ -44,6 +45,24 @@ func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
return ups
}
func (db *Database) GetUsersInPortal(channelID string) []id.UserID {
rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID)
if err != nil {
db.Portal.log.Errorln("Failed to get users in portal:", err)
}
var users []id.UserID
for rows.Next() {
var mxid id.UserID
err = rows.Scan(&mxid)
if err != nil {
db.Portal.log.Errorln("Failed to scan user in portal:", err)
} else {
users = append(users, mxid)
}
}
return users
}
func (u *User) GetPortals() []UserPortal {
rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
if err != nil {

663
directmedia.go Normal file
View 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
View 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,
}
}

View File

@@ -2,9 +2,6 @@
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com
# Publicly accessible base URL for media, used for avatars in relay mode.
# If not set, the connection address above will be used.
public_address: null
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com
@@ -94,7 +91,7 @@ bridge:
# .System - Whether the user is an official system user
# .Webhook - Whether the user is a webhook and is not an application
# .Application - Whether the user is an application
displayname_template: '{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}'
displayname_template: '{{if .Webhook}}Webhook{{else}}{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}{{end}}'
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
# Available variables:
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
@@ -113,6 +110,13 @@ bridge:
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode.
# If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address.
# This should not have a trailing slash, the endpoint above will be appended to the provided address.
public_address: null
# A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature.
avatar_proxy_key: generate
portal_message_buffer: 128
# Number of private channel portals to create on bridge startup.
@@ -126,7 +130,7 @@ bridge:
message_error_notices: true
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
# This can avoid unnecessary invite events in guild rooms when members are synced in.
restricted_rooms: true
restricted_rooms: false
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
autojoin_thread_on_open: true
@@ -157,31 +161,39 @@ bridge:
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).
prefix_webhook_messages: false
#
# 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: true
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
# Patterns for converting Discord media to custom mxc:// URIs instead of reuploading.
# Each of the patterns can be set to null to disable custom URIs for that type of media.
# 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
media_patterns:
direct_media:
# Should custom mxc:// URIs be used instead of reuploading media?
enabled: false
# Pattern for normal message attachments.
attachments: mxc://discord-media.mau.dev/attachments|{{.ChannelID}}|{{.AttachmentID}}|{{.FileName}}
# Pattern for custom emojis.
emojis: mxc://discord-media.mau.dev/emojis|{{.ID}}.{{.Ext}}
# Pattern for stickers. Note that animated lottie stickers will not be converted if this is enabled.
stickers: mxc://discord-media.mau.dev/stickers|{{.ID}}.{{.Ext}}
# Pattern for static user avatars.
avatars: mxc://discord-media.mau.dev/avatars|{{.UserID}}|{{.AvatarID}}.{{.Ext}}
# 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.
@@ -257,7 +269,13 @@ bridge:
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
# Changing this option requires updating the appservice registration file.
appservice: false
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
# Changing this option requires updating the appservice registration file.
msc4190: false
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
@@ -332,6 +350,8 @@ bridge:
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Enable debug API at /debug with provisioning authentication.
debug_endpoints: false
# Permissions for using the bridge.
# Permitted values:

View File

@@ -74,7 +74,7 @@ var discordRendererWithInlineLinks = goldmark.New(
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
func (portal *Portal) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder
@@ -88,12 +88,17 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err))
}
return format.UnwrapSingleParagraph(buf.String())
return buf.String()
}
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
return format.UnwrapSingleParagraph(portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(text, allowInlineLinks))
}
const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews"
func appendIfNotContains(arr []string, newItem string) []string {
for _, item := range arr {
@@ -217,16 +222,24 @@ var matrixHTMLParser = &format.HTMLParser{
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)
}
return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
},
}
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
@@ -234,6 +247,7 @@ func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (strin
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext()
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil {

View File

@@ -30,6 +30,7 @@ import (
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
@@ -262,11 +263,19 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
}
switch node := n.(type) {
case *astDiscordUserMention:
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, user.MXID.URI().MatrixToURL(), user.MXID)
} else if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, puppet.MXID.URI().MatrixToURL(), puppet.Name)
var mxid id.UserID
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
case *astDiscordRoleMention:
role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))

35
go.mod
View File

@@ -1,43 +1,46 @@
module go.mau.fi/mautrix-discord
go 1.20
go 1.25.0
toolchain go1.26.0
require (
github.com/bwmarrin/discordgo v0.27.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gabriel-vasile/mimetype v1.4.9
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.17
github.com/rs/zerolog v1.31.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/rs/zerolog v1.34.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.5.6
go.mau.fi/util v0.2.0
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
golang.org/x/sync v0.4.0
github.com/stretchr/testify v1.10.0
github.com/yuin/goldmark v1.7.12
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc
golang.org/x/sync v0.16.0
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.16.1
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2
)
require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.6.0 // 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/tidwall/gjson v1.16.0 // indirect
github.com/tidwall/gjson v1.18.0 // 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
go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20231013182643-f333f2578a3c
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f

64
go.sum
View File

@@ -1,15 +1,18 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20231013182643-f333f2578a3c h1:WaJ9eX8eyOBHD8te5t7xzm27uwhfaN94o8vUVFXliyA=
github.com/beeper/discordgo v0.0.0-20231013182643-f333f2578a3c/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f h1:A+SRmETpSnFixbP1x6u7sQdoi8cOuYfL5bkDJy9F/Pg=
github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/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/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -21,46 +24,47 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=
github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.5.6 h1:COmQAWTCcGetChm3Ig7G/t8AFAN00t+o8Mt4cf7JpwA=
github.com/yuin/goldmark v1.5.6/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.2.0 h1:AMGBEdg9Ya/smb/09dljo9wBwKr432EpfjDWF7aFQg0=
go.mau.fi/util v0.2.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs=
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw=
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
@@ -71,5 +75,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.16.1 h1:Wb3CvOCe8A/NLsFeZYxKrgXKiqeZUQEBD1zqm7n/kWk=
maunium.net/go/mautrix v0.16.1/go.mod h1:2Jf15tulVtr6LxoiRL4smRXwpkGWUNfBFhwh/aXDBuk=
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=

View File

@@ -205,6 +205,7 @@ func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
Preset: "private_chat",
InitialState: initialState,
CreationContent: creationContent,
RoomVersion: "11",
})
if err != nil {
guild.log.Warnln("Failed to create room:", err)

10
main.go
View File

@@ -18,6 +18,7 @@ package main
import (
_ "embed"
"net/http"
"sync"
"go.mau.fi/util/configupgrade"
@@ -25,6 +26,7 @@ import (
"golang.org/x/sync/semaphore"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/config"
@@ -48,6 +50,7 @@ type DiscordBridge struct {
Config *config.Config
DB *database.Database
DMA *DirectMediaAPI
provisioning *ProvisioningAPI
usersByMXID map[id.UserID]*User
@@ -93,6 +96,7 @@ func (br *DiscordBridge) GetConfigPtr() interface{} {
func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
matrixHTMLParser.PillConverter = br.pillConverter
@@ -104,6 +108,10 @@ func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
if br.Config.Bridge.PublicAddress != "" {
br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet)
}
br.DMA = newDirectMediaAPI(br)
br.WaitWebsocketConnected()
go br.startUsers()
}
@@ -179,7 +187,7 @@ func main() {
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.6.3",
Version: "0.7.6",
ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",

332
portal.go
View File

@@ -3,12 +3,17 @@ package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -17,6 +22,7 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/util/variationselector"
@@ -295,7 +301,8 @@ func (portal *Portal) MainIntent() *appservice.IntentAPI {
type CustomBridgeInfoContent struct {
event.BridgeEventContent
RoomType string `json:"com.beeper.room_type,omitempty"`
RoomType string `json:"com.beeper.room_type,omitempty"`
RoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"`
}
func init() {
@@ -338,7 +345,14 @@ func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) {
if portal.Type == discordgo.ChannelTypeDM || portal.Type == discordgo.ChannelTypeGroupDM {
roomType = "dm"
}
return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType}
var roomTypeV2 string
if portal.Type == discordgo.ChannelTypeDM {
roomTypeV2 = "dm"
} else if portal.Type == discordgo.ChannelTypeGroupDM {
roomTypeV2 = "group_dm"
}
return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType, roomTypeV2}
}
func (portal *Portal) UpdateBridgeInfo() {
@@ -459,7 +473,7 @@ func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) e
Content: event.Content{Parsed: &event.JoinRulesEventContent{
JoinRule: event.JoinRuleRestricted,
Allow: []event.JoinRuleAllow{{
RoomID: spaceID,
RoomID: portal.Guild.MXID,
Type: event.JoinRuleAllowRoomMembership,
}},
}},
@@ -475,6 +489,7 @@ func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) e
IsDirect: portal.IsPrivateChat(),
InitialState: initialState,
CreationContent: creationContent,
RoomVersion: "11",
}
if !portal.shouldSetDMRoomMetadata() && !portal.FriendNick {
req.Name = ""
@@ -519,7 +534,7 @@ func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) e
if portal.GuildID == "" {
user.addPrivateChannelToSpace(portal)
} else {
portal.updateSpace()
portal.updateSpace(user)
}
portal.ensureUserInvited(user, true)
user.syncChatDoublePuppetDetails(portal, true)
@@ -1114,7 +1129,7 @@ func (portal *Portal) getEvent(mxid id.EventID) (*event.Event, error) {
if evt.Type == event.EventEncrypted {
decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to decrypt event: %w", err)
} else {
evt = decryptedEvt
}
@@ -1162,7 +1177,7 @@ func (portal *Portal) startThreadFromMatrix(sender *User, threadRoot id.EventID)
AutoArchiveDuration: 24 * 60,
Type: discordgo.ChannelTypeGuildPublicThread,
Location: "Message",
})
}, portal.RefererOptIfUser(sender.Session, "")...)
if err != nil {
return "", fmt.Errorf("error starting thread: %v", err)
}
@@ -1175,7 +1190,7 @@ func (portal *Portal) startThreadFromMatrix(sender *User, threadRoot id.EventID)
}
}
func (portal *Portal) sendErrorMessage(msgType, message string, confirmed bool) id.EventID {
func (portal *Portal) sendErrorMessage(evt *event.Event, msgType, message string, confirmed bool) id.EventID {
if !portal.bridge.Config.Bridge.MessageErrorNotices {
return ""
}
@@ -1186,10 +1201,15 @@ func (portal *Portal) sendErrorMessage(msgType, message string, confirmed bool)
if portal.RelayWebhookSecret != "" {
message = strings.ReplaceAll(message, portal.RelayWebhookSecret, "<redacted>")
}
resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, message),
}, nil, 0)
}
relatable, ok := evt.Content.Parsed.(event.Relatable)
if ok && relatable.OptionalGetRelatesTo().GetThreadParent() != "" {
content.GetRelatesTo().SetThread(relatable.OptionalGetRelatesTo().GetThreadParent(), evt.ID)
}
resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, content, nil, 0)
if err != nil {
portal.log.Warn().Err(err).Msg("Failed to send bridging error message")
return ""
@@ -1354,7 +1374,7 @@ func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part strin
if humanMessage == "" {
humanMessage = err.Error()
}
portal.sendErrorMessage(msgType, humanMessage, isCertain)
portal.sendErrorMessage(evt, msgType, humanMessage, isCertain)
}
portal.sendStatusEvent(evt.ID, err)
} else {
@@ -1365,6 +1385,64 @@ func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part strin
}
}
func (br *DiscordBridge) serveMediaProxy(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mxc := id.ContentURI{
Homeserver: vars["server"],
FileID: vars["mediaID"],
}
checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"])
if err != nil || len(checksum) != 32 {
w.WriteHeader(http.StatusNotFound)
return
}
_, expectedChecksum := br.hashMediaProxyURL(mxc)
if !hmac.Equal(checksum, expectedChecksum) {
w.WriteHeader(http.StatusNotFound)
return
}
reader, err := br.Bot.Download(mxc)
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to download media to proxy")
w.WriteHeader(http.StatusInternalServerError)
return
}
buf := make([]byte, 32*1024)
n, err := io.ReadFull(reader, buf)
if err != nil && (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
br.ZLog.Warn().Err(err).Msg("Failed to read first part of media to proxy")
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Add("Content-Type", http.DetectContentType(buf[:n]))
if n < len(buf) {
w.Header().Add("Content-Length", strconv.Itoa(n))
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(buf[:n])
if err != nil {
return
}
if n >= len(buf) {
_, _ = io.CopyBuffer(w, reader, buf)
}
}
func (br *DiscordBridge) hashMediaProxyURL(mxc id.ContentURI) (string, []byte) {
path := fmt.Sprintf("/mautrix-discord/avatar/%s/%s/", mxc.Homeserver, mxc.FileID)
checksum := hmac.New(sha256.New, []byte(br.Config.Bridge.AvatarProxyKey))
checksum.Write([]byte(path))
return path, checksum.Sum(nil)
}
func (br *DiscordBridge) makeMediaProxyURL(mxc id.ContentURI) string {
if br.Config.Bridge.PublicAddress == "" {
return ""
}
path, checksum := br.hashMediaProxyURL(mxc)
return br.Config.Bridge.PublicAddress + path + base64.RawURLEncoding.EncodeToString(checksum)
}
func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) {
member := portal.bridge.StateStore.GetMember(portal.MXID, sender.MXID)
name = member.Displayname
@@ -1372,11 +1450,8 @@ func (portal *Portal) getRelayUserMeta(sender *User) (name, avatarURL string) {
name = sender.MXID.String()
}
mxc := member.AvatarURL.ParseOrIgnore()
if !mxc.IsEmpty() {
avatarURL = mautrix.BuildURL(
portal.bridge.PublicHSAddress,
"_matrix", "media", "v3", "download", mxc.Homeserver, mxc.FileID,
).String()
if !mxc.IsEmpty() && portal.bridge.Config.Bridge.PublicAddress != "" {
avatarURL = portal.bridge.makeMediaProxyURL(mxc)
}
return
}
@@ -1405,13 +1480,9 @@ func cutBody(body string) string {
}
func (portal *Portal) convertReplyMessageToEmbed(eventID id.EventID, url string) (*discordgo.MessageEmbed, error) {
evt, err := portal.MainIntent().GetEvent(portal.MXID, eventID)
evt, err := portal.getEvent(eventID)
if err != nil {
return nil, fmt.Errorf("failed to fetch event: %w", err)
}
err = evt.Content.ParseRaw(evt.Type)
if err != nil {
return nil, fmt.Errorf("failed to parse event content: %w", err)
return nil, fmt.Errorf("failed to get reply target event: %w", err)
}
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
@@ -1436,10 +1507,30 @@ func (portal *Portal) convertReplyMessageToEmbed(eventID id.EventID, url string)
return embed, nil
}
func (portal *Portal) RefererOpt(threadID string) discordgo.RequestOption {
if threadID != "" && threadID != portal.Key.ChannelID {
return discordgo.WithThreadReferer(portal.GuildID, portal.Key.ChannelID, threadID)
}
return discordgo.WithChannelReferer(portal.GuildID, portal.Key.ChannelID)
}
func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) []discordgo.RequestOption {
if sess == nil || !sess.IsUser {
return nil
}
return []discordgo.RequestOption{portal.RefererOpt(threadID)}
}
func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return
if portal.IsPrivateChat() {
if !sender.IsLoggedIn() {
go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring")
return
}
if sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return
}
}
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
@@ -1460,7 +1551,8 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil {
edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID)
if edits != nil {
discordContent, allowedMentions := portal.parseMatrixHTML(content.NewContent)
newContentRaw, _ := evt.Content.Raw["m.new_content"].(map[string]any)
discordContent, allowedMentions := portal.parseMatrixHTML(content.NewContent, parseAllowedLinkPreviews(newContentRaw))
var err error
var msg *discordgo.Message
if !isWebhookSend {
@@ -1515,9 +1607,12 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
}
}
if replyToMXID := content.RelatesTo.GetNonFallbackReplyTo(); replyToMXID != "" {
replyToMXID := content.RelatesTo.GetNonFallbackReplyTo()
var replyToUser id.UserID
if replyToMXID != "" {
replyTo := portal.bridge.DB.Message.GetByMXID(portal.Key, replyToMXID)
if replyTo != nil && replyTo.ThreadID == threadID {
replyToUser = replyTo.SenderMXID
if isWebhookSend {
messageURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", portal.GuildID, channelID, replyTo.DiscordID)
embed, err := portal.convertReplyMessageToEmbed(replyTo.MXID, messageURL)
@@ -1536,7 +1631,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
}
switch content.MsgType {
case event.MsgText, event.MsgEmote, event.MsgNotice:
sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content)
sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content, parseAllowedLinkPreviews(evt.Content.Raw))
if content.MsgType == event.MsgEmote {
sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content)
}
@@ -1549,30 +1644,39 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
filename := content.Body
if content.FileName != "" && content.FileName != content.Body {
filename = content.FileName
sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content)
sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content, parseAllowedLinkPreviews(evt.Content.Raw))
}
if evt.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true {
filename = "SPOILER_" + filename
}
if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser {
att := &discordgo.MessageAttachment{
ID: "0",
Filename: filename,
Description: description,
ID: "0",
Filename: filename,
Description: description,
OriginalContentType: content.Info.MimeType,
}
sendReq.Attachments = []*discordgo.MessageAttachment{att}
isClip := false
prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
Files: []*discordgo.FilePrepare{{
Size: len(data),
Name: att.Filename,
ID: sender.NextDiscordUploadID(),
IsClip: &isClip,
OriginalContentType: att.OriginalContentType,
}},
})
}, portal.RefererOpt(threadID))
if err != nil {
go portal.sendMessageMetrics(evt, err, "Error preparing to reupload media in")
return
}
prepared := prep.Attachments[0]
att.UploadedFilename = prepared.UploadFilename
err = uploadDiscordAttachment(prepared.UploadURL, data)
err = uploadDiscordAttachment(sender.Session.Client, prepared.UploadURL, data)
if err != nil {
go portal.sendMessageMetrics(evt, err, "Error reuploading media in")
return
@@ -1588,10 +1692,22 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
go portal.sendMessageMetrics(evt, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType), "Ignoring")
return
}
silentReply := content.Mentions != nil && replyToMXID != "" &&
(len(content.Mentions.UserIDs) == 0 || (replyToUser != "" && !slices.Contains(content.Mentions.UserIDs, replyToUser)))
if silentReply && sendReq.AllowedMentions != nil {
sendReq.AllowedMentions.RepliedUser = false
}
if !isWebhookSend {
// AllowedMentions must not be set for real users, and it's also not that useful for personal bots.
// It's only important for relaying, where the webhook may have higher permissions than the user on Matrix.
sendReq.AllowedMentions = nil
if silentReply {
sendReq.AllowedMentions = &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{discordgo.AllowedMentionTypeUsers, discordgo.AllowedMentionTypeRoles, discordgo.AllowedMentionTypeEveryone},
RepliedUser: false,
}
} else {
sendReq.AllowedMentions = nil
}
} else if strings.Contains(sendReq.Content, "@everyone") || strings.Contains(sendReq.Content, "@here") {
powerLevels, err := portal.MainIntent().PowerLevels(portal.MXID)
if err != nil {
@@ -1606,20 +1722,20 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
var msg *discordgo.Message
var err error
if !isWebhookSend {
msg, err = sess.ChannelMessageSendComplex(channelID, &sendReq)
msg, err = sess.ChannelMessageSendComplex(channelID, &sendReq, portal.RefererOptIfUser(sess, threadID)...)
} else {
username, avatarURL := portal.getRelayUserMeta(sender)
msg, err = relayClient.WebhookThreadExecute(portal.RelayWebhookID, portal.RelayWebhookSecret, true, threadID, &discordgo.WebhookParams{
Content: sendReq.Content,
Username: username,
AvatarURL: avatarURL,
TTS: sendReq.TTS,
Files: sendReq.Files,
Components: sendReq.Components,
Embeds: sendReq.Embeds,
AllowedMentions: sendReq.AllowedMentions,
})
}
sender.handlePossible40002(err)
go portal.sendMessageMetrics(evt, err, "Error sending")
if msg != nil {
dbMsg := portal.bridge.DB.Message.New()
@@ -1641,6 +1757,28 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
}
}
func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil {
return nil
}
linkPreviews, ok := raw["com.beeper.linkpreviews"].([]any)
if !ok {
return nil
}
allowedLinkPreviews := make([]string, 0, len(linkPreviews))
for _, preview := range linkPreviews {
previewMap, ok := preview.(map[string]any)
if !ok {
continue
}
matchedURL, _ := previewMap["matched_url"].(string)
if matchedURL != "" {
allowedLinkPreviews = append(allowedLinkPreviews, matchedURL)
}
}
return allowedLinkPreviews
}
func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) {
if portal.bridge.Config.Bridge.DeliveryReceipts {
err := portal.bridge.Bot.MarkRead(portal.MXID, eventID)
@@ -1791,12 +1929,13 @@ func (portal *Portal) getMatrixUsers() ([]id.UserID, error) {
}
func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
if !sender.IsLoggedIn() {
go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring")
return
}
if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return
} else if !sender.IsLoggedIn() {
//go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring")
return
}
reaction := evt.Content.AsReaction()
@@ -1831,13 +1970,15 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
emojiID := reaction.RelatesTo.Key
if strings.HasPrefix(emojiID, "mxc://") {
uri, _ := id.ParseContentURI(emojiID)
emojiFile := portal.bridge.DB.File.GetEmojiByMXC(uri)
if emojiFile == nil || emojiFile.ID == "" || emojiFile.EmojiName == "" {
emojiInfo := portal.bridge.DMA.GetEmojiInfo(uri)
if emojiInfo != nil {
emojiID = fmt.Sprintf("%s:%d", emojiInfo.Name, emojiInfo.EmojiID)
} else if emojiFile := portal.bridge.DB.File.GetEmojiByMXC(uri); emojiFile != nil && emojiFile.ID != "" && emojiFile.EmojiName != "" {
emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID)
} else {
go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring")
return
}
emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID)
} else {
emojiID = variationselector.FullyQualify(emojiID)
}
@@ -1852,7 +1993,7 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
return
}
err := sender.Session.MessageReactionAdd(msg.DiscordProtoChannelID(), msg.DiscordID, emojiID)
err := sender.Session.MessageReactionAddUser(portal.GuildID, msg.DiscordProtoChannelID(), msg.DiscordID, emojiID)
go portal.sendMessageMetrics(evt, err, "Error sending")
if err == nil {
dbReaction := portal.bridge.DB.Reaction.New()
@@ -1972,9 +2113,15 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess
}
func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) {
if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return
if portal.IsPrivateChat() {
if !sender.IsLoggedIn() {
go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring")
return
}
if sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return
}
}
sess := sender.Session
@@ -1988,7 +2135,7 @@ func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) {
var err error
// TODO add support for deleting individual attachments from messages
if sess != nil {
err = sess.ChannelMessageDelete(message.DiscordProtoChannelID(), message.DiscordID)
err = sess.ChannelMessageDelete(message.DiscordProtoChannelID(), message.DiscordID, portal.RefererOptIfUser(sess, message.ThreadID)...)
} else {
// TODO pre-validate that the message was sent by the webhook?
err = relayClient.WebhookMessageDelete(portal.RelayWebhookID, portal.RelayWebhookSecret, message.DiscordID)
@@ -2003,7 +2150,7 @@ func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) {
if sess != nil {
reaction := portal.bridge.DB.Reaction.GetByMXID(evt.Redacts)
if reaction != nil && reaction.Channel == portal.Key {
err := sess.MessageReactionRemove(reaction.DiscordProtoChannelID(), reaction.MessageID, reaction.EmojiName, reaction.Sender)
err := sess.MessageReactionRemoveUser(portal.GuildID, reaction.DiscordProtoChannelID(), reaction.MessageID, reaction.EmojiName, reaction.Sender)
go portal.sendMessageMetrics(evt, err, "Error sending")
if err == nil {
reaction.Delete()
@@ -2072,7 +2219,7 @@ func (portal *Portal) HandleMatrixReadReceipt(brUser bridge.User, eventID id.Eve
Msg("Dropping read receipt: thread ID mismatch")
return
}
resp, err := sender.Session.ChannelMessageAckNoToken(msg.DiscordProtoChannelID(), msg.DiscordID)
resp, err := sender.Session.ChannelMessageAckNoToken(msg.DiscordProtoChannelID(), msg.DiscordID, portal.RefererOpt(msg.DiscordProtoChannelID()))
if err != nil {
log.Err(err).Msg("Failed to send read receipt to Discord")
} else if resp.Token != nil {
@@ -2106,7 +2253,7 @@ func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) {
user := portal.bridge.GetUserByMXID(userID)
if user != nil && user.Session != nil {
user.ViewingChannel(portal)
err := user.Session.ChannelTyping(portal.Key.ChannelID)
err := user.Session.ChannelTyping(portal.Key.ChannelID, portal.RefererOptIfUser(user.Session, "")...)
if err != nil {
portal.log.Warn().Err(err).
Str("user_id", user.MXID.String()).
@@ -2318,11 +2465,19 @@ func (portal *Portal) ExpectedSpaceID() id.RoomID {
return ""
}
func (portal *Portal) updateSpace() bool {
func (portal *Portal) updateSpace(source *User) bool {
if portal.MXID == "" {
return false
}
if portal.Parent != nil {
if portal.Parent.MXID != "" {
portal.log.Warn().Str("parent_id", portal.ParentID).Msg("Parent portal has no Matrix room, creating...")
err := portal.Parent.CreateMatrixRoom(source, nil)
if err != nil {
portal.log.Err(err).Str("parent_id", portal.ParentID).Msg("Failed to create Matrix room for parent")
return false
}
}
return portal.addToSpace(portal.Parent.MXID)
} else if portal.Guild != nil {
return portal.addToSpace(portal.Guild.MXID)
@@ -2413,7 +2568,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord
changed = portal.UpdateParent(meta.ParentID) || changed
// Private channels are added to the space in User.handlePrivateChannel
if portal.GuildID != "" && portal.MXID != "" && portal.ExpectedSpaceID() != portal.InSpace {
changed = portal.updateSpace() || changed
changed = portal.updateSpace(source) || changed
}
if changed {
portal.UpdateBridgeInfo()
@@ -2421,3 +2576,74 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord
}
return meta
}
func (br *DiscordBridge) HandleTombstone(evt *event.Event) {
if evt.StateKey == nil || *evt.StateKey != "" {
return
}
content, ok := evt.Content.Parsed.(*event.TombstoneEventContent)
if !ok {
return
}
defer br.MatrixHandler.TrackEventDuration(evt.Type)()
portal := br.GetPortalByMXID(evt.RoomID)
if portal == nil {
return
}
logEvt := portal.log.Debug().
Stringer("sender", evt.Sender).
Stringer("replacement_room", content.ReplacementRoom).
Str("body", content.Body)
if content.ReplacementRoom == "" {
logEvt.Msg("Received tombstone event with no replacement room, cleaning up portal")
portal.cleanup(true)
portal.RemoveMXID()
return
}
logEvt.Msg("Received tombstone event, joining new room")
_, err := br.Bot.JoinRoom(content.ReplacementRoom.String(), evt.Sender.Homeserver(), nil)
if err != nil {
portal.log.Err(err).Msg("Failed to join replacement room")
return
}
_, err = br.Bot.State(content.ReplacementRoom)
if err != nil {
portal.log.Err(err).Msg("Failed to get state of replacement room")
return
}
encrypted := br.AS.StateStore.IsEncrypted(portal.MXID)
br.portalsLock.Lock()
defer br.portalsLock.Unlock()
if portal.MXID != evt.RoomID {
portal.log.Warn().
Stringer("old_mxid", evt.RoomID).
Stringer("new_mxid", portal.MXID).
Msg("Portal MXID changed while processing tombstone event, not updating")
return
}
_, alreadyAPortal := br.portalsByMXID[content.ReplacementRoom]
if alreadyAPortal {
portal.log.Warn().
Stringer("replacement_room", content.ReplacementRoom).
Msg("Replacement room is already a portal, not updating")
return
}
delete(portal.bridge.portalsByMXID, portal.MXID)
portal.MXID = content.ReplacementRoom
portal.bridge.portalsByMXID[portal.MXID] = portal
portal.log = portal.bridge.ZLog.With().
Str("channel_id", portal.Key.ChannelID).
Str("channel_receiver", portal.Key.Receiver).
Str("room_id", portal.MXID.String()).
Logger()
portal.AvatarSet = false
portal.NameSet = false
portal.TopicSet = false
portal.Encrypted = encrypted
portal.InSpace = ""
portal.FirstEventID = ""
portal.Update()
portal.log.Info().Msg("Followed tombstone and updated portal MXID")
portal.UpdateBridgeInfo()
}

View File

@@ -18,6 +18,8 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"strconv"
@@ -27,12 +29,13 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/id"
"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 {
@@ -102,21 +105,17 @@ func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventCon
}
}
func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
var mime, ext string
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"
ext = "png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
ext = "png"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
ext = "json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
ext = "gif"
default:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
@@ -130,8 +129,9 @@ func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appserv
},
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Sticker(sticker.ID, ext)
if mxc.IsEmpty() {
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()
@@ -144,7 +144,7 @@ func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appserv
}
}
func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
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{
@@ -156,24 +156,27 @@ func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *apps
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
}
var extra map[string]any
switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
if att.Waveform != nil {
// TODO convert waveform
extra = map[string]any{
"org.matrix.1767.audio": map[string]any{
"duration": int(att.DurationSeconds * 1000),
},
"org.matrix.msc3245.voice": map[string]any{},
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
@@ -182,7 +185,7 @@ func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *apps
default:
content.MsgType = event.MsgFile
}
mxc := portal.bridge.Config.Bridge.MediaPatterns.Attachment(portal.Key.ChannelID, att.ID, att.Filename)
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 {
@@ -256,6 +259,7 @@ func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *apps
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,
@@ -287,7 +291,7 @@ func (portal *Portal) convertDiscordMessage(ctx context.Context, puppet *Puppet,
}
handledIDs[att.ID] = struct{}{}
log := log.With().Str("attachment_id", att.ID).Logger()
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil {
parts = append(parts, part)
}
}
@@ -345,10 +349,10 @@ func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Messa
var discordAvatarURL string
if msg.Member.Avatar != "" {
var err error
avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
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.Author.Avatar).
Str("avatar_id", msg.Member.Avatar).
Msg("Failed to reupload guild user avatar")
}
}
@@ -360,8 +364,7 @@ func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Messa
}
if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
perMessageProfile := map[string]any{
"is_multiple_users": false,
"id": fmt.Sprintf("%s_%s", msg.GuildID, msg.Author.ID),
"displayname": msg.Member.Nick,
"avatar_url": avatarURL.String(),
}
@@ -399,11 +402,21 @@ func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Mess
"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{
"is_multiple_users": true,
"avatar_url": avatarURL.String(),
"displayname": msg.Author.Username,
"id": hex.EncodeToString(profileID[:]),
"avatar_url": avatarURL.String(),
"displayname": msg.Author.Username,
"has_fallback": hasFallback,
}
}
@@ -636,7 +649,7 @@ func isPlainGifMessage(msg *discordgo.Message) bool {
}
embed := msg.Embeds[0]
isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != 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)
}
@@ -663,6 +676,12 @@ func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts
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 {
@@ -684,6 +703,36 @@ func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *app
}
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 {
@@ -726,11 +775,5 @@ func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *app
"com.beeper.linkpreviews": previews,
}
if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
content.EnsureHasHTML()
content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"net"
"net/http"
_ "net/http/pprof"
"strings"
"time"
@@ -71,6 +72,13 @@ func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost)
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
}

View File

@@ -217,27 +217,23 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
}
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
var downloadURL, ext string
var downloadURL string
if guildID == "" {
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
ext = "gif"
} else {
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
}
} else {
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
ext = "png"
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
ext = "gif"
} else {
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
}
}
if guildID == "" {
url := br.Config.Bridge.MediaPatterns.Avatar(userID, avatarID, ext)
if !url.IsEmpty() {
return url, downloadURL, nil
}
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),

View File

@@ -112,6 +112,10 @@ func (thread *Thread) maybeInitialBackfill(source *User) {
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) {
if user.IsInPortal(thread.ID) {
return
@@ -137,7 +141,7 @@ func (thread *Thread) Join(user *User) {
var err error
if user.Session.IsUser {
err = user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu)
err = user.Session.ThreadJoin(thread.ID, discordgo.WithLocationParam(discordgo.ThreadJoinLocationContextMenu), thread.RefererOpt())
} else {
err = user.Session.ThreadJoin(thread.ID)
}

72
user.go
View File

@@ -2,11 +2,14 @@ package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net/http"
"net/url"
"os"
"runtime/debug"
"sort"
"strconv"
"strings"
@@ -100,7 +103,7 @@ func discordToZeroLevel(level int) zerolog.Level {
func init() {
discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) {
discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
}
}
@@ -195,6 +198,12 @@ func (br *DiscordBridge) GetCachedUserByID(id string) *User {
return br.usersByID[id]
}
func (br *DiscordBridge) GetCachedUserByMXID(userID id.UserID) *User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
return br.usersByMXID[userID]
}
func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
user := &User{
User: dbUser,
@@ -335,6 +344,7 @@ func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.Roo
user.MXID: 50,
},
},
RoomVersion: "11",
})
if err != nil {
@@ -540,21 +550,55 @@ func (user *User) Connect() error {
if err != nil {
return err
}
if user.HeartbeatSession == nil || user.HeartbeatSession.IsExpired() {
user.log.Debug().Msg("Creating new heartbeat session")
sess := discordgo.NewHeartbeatSession()
user.HeartbeatSession = &sess
}
user.HeartbeatSession.BumpLastUsed()
user.Update()
// make discordgo use our session instead of the one it creates automatically
session.HeartbeatSession = *user.HeartbeatSession
if user.bridge.Config.Bridge.Proxy != "" {
u, _ := url.Parse(user.bridge.Config.Bridge.Proxy)
tlsConf := &tls.Config{
InsecureSkipVerify: os.Getenv("DISCORD_SKIP_TLS_VERIFICATION") == "true",
}
session.Client.Transport = &http.Transport{
Proxy: http.ProxyURL(u),
TLSClientConfig: tlsConf,
ForceAttemptHTTP2: true,
}
session.Dialer.Proxy = http.ProxyURL(u)
session.Dialer.TLSClientConfig = tlsConf
}
// TODO move to config
if os.Getenv("DISCORD_DEBUG") == "1" {
session.LogLevel = discordgo.LogDebug
} else {
session.LogLevel = discordgo.LogInformational
}
userDiscordLog := user.log.With().Str("component", "discordgo").Logger()
userDiscordLog := user.log.With().
Str("component", "discordgo").
Str("heartbeat_session", session.HeartbeatSession.ID.String()).
Logger()
session.Logger = func(msgL, caller int, format string, a ...interface{}) {
userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
}
if !session.IsUser {
session.Identify.Intents = BotIntents
}
session.EventHandler = user.eventHandlerSync
if session.IsUser {
err = session.LoadMainPage(context.TODO())
if err != nil {
user.log.Warn().Err(err).Msg("Failed to load main page")
}
}
user.Session = session
for {
@@ -573,6 +617,15 @@ func (user *User) eventHandlerSync(rawEvt any) {
}
func (user *User) eventHandler(rawEvt any) {
defer func() {
err := recover()
if err != nil {
user.log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Any(zerolog.ErrorFieldName, err).
Msg("Panic in Discord event handler")
}
}()
switch evt := rawEvt.(type) {
case *discordgo.Ready:
user.readyHandler(evt)
@@ -768,6 +821,7 @@ func (user *User) subscribeGuilds(delay time.Duration) {
func (user *User) resumeHandler(_ *discordgo.Resumed) {
user.log.Debug().Msg("Discord connection resumed")
user.subscribeGuilds(0 * time.Second)
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
@@ -968,7 +1022,6 @@ func (user *User) connectedHandler(_ *discordgo.Connect) {
user.log.Debug().Msg("Connected to Discord")
if user.wasDisconnected {
user.wasDisconnected = false
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
}
@@ -993,6 +1046,15 @@ func (user *User) invalidAuthHandler(_ *discordgo.InvalidAuth) {
go user.Logout(false)
}
func (user *User) handlePossible40002(err error) bool {
var restErr *discordgo.RESTError
if !errors.As(err, &restErr) || restErr.Message == nil || restErr.Message.Code != discordgo.ErrCodeActionRequiredVerifiedAccount {
return false
}
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-http-40002", Message: restErr.Message.Message})
return true
}
func (user *User) guildCreateHandler(g *discordgo.GuildCreate) {
user.log.Info().
Str("guild_id", g.ID).
@@ -1106,7 +1168,7 @@ func (user *User) channelUpdateHandler(c *discordgo.ChannelUpdate) {
portal := user.GetPortalByMeta(c.Channel)
if c.GuildID == "" {
user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String()))
} else {
} else if user.channelIsBridgeable(c.Channel) {
portal.UpdateInfo(user, c.Channel)
}
}