20 Commits
v0.7.5 ... main

Author SHA1 Message Date
1717ddb30f Add simple thread support for forums
Some checks failed
Go / Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} (1.26) (push) Has been cancelled
Go / Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} (1.25) (push) Failing after 4m44s
Lock old issues / lock-stale (push) Failing after 5s
2026-02-17 21:38:06 +02:00
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
19 changed files with 259 additions and 70 deletions

View File

@@ -1,14 +1,16 @@
--- ---
name: Bug report name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue), about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. file a bug report. Remember to include relevant logs. Asking in the Matrix room first
labels: bug is strongly recommended.
type: Bug
--- ---
<!-- <!--
Remember to include relevant logs, the bridge version and any other details. Remember to include relevant logs, the bridge version and any other details.
If you aren't sure what's needed, ask in the Matrix room rather than opening an It's always best to ask in the Matrix room first, especially if you aren't sure
incomplete issue. Issues with insufficient detail will likely just be ignored. 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 name: Enhancement request
about: Submit a feature request or other suggestion about: Submit a feature request or other suggestion
labels: enhancement type: Feature
--- ---

View File

@@ -8,8 +8,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go-version: ["1.23", "1.24"] go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -1,3 +1,15 @@
# 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) # v0.7.5 (2025-07-16)
* Fixed federation key response when using direct media. * Fixed federation key response when using direct media.

View File

@@ -1,6 +1,4 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.22 AS lottie FROM golang:1-alpine3.23 AS builder
FROM golang:1-alpine3.22 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -8,19 +6,17 @@ COPY . /build
WORKDIR /build WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord RUN go build -o /usr/bin/mautrix-discord
FROM alpine:3.22 FROM alpine:3.23
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \ RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
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 --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY --from=builder /build/docker-run.sh /docker-run.sh COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"] CMD ["/docker-run.sh"]

View File

@@ -1,19 +1,15 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.22 AS lottie FROM alpine:3.23
FROM alpine:3.22
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \ RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
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
ARG EXECUTABLE=./mautrix-discord ARG EXECUTABLE=./mautrix-discord
COPY $EXECUTABLE /usr/bin/mautrix-discord COPY $EXECUTABLE /usr/bin/mautrix-discord
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY ./docker-run.sh /docker-run.sh COPY ./docker-run.sh /docker-run.sh
VOLUME /data VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"] CMD ["/docker-run.sh"]

View File

@@ -706,9 +706,25 @@ func fnBridge(ce *WrappedCommandEvent) {
} }
portal := ce.User.GetExistingPortalByID(channelID) portal := ce.User.GetExistingPortalByID(channelID)
if portal == nil { if portal == nil {
// HACK: Before giving up, discover if the user is trying to join a
// thread. Then, cause the creation of a portal.
// This is for forum channel threads; they don't show up on the
// forum channels.
ch, err := ce.User.Session.Channel(channelID)
if err != nil {
ce.Reply("Channel not found") ce.Reply("Channel not found")
return return
} }
if ch.Type != discordgo.ChannelTypeGuildPublicThread &&
ch.Type != discordgo.ChannelTypeGuildPrivateThread {
return
}
ce.ZLog.Debug().Msg("Adding public / private thread as a portal")
portal = ce.User.GetPortalByID(channelID, ch.Type)
}
portal.roomCreateLock.Lock() portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock() defer portal.roomCreateLock.Unlock()
if portal.MXID != "" { if portal.MXID != "" {

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

@@ -1,4 +1,4 @@
-- v0 -> v23 (compatible with v19+): Latest revision -- v0 -> v24 (compatible with v19+): Latest revision
CREATE TABLE guild ( CREATE TABLE guild (
dcid TEXT PRIMARY KEY, dcid TEXT PRIMARY KEY,
@@ -92,7 +92,8 @@ CREATE TABLE "user" (
space_room TEXT, space_room TEXT,
dm_space_room TEXT, dm_space_room TEXT,
read_state_version INTEGER NOT NULL DEFAULT 0 read_state_version INTEGER NOT NULL DEFAULT 0,
heartbeat_session jsonb
); );
CREATE TABLE user_portal ( CREATE TABLE user_portal (

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

View File

@@ -154,6 +154,7 @@ func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI {
addRoutes("r0") addRoutes("r0")
addRoutes("v1") addRoutes("v1")
federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) 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) federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet)
mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
@@ -556,7 +557,7 @@ func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWri
func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) { func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/") isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/")
vars := mux.Vars(r) vars := mux.Vars(r)
if !isNewFederation && vars["serverName"] != dma.cfg.ServerName { if !isNewFederation && vars["serverName"] != dma.cfg.ServerName {
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ jsonResponse(w, http.StatusNotFound, &mautrix.RespError{

View File

@@ -130,7 +130,7 @@ bridge:
message_error_notices: true message_error_notices: true
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms? # Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
# This can avoid unnecessary invite events in guild rooms when members are synced in. # This can avoid unnecessary invite events in guild rooms when members are synced in.
restricted_rooms: true restricted_rooms: false
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
autojoin_thread_on_open: true autojoin_thread_on_open: true

View File

@@ -98,6 +98,7 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLink
const formatterContextPortalKey = "fi.mau.discord.portal" const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions" const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_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 { func appendIfNotContains(arr []string, newItem string) []string {
for _, item := range arr { for _, item := range arr {
@@ -221,16 +222,24 @@ var matrixHTMLParser = &format.HTMLParser{
return fmt.Sprintf("||%s||", text) return fmt.Sprintf("||%s||", text)
}, },
LinkConverter: func(text, href string, ctx format.Context) string { 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 text == href {
if !allowPreview {
return fmt.Sprintf("<%s>", text)
}
return text return text
} else if !discordLinkRegexFull.MatchString(href) { } else if !discordLinkRegexFull.MatchString(href) {
return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(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{ allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{}, Parse: []discordgo.AllowedMentionType{},
Users: []string{}, Users: []string{},
@@ -238,6 +247,7 @@ func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (strin
} }
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext() ctx := format.NewContext()
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
ctx.ReturnData[formatterContextPortalKey] = portal ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil { if content.Mentions != nil {

9
go.mod
View File

@@ -1,8 +1,8 @@
module go.mau.fi/mautrix-discord module go.mau.fi/mautrix-discord
go 1.23.0 go 1.25.0
toolchain go1.24.5 toolchain go1.26.0
require ( require (
github.com/bwmarrin/discordgo v0.27.0 github.com/bwmarrin/discordgo v0.27.0
@@ -20,12 +20,13 @@ require (
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc
golang.org/x/sync v0.16.0 golang.org/x/sync v0.16.0
maunium.net/go/maulogger/v2 v2.4.1 maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.16.3-0.20250629161415-a29d782e6638 maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2
) )
require ( require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -42,4 +43,4 @@ require (
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect
) )
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f

10
go.sum
View File

@@ -1,7 +1,7 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f h1:A+SRmETpSnFixbP1x6u7sQdoi8cOuYfL5bkDJy9F/Pg=
github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= 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 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -11,6 +11,8 @@ github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFA
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -73,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/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 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.16.3-0.20250629161415-a29d782e6638 h1:tZEmq1FBuueW6cmxoa2BkXYbfMknW8XPPMiZfD5PKto= maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
maunium.net/go/mautrix v0.16.3-0.20250629161415-a29d782e6638/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q= maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=

View File

@@ -26,6 +26,7 @@ import (
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/config" "go.mau.fi/mautrix-discord/config"
@@ -95,6 +96,7 @@ func (br *DiscordBridge) GetConfigPtr() interface{} {
func (br *DiscordBridge) Init() { func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge) br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands() br.RegisterCommands()
br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
matrixHTMLParser.PillConverter = br.pillConverter matrixHTMLParser.PillConverter = br.pillConverter
@@ -185,7 +187,7 @@ func main() {
Name: "mautrix-discord", Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord", URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.", Description: "A Matrix-Discord puppeting bridge.",
Version: "0.7.5", Version: "0.7.6",
ProtocolName: "Discord", ProtocolName: "Discord",
BeeperServiceName: "discordgo", BeeperServiceName: "discordgo",
BeeperNetworkName: "discord", BeeperNetworkName: "discord",

128
portal.go
View File

@@ -1522,10 +1522,16 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string)
} }
func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { 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") go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return return
} }
}
content, ok := evt.Content.Parsed.(*event.MessageEventContent) content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok { if !ok {
@@ -1545,7 +1551,8 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil { if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil {
edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID) edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID)
if edits != nil { 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 err error
var msg *discordgo.Message var msg *discordgo.Message
if !isWebhookSend { if !isWebhookSend {
@@ -1624,7 +1631,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
} }
switch content.MsgType { switch content.MsgType {
case event.MsgText, event.MsgEmote, event.MsgNotice: 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 { if content.MsgType == event.MsgEmote {
sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content) sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content)
} }
@@ -1637,7 +1644,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
filename := content.Body filename := content.Body
if content.FileName != "" && content.FileName != content.Body { if content.FileName != "" && content.FileName != content.Body {
filename = content.FileName 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 { if evt.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true {
@@ -1649,13 +1656,18 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
ID: "0", ID: "0",
Filename: filename, Filename: filename,
Description: description, Description: description,
OriginalContentType: content.Info.MimeType,
} }
sendReq.Attachments = []*discordgo.MessageAttachment{att} sendReq.Attachments = []*discordgo.MessageAttachment{att}
isClip := false
prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
Files: []*discordgo.FilePrepare{{ Files: []*discordgo.FilePrepare{{
Size: len(data), Size: len(data),
Name: att.Filename, Name: att.Filename,
ID: sender.NextDiscordUploadID(), ID: sender.NextDiscordUploadID(),
IsClip: &isClip,
OriginalContentType: att.OriginalContentType,
}}, }},
}, portal.RefererOpt(threadID)) }, portal.RefererOpt(threadID))
if err != nil { if err != nil {
@@ -1745,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) { func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) {
if portal.bridge.Config.Bridge.DeliveryReceipts { if portal.bridge.Config.Bridge.DeliveryReceipts {
err := portal.bridge.Bot.MarkRead(portal.MXID, eventID) err := portal.bridge.Bot.MarkRead(portal.MXID, eventID)
@@ -1895,12 +1929,13 @@ func (portal *Portal) getMatrixUsers() ([]id.UserID, error) {
} }
func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { 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 { if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver {
go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return return
} else if !sender.IsLoggedIn() {
//go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring")
return
} }
reaction := evt.Content.AsReaction() reaction := evt.Content.AsReaction()
@@ -2078,10 +2113,16 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess
} }
func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) {
if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { 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") go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
return return
} }
}
sess := sender.Session sess := sender.Session
if sess == nil && portal.RelayWebhookID == "" { if sess == nil && portal.RelayWebhookID == "" {
@@ -2535,3 +2576,74 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord
} }
return meta 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()
}

18
user.go
View File

@@ -550,6 +550,17 @@ func (user *User) Connect() error {
if err != nil { if err != nil {
return err 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 != "" { if user.bridge.Config.Bridge.Proxy != "" {
u, _ := url.Parse(user.bridge.Config.Bridge.Proxy) u, _ := url.Parse(user.bridge.Config.Bridge.Proxy)
tlsConf := &tls.Config{ tlsConf := &tls.Config{
@@ -569,7 +580,10 @@ func (user *User) Connect() error {
} else { } else {
session.LogLevel = discordgo.LogInformational 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{}) { session.Logger = func(msgL, caller int, format string, a ...interface{}) {
userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
} }
@@ -807,6 +821,7 @@ func (user *User) subscribeGuilds(delay time.Duration) {
func (user *User) resumeHandler(_ *discordgo.Resumed) { func (user *User) resumeHandler(_ *discordgo.Resumed) {
user.log.Debug().Msg("Discord connection resumed") user.log.Debug().Msg("Discord connection resumed")
user.subscribeGuilds(0 * time.Second) user.subscribeGuilds(0 * time.Second)
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
} }
func (user *User) addPrivateChannelToSpace(portal *Portal) bool { func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
@@ -1007,7 +1022,6 @@ func (user *User) connectedHandler(_ *discordgo.Connect) {
user.log.Debug().Msg("Connected to Discord") user.log.Debug().Msg("Connected to Discord")
if user.wasDisconnected { if user.wasDisconnected {
user.wasDisconnected = false user.wasDisconnected = false
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
} }
} }