diff --git a/Dockerfile b/Dockerfile index c5f7e329..f6bea77d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev COPY . /build WORKDIR /build -RUN go build -o /usr/bin/mautrix-discord +RUN ./build.sh -o /usr/bin/mautrix-discord FROM alpine:3.22 diff --git a/build.sh b/build.sh index aa6d009f..25883bd7 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@" +go build -ldflags="-s -w $GO_LDFLAGS" "$@" ./cmd/mautrix-discord diff --git a/cmd/mautrix-discord/main.go b/cmd/mautrix-discord/main.go index 53918015..001ffbba 100644 --- a/cmd/mautrix-discord/main.go +++ b/cmd/mautrix-discord/main.go @@ -17,6 +17,7 @@ package main import ( + "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/matrix/mxmain" "go.mau.fi/mautrix-discord/pkg/connector" @@ -40,6 +41,7 @@ var m = mxmain.BridgeMain{ } func main() { + bridgeconfig.HackyMigrateLegacyNetworkConfig = connector.MigrateLegacyConfig m.PostInit = func() { m.CheckLegacyDB(24, "v0.7.6", "v26.03", m.LegacyMigrateWithAnotherUpgrader( diff --git a/go.mod b/go.mod index c93390d7..29b27b3e 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,17 @@ toolchain go1.26.2 require ( github.com/bwmarrin/discordgo v0.27.0 - github.com/coder/websocket v1.8.14 + github.com/coder/websocket v1.8.15 github.com/google/uuid v1.6.0 github.com/imroc/req/v3 v3.57.0 github.com/refraction-networking/utls v1.8.1 github.com/rs/zerolog v1.35.1 github.com/yuin/goldmark v1.8.2 - go.mau.fi/util v0.9.9 - golang.org/x/net v0.55.0 - golang.org/x/term v0.43.0 + go.mau.fi/util v0.9.10 + golang.org/x/net v0.56.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.28.1-0.20260519145316-34b5f49408a3 + maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895 ) require ( @@ -30,7 +30,7 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect - github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/mattn/go-sqlite3 v1.14.45 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect @@ -41,11 +41,11 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 377345dc..0000463a 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5 h1:RS1pVR7FUf0nD3 github.com/beeper/discordgo v0.0.0-20260620030032-507542c8bda5/go.mod h1:s8aRVTzFZnyujER0GEVscqvjH6RwlPIzeDjOtWsrReM= github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73 h1:stqCPKTpnUY5JFn6wShL4RoMnebtR+MNC9fcLvOCH1Y= github.com/beeper/req/v3 v3.0.0-20260114152409-4c060b237f73/go.mod h1:pH5qbsMNeaNpCOlozJoLRuLfSxEYGp9uWEG5j6zogNQ= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -36,8 +36,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -73,26 +73,26 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -107,5 +107,5 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 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/mautrix v0.28.1-0.20260519145316-34b5f49408a3 h1:l86igJY8Te5JEBNPL9NSpZaGv6eHsxtCrAabXv8KPJo= -maunium.net/go/mautrix v0.28.1-0.20260519145316-34b5f49408a3/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= +maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895 h1:oe87/mP1vo4LnA6CJDEfyg5ZiAOar2quJNypfO8eS4Y= +maunium.net/go/mautrix v0.28.2-0.20260702123919-bf2dbb2aa895/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY= diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 20d9efeb..b09d6fca 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -168,6 +168,10 @@ var discordCaps = &event.RoomFeatures{ LocationMessage: event.CapLevelUnsupported, MaxTextLength: MaxTextLength, Thread: event.CapLevelPartialSupport, + + // Relayed Matrix users are sent through Discord webhooks, so Discord can + // show the sender name per message instead of prefixing the content. + PerMessageProfileRelay: true, } func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 0afc158e..1092339a 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -24,6 +24,7 @@ import ( "github.com/bwmarrin/discordgo" up "go.mau.fi/util/configupgrade" "gopkg.in/yaml.v3" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" ) //go:embed example-config.yaml @@ -147,6 +148,15 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Bool, "proxy_login_remoteauth") } +func MigrateLegacyConfig(helper up.Helper) { + bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "channel_name_template"}, []string{"network", "channel_name_template"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "custom_emoji_reactions"}, []string{"network", "custom_emoji_reactions"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "guild_avatars_in_rooms"}, []string{"network", "guilds", "guild_avatars_in_rooms"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "forbid_dming_strangers"}, []string{"network", "forbid_dming_strangers"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "log_when_dropping_messages"}, []string{"network", "log_when_dropping_messages"}) + bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "proxy"}, []string{"network", "proxy"}) +} + func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig) } diff --git a/pkg/connector/directmedia_test.go b/pkg/connector/directmedia_test.go index e4d09b86..6d7b3165 100644 --- a/pkg/connector/directmedia_test.go +++ b/pkg/connector/directmedia_test.go @@ -1,21 +1,19 @@ package connector import ( + "encoding/binary" + "encoding/hex" "testing" "time" ) func TestParseAttachmentExpiryParam(t *testing.T) { - losAngeles, err := time.LoadLocation("America/Los_Angeles") - if err != nil { - t.Fatalf("failed to load test timezone: %v", err) - } - - expiry := parseAttachmentExpiryParam("69be6214").In(losAngeles) - got := expiry.String() - want := "2026-03-21 02:17:08 -0700 PDT" + want := time.Now().Add(24 * time.Hour).Truncate(time.Second) + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], uint32(want.Unix())) - if got != want { + got := parseAttachmentExpiryParam(hex.EncodeToString(encoded[:])) + if !got.Equal(want) { t.Fatalf("unexpected parsed expiry: got %q, want %q", got, want) } } diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 6bc77f15..09225c07 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -724,6 +724,32 @@ func (d *DiscordClient) channelIsBridged(ctx context.Context, channelID string) return existingPortal != nil && existingPortal.MXID != "", route } +func (d *DiscordClient) isOwnRelayWebhookMessage(ctx context.Context, msg *discordgo.Message, route *router.Route) bool { + if msg == nil || route == nil { + return false + } + portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, route.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to look up portal when checking relay webhook message") + return false + } + if portal == nil { + return false + } + meta := portal.Metadata.(*discordid.PortalMetadata) + return isRelayWebhookDiscordMessage(msg, meta.RelayWebhookID) +} + +func isRelayWebhookDiscordMessage(msg *discordgo.Message, relayWebhookID string) bool { + if msg == nil || relayWebhookID == "" { + return false + } + if msg.WebhookID == relayWebhookID { + return true + } + return msg.WebhookID != "" && msg.Author != nil && msg.Author.ID == relayWebhookID +} + func (d *DiscordClient) handleUserGuildSettingsUpdate(ctx context.Context, evt *discordgo.UserGuildSettingsUpdate) { log := zerolog.Ctx(ctx) log.Debug().Msg("Handling user guild settings update") @@ -1010,6 +1036,10 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { } return } + if d.isOwnRelayWebhookMessage(ctx, evt.Message, route) { + log.Debug().Msg("Dropping message from own relay webhook") + return + } if evt.Message.Type == discordgo.MessageTypeGuildMemberJoin { d.userCache.UpdateWithMessage(evt.Message) @@ -1030,6 +1060,10 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { if !bridged { return } + if d.isOwnRelayWebhookMessage(ctx, evt.Message, route) { + log.Debug().Msg("Dropping message update from own relay webhook") + return + } if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil { log.Err(err).Str("message_id", evt.ID).Msg("Failed to persist thread info from message update") diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 5a758435..e4ebaf9d 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -149,7 +149,39 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M d.lastSendAttemptMutex.Unlock() } - sentMsg, err := d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx)) + var sentMsg *discordgo.Message + if msg.OrigSender != nil { + if guildID == "" { + err = fmt.Errorf("Discord webhooks are not available in DMs/group DMs") + return nil, bridgev2.WrapErrorInStatus(err). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithMessage("Relay messages need Discord webhooks, which are only available in guild channels."). + WithSendNotice(true) + } + username, avatarURL := d.relayWebhookProfile(ctx, portal, msg.OrigSender) + if username == "" { + username = msg.OrigSender.UserID.String() + } + username = sanitizeRelayWebhookUsername(username, msg.OrigSender.UserID.String()) + params := &discordgo.WebhookParams{ + Content: sendReq.Content, + Username: username, + AvatarURL: avatarURL, + Embeds: sendReq.Embeds, + Components: sendReq.Components, + Files: sendReq.Files, + Attachments: sendReq.Attachments, + AllowedMentions: sendReq.AllowedMentions, + Flags: relayWebhookFlags(sendReq.Flags), + } + if sendReq.Reference != nil { + params.Embeds = prependReplyEmbed(params.Embeds, guildID, sendReq.Reference.ChannelID, sendReq.Reference.MessageID) + } + sentMsg, err = d.executeRelayWebhook(ctx, portal, parentChannelID, threadChannelID, params, refererOpt) + } else { + sentMsg, err = d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx)) + } if err != nil { return nil, d.tryWrappingError(ctx, err) } @@ -213,11 +245,14 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr if !d.IsLoggedIn() { return bridgev2.ErrNotLoggedIn } + if msg.EditTarget == nil { + return fmt.Errorf("missing edit target") + } log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger() ctx = log.WithContext(ctx) - content, _ := d.connector.MsgConv.ConvertMatrixMessageContent( + content, allowedMentions := d.connector.MsgConv.ConvertMatrixMessageContent( ctx, msg.Portal, msg.Content, @@ -240,6 +275,29 @@ func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matr } } + if d.isRelayWebhookMessage(msg.Portal, msg.EditTarget) { + meta := msg.Portal.Metadata.(*discordid.PortalMetadata) + edit := &discordgo.WebhookEdit{ + Content: &content, + AllowedMentions: allowedMentions, + } + if err := d.populateRelayWebhookEditMedia(ctx, edit, msg.Content); err != nil { + return err + } + _, err := d.webhookMessageEditThread( + ctx, + meta.RelayWebhookID, + meta.RelayWebhookToken, + discordid.ParseMessageID(msg.EditTarget.ID), + threadChannelID, + edit, + ) + if err != nil { + return d.tryWrappingError(ctx, err) + } + return nil + } + _, err := d.Session.ChannelMessageEdit( channelID, discordid.ParseMessageID(msg.EditTarget.ID), @@ -275,6 +333,10 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b emojiID = variationselector.FullyQualify(emojiID) } + // Relayed reactions have to use the relay login on Discord. Discord only + // allows one reaction per user/emoji, so multiple Matrix users reacting + // with the same emoji intentionally collapse into the relay user's one + // Discord reaction. return bridgev2.MatrixReactionPreResponse{ SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID), EmojiID: discordid.MakeEmojiID(emojiID), @@ -349,6 +411,9 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * if !d.IsLoggedIn() { return bridgev2.ErrNotLoggedIn } + if removal.TargetMessage == nil { + return fmt.Errorf("missing message remove target") + } guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID) @@ -364,6 +429,16 @@ func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal * } } messageID := discordid.ParseMessageID(removal.TargetMessage.ID) + if d.isRelayWebhookMessage(removal.Portal, removal.TargetMessage) { + meta := removal.Portal.Metadata.(*discordid.PortalMetadata) + return d.tryWrappingError(ctx, d.webhookMessageDeleteThread( + ctx, + meta.RelayWebhookID, + meta.RelayWebhookToken, + messageID, + threadChannelID, + )) + } return d.tryWrappingError(ctx, d.Session.ChannelMessageDelete(channelID, messageID, makeDiscordReferer(guildID, parentChannelID, threadChannelID))) } diff --git a/pkg/connector/relaywebhook.go b/pkg/connector/relaywebhook.go new file mode 100644 index 00000000..3367d5ff --- /dev/null +++ b/pkg/connector/relaywebhook.go @@ -0,0 +1,372 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + + "go.mau.fi/mautrix-discord/pkg/discordid" +) + +const relayWebhookName = "mau bridge" + +var discordWebhookUsernameWord = regexp.MustCompile(`(?i)discord`) + +func relayWebhookFlags(flags *int) discordgo.MessageFlags { + if flags == nil { + return 0 + } + return discordgo.MessageFlags(*flags) & discordgo.MessageFlagsSuppressEmbeds +} + +func prependReplyEmbed(embeds []*discordgo.MessageEmbed, guildID, channelID, messageID string) []*discordgo.MessageEmbed { + if channelID == "" || messageID == "" { + return embeds + } + guildPart := guildID + if guildPart == "" { + guildPart = "@me" + } + replyEmbed := &discordgo.MessageEmbed{ + Description: fmt.Sprintf("[Replying to message](https://discord.com/channels/%s/%s/%s)", guildPart, channelID, messageID), + } + return append([]*discordgo.MessageEmbed{replyEmbed}, embeds...) +} + +func (d *DiscordClient) executeRelayWebhook( + ctx context.Context, + portal *bridgev2.Portal, + channelID string, + threadID string, + params *discordgo.WebhookParams, + refererOpt discordgo.RequestOption, +) (*discordgo.Message, error) { + webhookID, webhookToken, err := d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + sentMsg, err := d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) + if err == nil || !isStaleWebhookError(err) { + return sentMsg, err + } + zerolog.Ctx(ctx).Warn().Err(err).Msg("Relay webhook failed, clearing cached credentials and retrying once") + if clearErr := d.clearRelayWebhook(ctx, portal); clearErr != nil { + zerolog.Ctx(ctx).Warn().Err(clearErr).Msg("Failed to clear stale relay webhook metadata") + } + webhookID, webhookToken, err = d.getRelayWebhook(ctx, portal, channelID, refererOpt) + if err != nil { + return nil, d.tryWrappingError(ctx, err) + } + return d.Session.WebhookThreadExecute(webhookID, webhookToken, true, threadID, params, discordgo.WithContext(ctx)) +} + +func (d *DiscordClient) getRelayWebhook(ctx context.Context, portal *bridgev2.Portal, channelID string, refererOpt discordgo.RequestOption) (id, token string, err error) { + meta := portal.Metadata.(*discordid.PortalMetadata) + if meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" { + return meta.RelayWebhookID, meta.RelayWebhookToken, nil + } + + webhooks, err := d.Session.ChannelWebhooks(channelID, refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + for _, webhook := range webhooks { + if webhook != nil && webhook.Name == relayWebhookName && webhook.Token != "" { + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } + return webhook.ID, webhook.Token, nil + } + } + + webhook, err := d.Session.WebhookCreate(channelID, relayWebhookName, "", refererOpt, discordgo.WithContext(ctx)) + if err != nil { + return "", "", err + } + meta.RelayWebhookID = webhook.ID + meta.RelayWebhookToken = webhook.Token + if err = d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save relay webhook metadata") + } + return webhook.ID, webhook.Token, nil +} + +func (d *DiscordClient) clearRelayWebhook(ctx context.Context, portal *bridgev2.Portal) error { + meta := portal.Metadata.(*discordid.PortalMetadata) + meta.RelayWebhookID = "" + meta.RelayWebhookToken = "" + return d.UserLogin.Bridge.DB.Portal.Update(ctx, portal.Portal) +} + +func isStaleWebhookError(err error) bool { + var restErr *discordgo.RESTError + if !errors.As(err, &restErr) { + return false + } + if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusUnauthorized || restErr.Response.StatusCode == http.StatusNotFound) { + return true + } + return restErr.Message != nil && (restErr.Message.Code == discordgo.ErrCodeUnknownWebhook || restErr.Message.Code == discordgo.ErrCodeInvalidWebhookTokenProvided) +} + +func (d *DiscordClient) relayWebhookProfile(ctx context.Context, portal *bridgev2.Portal, sender *bridgev2.OrigSender) (username, avatarURL string) { + log := zerolog.Ctx(ctx) + username = relayWebhookUsername(sender) + if sender.User != nil { + if login, _, err := portal.FindPreferredLogin(ctx, sender.User, false); err != nil { + zerolog.Ctx(ctx).Debug().Err(err).Stringer("sender_mxid", sender.UserID).Msg("No explicit Discord login for relayed sender in portal") + } else if login != nil { + if user := d.userCache.Resolve(ctx, discordid.ParseUserLoginID(login.ID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("login_id", string(login.ID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from explicit Discord login") + return user.DisplayName(), user.AvatarURL("256") + } + } + } + if ghostID, ok := d.UserLogin.Bridge.Matrix.ParseGhostMXID(sender.UserID); ok { + if user := d.userCache.Resolve(ctx, discordid.ParseUserID(ghostID)); user != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("ghost_id", string(ghostID)). + Str("webhook_username", user.DisplayName()). + Bool("has_avatar_url", user.AvatarURL("256") != ""). + Msg("Resolved relay webhook profile from ghost MXID") + return user.DisplayName(), user.AvatarURL("256") + } + } + if profile := d.resolveRelayGhostByDisplayName(ctx, username); profile != nil { + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Str("webhook_username", profile.username). + Bool("has_avatar_url", profile.avatarURL != ""). + Msg("Resolved relay webhook profile from matching Discord ghost display name") + return profile.username, profile.avatarURL + } + log.Debug(). + Stringer("sender_mxid", sender.UserID). + Str("display_name", username). + Msg("Falling back to Matrix relay webhook profile") + return username, "" +} + +func sanitizeRelayWebhookUsername(username, fallback string) string { + username = strings.TrimSpace(username) + if username == "" { + username = strings.TrimSpace(fallback) + } + username = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, username) + username = strings.TrimSpace(discordWebhookUsernameWord.ReplaceAllString(username, "dscord")) + if username == "" { + username = "Matrix user" + } + const maxWebhookUsernameRunes = 80 + if utf8.RuneCountInString(username) > maxWebhookUsernameRunes { + runes := []rune(username) + username = string(runes[:maxWebhookUsernameRunes]) + } + return username +} + +func relayWebhookUsername(sender *bridgev2.OrigSender) string { + if sender.PerMessageProfile.Displayname != "" { + return sender.PerMessageProfile.Displayname + } + if sender.MemberEventContent.Displayname != "" { + return sender.MemberEventContent.Displayname + } + return sender.DisambiguatedName +} + +type relayWebhookProfileMatch struct { + username string + avatarURL string +} + +type relayWebhookGhostMatch struct { + userID string + name string + avatarURL string +} + +func chooseRelayWebhookGhostMatch(matches []relayWebhookGhostMatch) *relayWebhookGhostMatch { + var realAvatarMatches []relayWebhookGhostMatch + for _, match := range matches { + if !strings.Contains(match.avatarURL, "cdn.discordapp.com/embed/avatars/") { + realAvatarMatches = append(realAvatarMatches, match) + } + } + switch { + case len(realAvatarMatches) == 1: + return &realAvatarMatches[0] + case len(realAvatarMatches) > 1: + return nil + case len(matches) == 1: + return &matches[0] + default: + return nil + } +} + +func (d *DiscordClient) resolveRelayGhostByDisplayName(ctx context.Context, displayName string) *relayWebhookProfileMatch { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return nil + } + + rows, err := d.UserLogin.Bridge.DB.Query(ctx, ` + SELECT id, name, avatar_id FROM ghost + WHERE bridge_id=$1 AND ( + name=$2 OR + name=$3 OR + name=$4 + ) + `, d.UserLogin.Bridge.DB.BridgeID, displayName, displayName+" (Discord)", displayName+" (bot) (Discord)") + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to resolve relay sender against Discord ghosts") + return nil + } + defer rows.Close() + + var matches []relayWebhookGhostMatch + for rows.Next() { + var match relayWebhookGhostMatch + if err = rows.Scan(&match.userID, &match.name, &match.avatarURL); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed to scan relay ghost match") + return nil + } + matches = append(matches, match) + } + if err = rows.Err(); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("display_name", displayName).Msg("Failed while resolving relay ghost match") + return nil + } + matched := chooseRelayWebhookGhostMatch(matches) + if matched == nil { + zerolog.Ctx(ctx).Debug(). + Str("display_name", displayName). + Int("match_count", len(matches)). + Msg("Skipping relay webhook profile match due to ambiguous Discord ghost display names") + return nil + } + return &relayWebhookProfileMatch{ + username: matched.name, + avatarURL: matched.avatarURL, + } +} + +func (d *DiscordClient) populateRelayWebhookEditMedia(ctx context.Context, edit *discordgo.WebhookEdit, content *event.MessageEventContent) error { + switch content.MsgType { + case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: + default: + return nil + } + mediaData, err := d.connector.MsgConv.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to download Matrix attachment for relay webhook edit") + return bridgev2.ErrMediaDownloadFailed + } + filename := content.Body + if content.FileName != "" { + filename = content.FileName + } + if filename == "" { + filename = "attachment" + } + contentType := "" + if content.Info != nil { + contentType = content.Info.MimeType + } + edit.Files = []*discordgo.File{{ + Name: filename, + ContentType: contentType, + Reader: bytes.NewReader(mediaData), + }} + attachments := []*discordgo.MessageAttachment{} + edit.Attachments = &attachments + return nil +} + +func (d *DiscordClient) webhookMessageEditThread(ctx context.Context, webhookID, token, messageID, threadID string, data *discordgo.WebhookEdit) (*discordgo.Message, error) { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + var response []byte + var err error + if len(data.Files) > 0 { + contentType, body, encodeErr := discordgo.MultipartBodyWithJSON(data, data.Files) + if encodeErr != nil { + return nil, encodeErr + } + response, err = d.Session.RequestRaw("PATCH", uri, contentType, body, uri, 0, discordgo.WithContext(ctx)) + } else { + response, err = d.Session.RequestWithBucketID("PATCH", uri, data, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + } + if err != nil { + return nil, err + } + var edited *discordgo.Message + err = discordgo.Unmarshal(response, &edited) + return edited, err +} + +func (d *DiscordClient) webhookMessageDeleteThread(ctx context.Context, webhookID, token, messageID, threadID string) error { + uri := webhookMessageThreadURI(webhookID, token, messageID, threadID) + _, err := d.Session.RequestWithBucketID("DELETE", uri, nil, discordgo.EndpointWebhookToken("", ""), discordgo.WithContext(ctx)) + return err +} + +func webhookMessageThreadURI(webhookID, token, messageID, threadID string) string { + uri := discordgo.EndpointWebhookMessage(webhookID, token, messageID) + if threadID == "" { + return uri + } + v := url.Values{} + v.Set("thread_id", threadID) + return uri + "?" + v.Encode() +} + +func (d *DiscordClient) isRelayWebhookMessage(portal *bridgev2.Portal, msg *database.Message) bool { + if msg == nil { + return false + } + meta := portal.Metadata.(*discordid.PortalMetadata) + return meta.RelayWebhookID != "" && meta.RelayWebhookToken != "" && string(msg.SenderID) == meta.RelayWebhookID +} diff --git a/pkg/connector/relaywebhook_test.go b/pkg/connector/relaywebhook_test.go new file mode 100644 index 00000000..52476949 --- /dev/null +++ b/pkg/connector/relaywebhook_test.go @@ -0,0 +1,215 @@ +package connector + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/bwmarrin/discordgo" +) + +func TestChooseRelayWebhookGhostMatch(t *testing.T) { + defaultAvatar := "https://cdn.discordapp.com/embed/avatars/0.png?size=256" + realAvatar := "https://cdn.discordapp.com/avatars/325703750469550084/avatar.png?size=256" + + tests := []struct { + name string + matches []relayWebhookGhostMatch + wantID string + }{ + { + name: "single default avatar match", + matches: []relayWebhookGhostMatch{{ + userID: "default", + name: "keith", + avatarURL: defaultAvatar, + }}, + wantID: "default", + }, + { + name: "one real avatar among webhook default avatar duplicates", + matches: []relayWebhookGhostMatch{ + {userID: "webhook1", name: "keith", avatarURL: defaultAvatar}, + {userID: "real", name: "keith", avatarURL: realAvatar}, + {userID: "webhook2", name: "keith", avatarURL: defaultAvatar}, + }, + wantID: "real", + }, + { + name: "ambiguous real avatars", + matches: []relayWebhookGhostMatch{ + {userID: "real1", name: "keith", avatarURL: realAvatar}, + {userID: "real2", name: "keith", avatarURL: strings.Replace(realAvatar, "avatar", "other", 1)}, + }, + }, + { + name: "ambiguous default avatars", + matches: []relayWebhookGhostMatch{ + {userID: "webhook1", name: "keith", avatarURL: defaultAvatar}, + {userID: "webhook2", name: "keith", avatarURL: defaultAvatar}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := chooseRelayWebhookGhostMatch(tc.matches) + if tc.wantID == "" { + if got != nil { + t.Fatalf("expected no match, got %#v", *got) + } + return + } + if got == nil { + t.Fatalf("expected match %q, got nil", tc.wantID) + } + if got.userID != tc.wantID { + t.Fatalf("unexpected match: got %q, want %q", got.userID, tc.wantID) + } + }) + } +} + +func TestPrependReplyEmbed(t *testing.T) { + embeds := []*discordgo.MessageEmbed{{Description: "existing"}} + got := prependReplyEmbed(embeds, "guild", "channel", "message") + if len(got) != 2 { + t.Fatalf("expected 2 embeds, got %d", len(got)) + } + if !strings.Contains(got[0].Description, "https://discord.com/channels/guild/channel/message") { + t.Fatalf("reply embed did not contain jump link: %q", got[0].Description) + } + if got[1] != embeds[0] { + t.Fatal("existing embeds were not preserved after reply embed") + } +} + +func TestPrependReplyEmbedSkipsMissingTarget(t *testing.T) { + embeds := []*discordgo.MessageEmbed{{Description: "existing"}} + got := prependReplyEmbed(embeds, "guild", "", "message") + if len(got) != 1 || got[0] != embeds[0] { + t.Fatalf("expected original embeds unchanged, got %#v", got) + } +} + +func TestRelayWebhookFlagsOnlyAllowsSuppressEmbeds(t *testing.T) { + flags := int(discordgo.MessageFlagsSuppressEmbeds | discordgo.MessageFlagsIsVoiceMessage | discordgo.MessageFlagsSuppressNotifications) + got := relayWebhookFlags(&flags) + if got != discordgo.MessageFlagsSuppressEmbeds { + t.Fatalf("unexpected flags: got %d, want %d", got, discordgo.MessageFlagsSuppressEmbeds) + } +} + +func TestSanitizeRelayWebhookUsername(t *testing.T) { + got := sanitizeRelayWebhookUsername(" Discord\nBridge ", "@user:example.com") + if strings.Contains(strings.ToLower(got), "discord") { + t.Fatalf("username still contains discord: %q", got) + } + if strings.Contains(got, "\n") { + t.Fatalf("username still contains control character: %q", got) + } + + long := strings.Repeat("a", 90) + got = sanitizeRelayWebhookUsername(long, "") + if len([]rune(got)) != 80 { + t.Fatalf("username was not truncated to 80 runes: got %d", len([]rune(got))) + } + + got = sanitizeRelayWebhookUsername(" \n\t ", "@user:example.com") + if got != "@user:example.com" { + t.Fatalf("unexpected fallback username: got %q", got) + } +} + +func TestWebhookMessageThreadURI(t *testing.T) { + got := webhookMessageThreadURI("webhook", "token", "message", "thread") + want := discordgo.EndpointWebhookMessage("webhook", "token", "message") + "?thread_id=thread" + if got != want { + t.Fatalf("unexpected URI:\n got: %s\nwant: %s", got, want) + } +} + +func TestIsRelayWebhookDiscordMessage(t *testing.T) { + tests := []struct { + name string + msg *discordgo.Message + want bool + }{ + { + name: "webhook id match", + msg: &discordgo.Message{WebhookID: "relay-webhook", Author: &discordgo.User{ID: "other"}}, + want: true, + }, + { + name: "author id match with webhook marker", + msg: &discordgo.Message{WebhookID: "some-webhook", Author: &discordgo.User{ID: "relay-webhook"}}, + want: true, + }, + { + name: "author match without webhook marker", + msg: &discordgo.Message{Author: &discordgo.User{ID: "relay-webhook"}}, + }, + { + name: "different webhook", + msg: &discordgo.Message{WebhookID: "other", Author: &discordgo.User{ID: "other"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isRelayWebhookDiscordMessage(tc.msg, "relay-webhook"); got != tc.want { + t.Fatalf("unexpected relay webhook classification: got %t, want %t", got, tc.want) + } + }) + } +} + +func TestIsStaleWebhookError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "unknown webhook code", + err: &discordgo.RESTError{ + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeUnknownWebhook}, + }, + want: true, + }, + { + name: "invalid webhook token code", + err: &discordgo.RESTError{ + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeInvalidWebhookTokenProvided}, + }, + want: true, + }, + { + name: "not found status", + err: &discordgo.RESTError{ + Response: &http.Response{StatusCode: http.StatusNotFound}, + }, + want: true, + }, + { + name: "other rest error", + err: &discordgo.RESTError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Message: &discordgo.APIErrorMessage{Code: discordgo.ErrCodeInvalidFormBody}, + }, + }, + { + name: "plain error", + err: errors.New("nope"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isStaleWebhookError(tc.err); got != tc.want { + t.Fatalf("unexpected stale-webhook classification: got %t, want %t", got, tc.want) + } + }) + } +} diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index d8b6ba3a..f68cc5fd 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -33,6 +33,9 @@ type PortalMetadata struct { // // This is omitted for guild space portals. ChannelType *discordgo.ChannelType `json:"channel_type,omitempty"` + + RelayWebhookID string `json:"relay_webhook_id,omitempty"` + RelayWebhookToken string `json:"relay_webhook_token,omitempty"` } type UserLoginMetadata struct { diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index c0a28cd1..e50bee96 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -120,10 +120,14 @@ func (mc *MessageConverter) ToDiscord( // // Since we only support real users at the moment, always ignore the // returned allowed mentions. - req.Content, _ = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) + var allowedMentions *discordgo.MessageAllowedMentions + req.Content, allowedMentions = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) if content.MsgType == event.MsgEmote { req.Content = fmt.Sprintf("_%s_", req.Content) } + if msg.OrigSender != nil { + req.AllowedMentions = allowedMentions + } } switch content.MsgType { @@ -155,7 +159,6 @@ func (mc *MessageConverter) ToDiscord( filename = "SPOILER_" + filename } - // TODO: Support attachments for relay/webhook. (A branch was removed here.) att := &discordgo.MessageAttachment{ ID: "0", Filename: filename, @@ -164,8 +167,6 @@ func (mc *MessageConverter) ToDiscord( att.OriginalContentType = content.Info.MimeType } if voiceMeta != nil { - flags := int(discordgo.MessageFlagsIsVoiceMessage) - req.Flags = &flags att.ContentType = voiceMeta.ContentType att.DurationSeconds = voiceMeta.DurationSeconds att.Waveform = voiceMeta.Waveform @@ -173,35 +174,47 @@ func (mc *MessageConverter) ToDiscord( // gets angry and returns 50160 "Voice messages must have a single // audio attachment". att.Filename = "voice-message" + voiceAttachmentExtension(voiceMeta.ContentType) + if msg.OrigSender == nil { + flags := int(discordgo.MessageFlagsIsVoiceMessage) + req.Flags = &flags + } + } + + if msg.OrigSender != nil { + req.Files = append(req.Files, &discordgo.File{ + Name: att.Filename, + ContentType: att.ContentType, + Reader: bytes.NewReader(mediaData), + }) + } else { + uploadID := mc.NextDiscordUploadID() + log.Debug().Str("upload_id", uploadID).Msg("Preparing attachment") + filePrep := &discordgo.FilePrepare{ + Size: len(mediaData), + Name: att.Filename, + ID: uploadID, + OriginalContentType: att.OriginalContentType, + } + prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ + Files: []*discordgo.FilePrepare{filePrep}, + }, refererOpt) + + if err != nil { + log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") + return nil, bridgev2.ErrMediaReuploadFailed + } + + prepared := prep.Attachments[0] + att.UploadedFilename = prepared.UploadFilename + + err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) + if err != nil { + log.Err(err).Msg("Failed to reupload Discord attachment after preparing") + return nil, bridgev2.ErrMediaReuploadFailed + } + + req.Attachments = append(req.Attachments, att) } - - uploadID := mc.NextDiscordUploadID() - log.Debug().Str("upload_id", uploadID).Msg("Preparing attachment") - filePrep := &discordgo.FilePrepare{ - Size: len(mediaData), - Name: att.Filename, - ID: uploadID, - OriginalContentType: att.OriginalContentType, - } - prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ - Files: []*discordgo.FilePrepare{filePrep}, - }, refererOpt) - - if err != nil { - log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") - return nil, bridgev2.ErrMediaReuploadFailed - } - - prepared := prep.Attachments[0] - att.UploadedFilename = prepared.UploadFilename - - err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) - if err != nil { - log.Err(err).Msg("Failed to reupload Discord attachment after preparing") - return nil, bridgev2.ErrMediaReuploadFailed - } - - req.Attachments = append(req.Attachments, att) } return &req, nil diff --git a/pkg/msgconv/from_matrix_test.go b/pkg/msgconv/from_matrix_test.go new file mode 100644 index 00000000..78ec313b --- /dev/null +++ b/pkg/msgconv/from_matrix_test.go @@ -0,0 +1,60 @@ +package msgconv + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func TestToDiscordRelayedTextKeepsRestrictiveAllowedMentions(t *testing.T) { + mc := NewMessageConverter(nil) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "@everyone <@123456789012345678>", + } + msg := &bridgev2.MatrixMessage{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Event: &event.Event{Content: event.Content{Raw: map[string]any{}}}, + Content: content, + OrigSender: &bridgev2.OrigSender{}, + }, + } + + got, err := mc.ToDiscord(context.Background(), nil, msg, "channel", nil) + if err != nil { + t.Fatalf("ToDiscord() failed: %v", err) + } + if got.AllowedMentions == nil { + t.Fatal("expected relayed text to include restrictive allowed_mentions") + } + if len(got.AllowedMentions.Parse) != 0 { + t.Fatalf("expected no mention parse types, got %#v", got.AllowedMentions.Parse) + } + if len(got.AllowedMentions.Users) != 0 { + t.Fatalf("expected no explicitly allowed users, got %#v", got.AllowedMentions.Users) + } +} + +func TestToDiscordNormalTextLeavesAllowedMentionsUnset(t *testing.T) { + mc := NewMessageConverter(nil) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "hello", + } + msg := &bridgev2.MatrixMessage{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Event: &event.Event{Content: event.Content{Raw: map[string]any{}}}, + Content: content, + }, + } + + got, err := mc.ToDiscord(context.Background(), nil, msg, "channel", nil) + if err != nil { + t.Fatalf("ToDiscord() failed: %v", err) + } + if got.AllowedMentions != nil { + t.Fatalf("expected normal puppet send to keep old nil allowed_mentions behavior, got %#v", got.AllowedMentions) + } +}