github.com/beeper/discordgo v0.0.0-20260215125047-ccf8cbaa0a9f
+replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec
diff --git a/go.sum b/go.sum
index dc97b3a..c52c58a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,41 +1,45 @@
-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/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=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I=
+github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
+github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
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.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=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
-github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
+github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
+github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
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.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/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=
@@ -46,25 +50,27 @@ 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.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.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=
+github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4=
+go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ=
+go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
+go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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=
@@ -73,7 +79,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/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.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
-maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
+maunium.net/go/mautrix v0.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g=
+maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo=
diff --git a/guildportal.go b/guildportal.go
deleted file mode 100644
index d7be670..0000000
--- a/guildportal.go
+++ /dev/null
@@ -1,335 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 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 main
-
-import (
- "errors"
- "fmt"
- "sync"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/maulogger/v2/maulogadapt"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "github.com/bwmarrin/discordgo"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-type Guild struct {
- *database.Guild
-
- bridge *DiscordBridge
- log log.Logger
-
- roomCreateLock sync.Mutex
-}
-
-func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild {
- if dbGuild == nil {
- if id == "" || !createIfNotExist {
- return nil
- }
-
- dbGuild = br.DB.Guild.New()
- dbGuild.ID = id
- dbGuild.Insert()
- }
-
- guild := br.NewGuild(dbGuild)
-
- br.guildsByID[guild.ID] = guild
- if guild.MXID != "" {
- br.guildsByMXID[guild.MXID] = guild
- }
-
- return guild
-}
-
-func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- portal, ok := br.guildsByMXID[mxid]
- if !ok {
- return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false)
- }
-
- return portal
-}
-
-func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- guild, ok := br.guildsByID[id]
- if !ok {
- return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist)
- }
-
- return guild
-}
-
-func (br *DiscordBridge) GetAllGuilds() []*Guild {
- return br.dbGuildsToGuilds(br.DB.Guild.GetAll())
-}
-
-func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- output := make([]*Guild, len(dbGuilds))
- for index, dbGuild := range dbGuilds {
- if dbGuild == nil {
- continue
- }
-
- guild, ok := br.guildsByID[dbGuild.ID]
- if !ok {
- guild = br.loadGuild(dbGuild, "", false)
- }
-
- output[index] = guild
- }
-
- return output
-}
-
-func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild {
- guild := &Guild{
- Guild: dbGuild,
- bridge: br,
- log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)),
- }
-
- return guild
-}
-
-func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) {
- bridgeInfo := event.BridgeEventContent{
- BridgeBot: guild.bridge.Bot.UserID,
- Creator: guild.bridge.Bot.UserID,
- Protocol: event.BridgeInfoSection{
- ID: "discordgo",
- DisplayName: "Discord",
- AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
- ExternalURL: "https://discord.com/",
- },
- Channel: event.BridgeInfoSection{
- ID: guild.ID,
- DisplayName: guild.Name,
- AvatarURL: guild.AvatarURL.CUString(),
- },
- }
- bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID)
- return bridgeInfoStateKey, bridgeInfo
-}
-
-func (guild *Guild) UpdateBridgeInfo() {
- if len(guild.MXID) == 0 {
- guild.log.Debugln("Not updating bridge info: no Matrix room created")
- return
- }
- guild.log.Debugln("Updating bridge info...")
- stateKey, content := guild.getBridgeInfo()
- _, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content)
- if err != nil {
- guild.log.Warnln("Failed to update m.bridge:", err)
- }
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- _, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content)
- if err != nil {
- guild.log.Warnln("Failed to update uk.half-shot.bridge:", err)
- }
-}
-
-func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
- guild.roomCreateLock.Lock()
- defer guild.roomCreateLock.Unlock()
- if guild.MXID != "" {
- return nil
- }
- guild.log.Infoln("Creating Matrix room for guild")
- guild.UpdateInfo(user, meta)
-
- bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo()
-
- initialState := []*event.Event{{
- Type: event.StateBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }, {
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- Type: event.StateHalfShotBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }}
-
- if !guild.AvatarURL.IsEmpty() {
- initialState = append(initialState, &event.Event{
- Type: event.StateRoomAvatar,
- Content: event.Content{Parsed: &event.RoomAvatarEventContent{
- URL: guild.AvatarURL,
- }},
- })
- }
-
- creationContent := map[string]interface{}{
- "type": event.RoomTypeSpace,
- }
- if !guild.bridge.Config.Bridge.FederateRooms {
- creationContent["m.federate"] = false
- }
-
- resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: guild.Name,
- Preset: "private_chat",
- InitialState: initialState,
- CreationContent: creationContent,
- RoomVersion: "11",
- })
- if err != nil {
- guild.log.Warnln("Failed to create room:", err)
- return err
- }
-
- guild.MXID = resp.RoomID
- guild.NameSet = true
- guild.AvatarSet = !guild.AvatarURL.IsEmpty()
- guild.Update()
- guild.bridge.guildsLock.Lock()
- guild.bridge.guildsByMXID[guild.MXID] = guild
- guild.bridge.guildsLock.Unlock()
- guild.log.Infoln("Matrix room created:", guild.MXID)
-
- user.ensureInvited(nil, guild.MXID, false, true)
-
- return nil
-}
-
-func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
- if meta.Unavailable {
- guild.log.Debugfln("Ignoring unavailable guild update")
- return meta
- }
- changed := false
- changed = guild.UpdateName(meta) || changed
- changed = guild.UpdateAvatar(meta.Icon) || changed
- if changed {
- guild.UpdateBridgeInfo()
- guild.Update()
- }
- source.ensureInvited(nil, guild.MXID, false, false)
- return meta
-}
-
-func (guild *Guild) UpdateName(meta *discordgo.Guild) bool {
- name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{
- Name: meta.Name,
- })
- if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") {
- return false
- }
- guild.log.Debugfln("Updating name %q -> %q", guild.Name, name)
- guild.Name = name
- guild.PlainName = meta.Name
- guild.NameSet = false
- if guild.MXID != "" {
- _, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name)
- if err != nil {
- guild.log.Warnln("Failed to update room name: %s", err)
- } else {
- guild.NameSet = true
- }
- }
- return true
-}
-
-func (guild *Guild) UpdateAvatar(iconID string) bool {
- if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") {
- return false
- }
- guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID)
- guild.AvatarSet = false
- guild.Avatar = iconID
- guild.AvatarURL = id.ContentURI{}
- if guild.Avatar != "" {
- // TODO direct media support
- copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{
- AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID),
- })
- if err != nil {
- guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
- return true
- }
- guild.AvatarURL = copied.MXC
- }
- if guild.MXID != "" {
- _, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
- if err != nil {
- guild.log.Warnln("Failed to update room avatar:", err)
- } else {
- guild.AvatarSet = true
- }
- }
- return true
-}
-
-func (guild *Guild) cleanup() {
- if guild.MXID == "" {
- return
- }
- intent := guild.bridge.Bot
- if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
- err := intent.BeeperDeleteRoom(guild.MXID)
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
- }
- return
- }
- guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
-}
-
-func (guild *Guild) RemoveMXID() {
- guild.bridge.guildsLock.Lock()
- defer guild.bridge.guildsLock.Unlock()
- if guild.MXID == "" {
- return
- }
- delete(guild.bridge.guildsByMXID, guild.MXID)
- guild.MXID = ""
- guild.AvatarSet = false
- guild.NameSet = false
- guild.BridgingMode = database.GuildBridgeNothing
- guild.Update()
-}
-
-func (guild *Guild) Delete() {
- guild.Guild.Delete()
- guild.bridge.guildsLock.Lock()
- delete(guild.bridge.guildsByID, guild.ID)
- if guild.MXID != "" {
- delete(guild.bridge.guildsByMXID, guild.MXID)
- }
- guild.bridge.guildsLock.Unlock()
-
-}
diff --git a/main.go b/main.go
deleted file mode 100644
index a9beca7..0000000
--- a/main.go
+++ /dev/null
@@ -1,208 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 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 main
-
-import (
- _ "embed"
- "net/http"
- "sync"
-
- "go.mau.fi/util/configupgrade"
- "go.mau.fi/util/exsync"
- "golang.org/x/sync/semaphore"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/commands"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-// Information to find out exactly which commit the bridge was built from.
-// These are filled at build time with the -X linker flag.
-var (
- Tag = "unknown"
- Commit = "unknown"
- BuildTime = "unknown"
-)
-
-//go:embed example-config.yaml
-var ExampleConfig string
-
-type DiscordBridge struct {
- bridge.Bridge
-
- Config *config.Config
- DB *database.Database
-
- DMA *DirectMediaAPI
- provisioning *ProvisioningAPI
-
- usersByMXID map[id.UserID]*User
- usersByID map[string]*User
- usersLock sync.Mutex
-
- managementRooms map[id.RoomID]*User
- managementRoomsLock sync.Mutex
-
- portalsByMXID map[id.RoomID]*Portal
- portalsByID map[database.PortalKey]*Portal
- portalsLock sync.Mutex
-
- threadsByID map[string]*Thread
- threadsByRootMXID map[id.EventID]*Thread
- threadsByCreationNoticeMXID map[id.EventID]*Thread
- threadsLock sync.Mutex
-
- guildsByMXID map[id.RoomID]*Guild
- guildsByID map[string]*Guild
- guildsLock sync.Mutex
-
- puppets map[string]*Puppet
- puppetsByCustomMXID map[id.UserID]*Puppet
- puppetsLock sync.Mutex
-
- attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
- parallelAttachmentSemaphore *semaphore.Weighted
-}
-
-func (br *DiscordBridge) GetExampleConfig() string {
- return ExampleConfig
-}
-
-func (br *DiscordBridge) GetConfigPtr() interface{} {
- br.Config = &config.Config{
- BaseConfig: &br.Bridge.Config,
- }
- br.Config.BaseConfig.Bridge = &br.Config.Bridge
- return br.Config
-}
-
-func (br *DiscordBridge) Init() {
- br.CommandProcessor = commands.NewProcessor(&br.Bridge)
- br.RegisterCommands()
- br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
-
- matrixHTMLParser.PillConverter = br.pillConverter
-
- br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
- discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
-}
-
-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()
-}
-
-func (br *DiscordBridge) Stop() {
- for _, user := range br.usersByMXID {
- if user.Session == nil {
- continue
- }
-
- br.Log.Debugln("Disconnecting", user.MXID)
- user.Session.Close()
- }
-}
-
-func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
- p := br.GetPortalByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
- p := br.GetUserByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
- _, isGhost := br.ParsePuppetMXID(mxid)
- return isGhost
-}
-
-func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
- p := br.GetPuppetByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
- //TODO implement
-}
-
-func main() {
- br := &DiscordBridge{
- usersByMXID: make(map[id.UserID]*User),
- usersByID: make(map[string]*User),
-
- managementRooms: make(map[id.RoomID]*User),
-
- portalsByMXID: make(map[id.RoomID]*Portal),
- portalsByID: make(map[database.PortalKey]*Portal),
-
- threadsByID: make(map[string]*Thread),
- threadsByRootMXID: make(map[id.EventID]*Thread),
- threadsByCreationNoticeMXID: make(map[id.EventID]*Thread),
-
- guildsByID: make(map[string]*Guild),
- guildsByMXID: make(map[id.RoomID]*Guild),
-
- puppets: make(map[string]*Puppet),
- puppetsByCustomMXID: make(map[id.UserID]*Puppet),
-
- attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
- parallelAttachmentSemaphore: semaphore.NewWeighted(3),
- }
- br.Bridge = bridge.Bridge{
- Name: "mautrix-discord",
- URL: "https://github.com/mautrix/discord",
- Description: "A Matrix-Discord puppeting bridge.",
- Version: "0.7.6",
- ProtocolName: "Discord",
- BeeperServiceName: "discordgo",
- BeeperNetworkName: "discord",
-
- CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
-
- ConfigUpgrader: &configupgrade.StructUpgrader{
- SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
- Blocks: config.SpacedBlocks,
- Base: ExampleConfig,
- },
-
- Child: br,
- }
- br.InitVersion(Tag, Commit, BuildTime)
-
- br.Main()
-}
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
new file mode 100644
index 0000000..b589098
--- /dev/null
+++ b/pkg/connector/backfill.go
@@ -0,0 +1,123 @@
+// 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 (
+ "context"
+ "slices"
+ "strconv"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var (
+ _ bridgev2.BackfillingNetworkAPI = (*DiscordClient)(nil)
+)
+
+func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
+ if !dc.IsLoggedIn() {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ channelID := discordid.ParsePortalID(fetchParams.Portal.ID)
+ log := zerolog.Ctx(ctx).With().
+ Str("channel_id", channelID).
+ Int("desired_count", fetchParams.Count).
+ Bool("forward", fetchParams.Forward).Logger()
+
+ var beforeID string
+ var afterID string
+
+ if fetchParams.AnchorMessage != nil {
+ anchorID := discordid.ParseMessageID(fetchParams.AnchorMessage.ID)
+
+ if fetchParams.Forward {
+ afterID = anchorID
+ } else {
+ beforeID = anchorID
+ }
+ }
+
+ // ChannelMessages returns messages ordered from newest to oldest.
+ count := min(fetchParams.Count, 100)
+ log.Debug().Msg("Fetching channel history for backfill")
+ msgs, err := dc.Session.ChannelMessages(channelID, count, beforeID, afterID, "")
+ if err != nil {
+ dc.handlePossible40002(err)
+ return nil, err
+ }
+
+ converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
+ for _, msg := range msgs {
+ streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)
+ ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
+
+ // NOTE: For now, we aren't backfilling reactions. This is because:
+ //
+ // - Discord does not provide enough historical reaction data in the
+ // response from the message history endpoint to construct valid
+ // BackfillReactions.
+ // - Fetching the reaction data would be prohibitively expensive for
+ // messages with many reactions. Messages in large guilds can have
+ // tens of thousands of reactions.
+ // - Indicating aggregated child events[1] from BackfillMessage doesn't
+ // seem possible due to how portal backfilling batching currently
+ // works.
+ //
+ // [1]: https://spec.matrix.org/v1.16/client-server-api/#reference-relations
+ //
+ // It might be worth fetching the reaction data anyways if we observe
+ // a small overall number of reactions.
+ sender := dc.makeEventSender(msg.Author)
+
+ // Use the ghost's intent, falling back to the bridge's.
+ ghost, err := dc.connector.Bridge.GetGhostByID(ctx, sender.Sender)
+ if err != nil {
+ log.Err(err).Msg("Failed to look up ghost while converting backfilled message")
+ }
+ var intent bridgev2.MatrixAPI
+ if ghost == nil {
+ intent = fetchParams.Portal.Bridge.Bot
+ } else {
+ intent = ghost.Intent
+ }
+
+ converted = append(converted, &bridgev2.BackfillMessage{
+ ID: discordid.MakeMessageID(msg.ID),
+ ConvertedMessage: dc.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, dc.Session, msg),
+ Sender: sender,
+ Timestamp: ts,
+ StreamOrder: streamOrder,
+ })
+ }
+ // FetchMessagesResponse expects messages to always be ordered from oldest to newest.
+ slices.Reverse(converted)
+
+ log.Debug().Int("converted_count", len(converted)).Msg("Finished fetching and converting, returning backfill response")
+
+ return &bridgev2.FetchMessagesResponse{
+ Messages: converted,
+ Forward: fetchParams.Forward,
+ // This might not actually be true if the channel's total number of messages is itself a multiple
+ // of `count`, but that's probably okay.
+ HasMore: len(msgs) == count,
+ }, nil
+}
diff --git a/pkg/connector/bridge_state.go b/pkg/connector/bridge_state.go
new file mode 100644
index 0000000..bd21f86
--- /dev/null
+++ b/pkg/connector/bridge_state.go
@@ -0,0 +1,153 @@
+// 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 (
+ "errors"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/status"
+)
+
+const (
+ DiscordNotLoggedIn status.BridgeStateErrorCode = "dc-not-logged-in"
+ DiscordTransientDisconnect status.BridgeStateErrorCode = "dc-transient-disconnect"
+ DiscordInvalidAuth status.BridgeStateErrorCode = "dc-websocket-disconnect-4004"
+ DiscordHTTP40002 status.BridgeStateErrorCode = "dc-http-40002"
+ DiscordUnknownWebsocketErr status.BridgeStateErrorCode = "dc-unknown-websocket-error"
+)
+
+const discordDisconnectDebounce = 7 * time.Second
+
+func init() {
+ status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
+ DiscordNotLoggedIn: "You're not logged into Discord. Relogin to continue using the bridge.",
+ DiscordTransientDisconnect: "Temporarily disconnected from Discord, trying to reconnect.",
+ DiscordInvalidAuth: "Discord access token is no longer valid, please log in again.",
+ DiscordHTTP40002: "Discord requires a verified account, please verify and log in again.",
+ DiscordUnknownWebsocketErr: "Unknown Discord websocket error.",
+ })
+}
+
+func (d *DiscordClient) resetBridgeStateTracking() {
+ d.bridgeStateLock.Lock()
+ if d.disconnectTimer != nil {
+ d.disconnectTimer.Stop()
+ d.disconnectTimer = nil
+ }
+ d.invalidAuthDetected = false
+ d.bridgeStateLock.Unlock()
+}
+
+func (d *DiscordClient) markConnected() {
+ if d.UserLogin == nil {
+ return
+ }
+ d.bridgeStateLock.Lock()
+ if d.disconnectTimer != nil {
+ d.disconnectTimer.Stop()
+ d.disconnectTimer = nil
+ }
+ d.invalidAuthDetected = false
+ d.bridgeStateLock.Unlock()
+ d.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
+}
+
+func (d *DiscordClient) markInvalidAuth(message string) {
+ if d.UserLogin == nil {
+ return
+ }
+ d.bridgeStateLock.Lock()
+ d.invalidAuthDetected = true
+ if d.disconnectTimer != nil {
+ d.disconnectTimer.Stop()
+ d.disconnectTimer = nil
+ }
+ d.bridgeStateLock.Unlock()
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: DiscordInvalidAuth,
+ Message: message,
+ UserAction: status.UserActionRelogin,
+ })
+}
+
+func (d *DiscordClient) scheduleTransientDisconnect(message string) {
+ if d.UserLogin == nil {
+ return
+ }
+ d.bridgeStateLock.Lock()
+ if d.invalidAuthDetected {
+ d.bridgeStateLock.Unlock()
+ return
+ }
+ if d.disconnectTimer != nil {
+ d.disconnectTimer.Stop()
+ }
+ login := d.UserLogin
+ d.disconnectTimer = time.AfterFunc(discordDisconnectDebounce, func() {
+ d.bridgeStateLock.Lock()
+ d.disconnectTimer = nil
+ invalidAuth := d.invalidAuthDetected
+ d.bridgeStateLock.Unlock()
+ if invalidAuth {
+ return
+ }
+ login.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateTransientDisconnect,
+ Error: DiscordTransientDisconnect,
+ Message: message,
+ })
+ })
+ d.bridgeStateLock.Unlock()
+}
+
+func (d *DiscordClient) sendConnectFailure(err error, final bool) {
+ if d.UserLogin == nil || err == nil {
+ return
+ }
+ stateEvent := status.StateTransientDisconnect
+ if final {
+ stateEvent = status.StateUnknownError
+ }
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: stateEvent,
+ Error: DiscordUnknownWebsocketErr,
+ Message: err.Error(),
+ Info: map[string]any{
+ "go_error": err.Error(),
+ },
+ })
+}
+
+func (d *DiscordClient) handlePossible40002(err error) bool {
+ var restErr *discordgo.RESTError
+ if !errors.As(err, &restErr) || restErr.Message == nil || restErr.Message.Code != discordgo.ErrCodeActionRequiredVerifiedAccount {
+ return false
+ }
+ if d.UserLogin == nil {
+ return true
+ }
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: DiscordHTTP40002,
+ Message: restErr.Message.Message,
+ UserAction: status.UserActionRelogin,
+ })
+ return true
+}
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
new file mode 100644
index 0000000..d0168e2
--- /dev/null
+++ b/pkg/connector/capabilities.go
@@ -0,0 +1,147 @@
+// 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 (
+ "context"
+
+ "go.mau.fi/util/ffmpeg"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+)
+
+var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
+ Provisioning: bridgev2.ProvisioningCapabilities{
+ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{},
+ GroupCreation: map[string]bridgev2.GroupTypeCapabilities{},
+ },
+}
+
+func (dc *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
+ return DiscordGeneralCaps
+}
+
+func (wa *DiscordConnector) GetBridgeInfoVersion() (info, caps int) {
+ return 1, 1
+}
+
+/*func supportedIfFFmpeg() event.CapabilitySupportLevel {
+ if ffmpeg.Supported() {
+ return event.CapLevelPartialSupport
+ }
+ return event.CapLevelRejected
+}*/
+
+func capID() string {
+ base := "fi.mau.discord.capabilities.2025_11_20"
+ if ffmpeg.Supported() {
+ return base + "+ffmpeg"
+ }
+ return base
+}
+
+// TODO: This limit is increased depending on user subscription status (Discord Nitro).
+const MaxTextLength = 2000
+
+// TODO: This limit is increased depending on user subscription status (Discord Nitro).
+// TODO: Verify this figure (10 MiB).
+const MaxFileSize = 10485760
+
+var discordCaps = &event.RoomFeatures{
+ ID: capID(),
+ Reply: event.CapLevelFullySupported,
+ Reaction: event.CapLevelFullySupported,
+ Delete: event.CapLevelFullySupported,
+ Formatting: event.FormattingFeatureMap{
+ event.FmtBold: event.CapLevelFullySupported,
+ event.FmtItalic: event.CapLevelFullySupported,
+ event.FmtStrikethrough: event.CapLevelFullySupported,
+ event.FmtInlineCode: event.CapLevelFullySupported,
+ event.FmtCodeBlock: event.CapLevelFullySupported,
+ event.FmtSyntaxHighlighting: event.CapLevelFullySupported,
+ event.FmtBlockquote: event.CapLevelFullySupported,
+ event.FmtInlineLink: event.CapLevelFullySupported,
+ event.FmtUserLink: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtUnorderedList: event.CapLevelFullySupported,
+ event.FmtOrderedList: event.CapLevelFullySupported,
+ event.FmtListStart: event.CapLevelFullySupported,
+ event.FmtListJumpValue: event.CapLevelUnsupported,
+ event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support.
+ },
+ File: event.FileFeatureMap{
+ event.MsgImage: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "image/jpeg": event.CapLevelFullySupported,
+ "image/png": event.CapLevelFullySupported,
+ "image/gif": event.CapLevelFullySupported,
+ "image/webp": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgVideo: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "video/mp4": event.CapLevelFullySupported,
+ "video/webm": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgAudio: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "audio/mpeg": event.CapLevelFullySupported,
+ "audio/webm": event.CapLevelFullySupported,
+ "audio/wav": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgFile: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "*/*": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.CapMsgGIF: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "image/gif": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ // TODO: Support voice messages.
+ },
+ LocationMessage: event.CapLevelUnsupported,
+ MaxTextLength: MaxTextLength,
+ // TODO: Support reactions.
+ // TODO: Support threads.
+ // TODO: Support editing.
+ // TODO: Support message deletion.
+}
+
+func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
+ return discordCaps
+}
diff --git a/database/upgrades/upgrades.go b/pkg/connector/chatinfo.go
similarity index 74%
rename from database/upgrades/upgrades.go
rename to pkg/connector/chatinfo.go
index d6954d5..449460c 100644
--- a/database/upgrades/upgrades.go
+++ b/pkg/connector/chatinfo.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// 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
@@ -14,19 +14,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package upgrades
+package connector
import (
- "embed"
+ "context"
- "go.mau.fi/util/dbutil"
+ "maunium.net/go/mautrix/bridgev2"
)
-var Table dbutil.UpgradeTable
-
-//go:embed *.sql
-var rawUpgrades embed.FS
-
-func init() {
- Table.RegisterFS(rawUpgrades)
+func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
+ //TODO implement me
+ panic("implement me")
}
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
new file mode 100644
index 0000000..0c81dd4
--- /dev/null
+++ b/pkg/connector/client.go
@@ -0,0 +1,467 @@
+// 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 (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/bridgev2/status"
+
+ "go.mau.fi/util/ptr"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type DiscordClient struct {
+ connector *DiscordConnector
+ usersFromReady map[string]*discordgo.User
+ UserLogin *bridgev2.UserLogin
+ Session *discordgo.Session
+
+ hasBegunSyncing bool
+
+ markedOpened map[string]time.Time
+ markedOpenedLock sync.Mutex
+
+ bridgeStateLock sync.Mutex
+ disconnectTimer *time.Timer
+ invalidAuthDetected bool
+}
+
+func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
+ meta := login.Metadata.(*discordid.UserLoginMetadata)
+
+ session, err := NewDiscordSession(ctx, meta.Token)
+ login.Save(ctx)
+
+ if err != nil {
+ return err
+ }
+
+ cl := DiscordClient{
+ connector: d,
+ UserLogin: login,
+ Session: session,
+ }
+ cl.SetUp(ctx, meta)
+
+ login.Client = &cl
+
+ return nil
+}
+
+var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
+
+// SetUp performs basic bookkeeping and initialization that should be done
+// immediately after a DiscordClient has been created.
+//
+// nil may be passed for meta, especially during provisioning where we need to
+// connect to the Discord gateway, but don't have a UserLogin yet.
+func (d *DiscordClient) SetUp(ctx context.Context, meta *discordid.UserLoginMetadata) {
+ // TODO: Turn this into a factory function like `NewDiscordClient`.
+ log := zerolog.Ctx(ctx)
+
+ // We'll have UserLogin metadata if this UserLogin is being loaded from the
+ // database, i.e. it hasn't just been provisioned.
+ if meta != nil {
+ if meta.HeartbeatSession.IsExpired() {
+ log.Info().Msg("Heartbeat session expired, creating a new one")
+ meta.HeartbeatSession = discordgo.NewHeartbeatSession()
+ }
+ meta.HeartbeatSession.BumpLastUsed()
+ d.Session.HeartbeatSession = meta.HeartbeatSession
+ }
+
+ d.markedOpened = make(map[string]time.Time)
+ d.resetBridgeStateTracking()
+}
+
+func (d *DiscordClient) Connect(ctx context.Context) {
+ log := zerolog.Ctx(ctx)
+
+ if d.Session == nil {
+ log.Error().Msg("No session present")
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: DiscordNotLoggedIn,
+ UserAction: status.UserActionRelogin,
+ })
+ return
+ }
+
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateConnecting,
+ })
+ d.connectWithRetry(ctx, 0)
+}
+
+func (cl *DiscordClient) handleDiscordEventSync(event any) {
+ go cl.handleDiscordEvent(event)
+}
+
+func (cl *DiscordClient) connect(ctx context.Context) error {
+ log := zerolog.Ctx(ctx)
+ log.Info().Msg("Opening session")
+
+ cl.Session.EventHandler = cl.handleDiscordEventSync
+
+ err := cl.Session.Open()
+ for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 {
+ log.Err(err).Int("attempts", attempts).Msg("Immediately disconnected while trying to open session, trying again in 5 seconds")
+ time.Sleep(5 * time.Second)
+ err = cl.Session.Open()
+ }
+ if err != nil {
+ log.Err(err).Msg("Failed to connect to Discord")
+ return err
+ }
+
+ // Ensure that we actually have a user.
+ if !cl.IsLoggedIn() {
+ err := fmt.Errorf("unknown identity even after connecting to Discord")
+ log.Err(err).Msg("No Discord user available after connecting")
+ return err
+ }
+ user := cl.Session.State.User
+ log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
+
+ // Stash all of the users we received in READY so we can perform quick lookups
+ // keyed by user ID.
+ cl.usersFromReady = make(map[string]*discordgo.User)
+ for _, user := range cl.Session.State.Ready.Users {
+ cl.usersFromReady[user.ID] = user
+ }
+
+ // NOTE: We won't have a UserLogin during provisioning, because the UserLogin
+ // can only be properly constructed once we know what the Discord user ID is
+ // (i.e. we have returned from this function). We'll rely on the login
+ // process calling this method manually instead.
+ cl.BeginSyncingIfUserLoginPresent(ctx)
+
+ return nil
+}
+
+func (d *DiscordClient) connectWithRetry(ctx context.Context, retryCount int) {
+ err := d.connect(ctx)
+ if err == nil || ctx.Err() != nil {
+ return
+ }
+ if retryCount < 6 {
+ d.sendConnectFailure(err, false)
+ retryInSeconds := 2 << retryCount
+ zerolog.Ctx(ctx).Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
+ select {
+ case <-time.After(time.Duration(retryInSeconds) * time.Second):
+ case <-ctx.Done():
+ zerolog.Ctx(ctx).Info().Msg("Context canceled, exiting connect retry loop")
+ return
+ }
+ d.connectWithRetry(ctx, retryCount+1)
+ } else {
+ d.sendConnectFailure(err, true)
+ }
+}
+
+func (d *DiscordClient) Disconnect() {
+ d.UserLogin.Log.Info().Msg("Disconnecting session")
+ d.Session.Close()
+}
+
+func (d *DiscordClient) IsLoggedIn() bool {
+ return d.Session != nil && d.Session.State != nil && d.Session.State.User != nil && d.Session.State.User.ID != ""
+}
+
+func (d *DiscordClient) LogoutRemote(ctx context.Context) {
+ // FIXME(skip): Implement.
+ d.Disconnect()
+}
+
+func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) {
+ if cl.UserLogin == nil {
+ cl.connector.Bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin")
+ return
+ }
+ if cl.hasBegunSyncing {
+ cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
+ return
+ }
+ cl.hasBegunSyncing = true
+
+ log := cl.UserLogin.Log
+ user := cl.Session.State.User
+
+ // FIXME(skip): Avatar.
+ cl.UserLogin.RemoteProfile = status.RemoteProfile{
+ Email: user.Email,
+ Phone: user.Phone,
+ Name: user.String(),
+ }
+ if err := cl.UserLogin.Save(ctx); err != nil {
+ log.Err(err).Msg("Couldn't save UserLogin after connecting")
+ }
+
+ go cl.syncPrivateChannels(ctx)
+ go cl.syncGuilds(ctx)
+}
+
+func (d *DiscordClient) syncPrivateChannels(ctx context.Context) {
+ dms := slices.Clone(d.Session.State.PrivateChannels)
+ // Only sync the top n private channels with recent activity.
+ slices.SortFunc(dms, func(a, b *discordgo.Channel) int {
+ ats, _ := discordgo.SnowflakeTimestamp(a.LastMessageID)
+ bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID)
+ return bts.Compare(ats)
+ })
+
+ // TODO(skip): This is startup_private_channel_create_limit. Support this in the config.
+ maxDms := 10
+ if maxDms > len(dms) {
+ maxDms = len(dms)
+ }
+ for _, dm := range dms[:maxDms] {
+ zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity")
+ d.syncChannel(ctx, dm)
+ }
+}
+
+func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Channel) bool {
+ log := zerolog.Ctx(ctx).With().
+ Str("channel_id", ch.ID).
+ Int("channel_type", int(ch.Type)).
+ Str("action", "determine guild channel visbility").Logger()
+
+ sess := d.Session
+ myDiscordUserID := d.Session.State.User.ID
+
+ // To calculate guild channel visibility we need to know our effective permission
+ // bitmask, which can only be truly determined when we know which roles we have
+ // in the guild.
+ //
+ // To this end, make sure we have detailed information about ourselves in the
+ // cache ("state").
+
+ _, err := sess.State.Member(ch.GuildID, myDiscordUserID)
+ if errors.Is(err, discordgo.ErrStateNotFound) {
+ log.Debug().Msg("Fetching own membership in guild to check roles")
+
+ member, err := sess.GuildMember(ch.GuildID, myDiscordUserID)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
+ } else {
+ err = sess.State.MemberAdd(member)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
+ }
+ }
+ } else if err != nil {
+ log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
+ }
+
+ err = sess.State.ChannelAdd(ch)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to add channel to cache")
+ }
+
+ perms, err := sess.State.UserChannelPermissions(myDiscordUserID, ch.ID)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
+ return true
+ }
+
+ canView := perms&discordgo.PermissionViewChannel > 0
+ log.Debug().
+ Int64("permissions", perms).
+ Bool("channel_visible", canView).
+ Msg("Computed visibility of guild channel")
+ return canView
+}
+
+func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey {
+ // TODO: Support configuring `split_portals`.
+ return networkid.PortalKey{
+ ID: discordid.MakeGuildPortalID(guildID),
+ Receiver: d.UserLogin.ID,
+ }
+}
+
+func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar {
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(guild.Icon),
+ Get: func(ctx context.Context) ([]byte, error) {
+ url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
+ return simpleDownload(ctx, url, "guild icon")
+ },
+ Remove: guild.Icon == "",
+ }
+}
+
+func (d *DiscordClient) syncGuildSpace(ctx context.Context, guild *discordgo.Guild) error {
+ prt, err := d.connector.Bridge.GetPortalByKey(ctx, d.guildPortalKeyFromID(guild.ID))
+ if err != nil {
+ return fmt.Errorf("couldn't get/create portal corresponding to guild: %w", err)
+ }
+
+ selfEvtSender := d.selfEventSender()
+ info := &bridgev2.ChatInfo{
+ Name: &guild.Name,
+ Topic: nil,
+ Members: &bridgev2.ChatMemberList{
+ MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}},
+
+ // As recommended by the spec, prohibit normal events by setting
+ // `events_default` to a suitably high number.
+ PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
+ },
+ Avatar: d.makeAvatarForGuild(guild),
+ Type: ptr.Ptr(database.RoomTypeSpace),
+ }
+
+ if prt.MXID == "" {
+ err := prt.CreateMatrixRoom(ctx, d.UserLogin, info)
+
+ if err != nil {
+ return fmt.Errorf("couldn't create room in order to materialize guild portal: %w", err)
+ }
+ } else {
+ prt.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{})
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) syncGuilds(ctx context.Context) {
+ guildIDs := d.connector.Config.Guilds.BridgingGuildIDs
+
+ for _, guildID := range guildIDs {
+ log := zerolog.Ctx(ctx).With().
+ Str("guild_id", guildID).
+ Str("action", "sync guild").
+ Logger()
+
+ err := d.bridgeGuild(log.WithContext(ctx), guildID)
+ if err != nil {
+ log.Err(err).Msg("Couldn't bridge guild during sync")
+ }
+ }
+}
+
+func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
+ log := zerolog.Ctx(ctx)
+
+ guild, err := d.Session.State.Guild(guildID)
+ if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil {
+ log.Err(err).Msg("Couldn't find guild, user isn't a member?")
+ return errors.New("couldn't find guild in state")
+ }
+
+ err = d.syncGuildSpace(ctx, guild)
+ if err != nil {
+ log.Err(err).Msg("Couldn't sync guild space portal")
+ return fmt.Errorf("couldn't sync guild space portal: %w", err)
+ }
+
+ for _, guildCh := range guild.Channels {
+ if guildCh.Type != discordgo.ChannelTypeGuildText {
+ // TODO implement categories (spaces) and news channels
+ log.Trace().
+ Str("channel_id", guildCh.ID).
+ Int("channel_type", int(guildCh.Type)).
+ Msg("Not bridging guild channel due to type")
+ continue
+ }
+
+ if !d.canSeeGuildChannel(ctx, guildCh) {
+ log.Trace().
+ Str("channel_id", guildCh.ID).
+ Int("channel_type", int(guildCh.Type)).
+ Msg("Not bridging guild channel that the user doesn't have permission to view")
+
+ continue
+ }
+
+ d.syncChannel(ctx, guildCh)
+ }
+
+ log.Debug().Msg("Subscribing to guild after bridging")
+ err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
+ GuildID: guild.ID,
+ Typing: true,
+ Activities: true,
+ Threads: true,
+ })
+ if err != nil {
+ d.handlePossible40002(err)
+ log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding")
+ }
+
+ return nil
+}
+
+func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to prepare request: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to download %s: %w", thing, err)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read %s data: %w", thing, err)
+ }
+ return data, nil
+}
+
+func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender {
+ return bridgev2.EventSender{
+ IsFromMe: userID == d.Session.State.User.ID,
+ SenderLogin: discordid.MakeUserLoginID(userID),
+ Sender: discordid.MakeUserID(userID),
+ }
+}
+
+func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
+ return d.makeEventSenderWithID(d.Session.State.User.ID)
+}
+
+func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
+ return d.makeEventSenderWithID(user.ID)
+}
+
+func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) {
+ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
+ Client: d,
+ channel: ch,
+ portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true),
+ })
+}
diff --git a/pkg/connector/config.go b/pkg/connector/config.go
new file mode 100644
index 0000000..22da26d
--- /dev/null
+++ b/pkg/connector/config.go
@@ -0,0 +1,40 @@
+// 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 (
+ _ "embed"
+
+ up "go.mau.fi/util/configupgrade"
+)
+
+//go:embed example-config.yaml
+var ExampleConfig string
+
+type Config struct {
+ Guilds struct {
+ BridgingGuildIDs []string `yaml:"bridging_guild_ids"`
+ } `yaml:"guilds"`
+}
+
+func upgradeConfig(helper up.Helper) {
+ helper.Copy(up.List, "guilds", "bridging_guild_ids")
+}
+
+func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) {
+ return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig)
+}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
new file mode 100644
index 0000000..d866e49
--- /dev/null
+++ b/pkg/connector/connector.go
@@ -0,0 +1,61 @@
+// 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 (
+ "context"
+
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/msgconv"
+)
+
+type DiscordConnector struct {
+ Bridge *bridgev2.Bridge
+ Config Config
+ MsgConv *msgconv.MessageConverter
+}
+
+var (
+ _ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
+ _ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
+)
+
+func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
+ d.Bridge = bridge
+ d.MsgConv = msgconv.NewMessageConverter(bridge)
+ d.setUpProvisioningAPIs()
+}
+
+func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
+ d.MsgConv.MaxFileSize = maxSize
+}
+
+func (d *DiscordConnector) Start(ctx context.Context) error {
+ return nil
+}
+
+func (d *DiscordConnector) GetName() bridgev2.BridgeName {
+ return bridgev2.BridgeName{
+ DisplayName: "Discord",
+ NetworkURL: "https://discord.com",
+ NetworkIcon: "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC",
+ NetworkID: "discord",
+ BeeperBridgeType: "discordgo",
+ DefaultPort: 29334,
+ }
+}
diff --git a/config/config.go b/pkg/connector/dbmeta.go
similarity index 63%
rename from config/config.go
rename to pkg/connector/dbmeta.go
index d704651..6d8fbd5 100644
--- a/config/config.go
+++ b/pkg/connector/dbmeta.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// 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
@@ -14,22 +14,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package config
+package connector
import (
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/bridgev2/database"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
)
-type Config struct {
- *bridgeconfig.BaseConfig `yaml:",inline"`
-
- Bridge BridgeConfig `yaml:"bridge"`
-}
-
-func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
- _, homeserver, _ := userID.Parse()
- _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
-
- return hasSecret
+func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes {
+ return database.MetaTypes{
+ Portal: func() any {
+ return &discordid.PortalMetadata{}
+ },
+ UserLogin: func() any {
+ return &discordid.UserLoginMetadata{}
+ },
+ }
}
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
new file mode 100644
index 0000000..86a5c52
--- /dev/null
+++ b/pkg/connector/events.go
@@ -0,0 +1,179 @@
+// 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 (
+ "context"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type DiscordChatResync struct {
+ Client *DiscordClient
+ channel *discordgo.Channel
+ portalKey networkid.PortalKey
+}
+
+var (
+ _ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
+ _ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
+ _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
+)
+
+func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
+ c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
+ return c
+}
+
+func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
+ return d.portalKey
+}
+
+func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
+ return bridgev2.EventSender{}
+}
+
+func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
+ return bridgev2.RemoteEventChatResync
+}
+
+func (d *DiscordChatResync) avatar(ctx context.Context) *bridgev2.Avatar {
+ ch := d.channel
+
+ // TODO make this configurable (ala workspace_avatar_in_rooms)
+ if !d.isPrivate() {
+ guild, err := d.Client.Session.State.Guild(ch.GuildID)
+
+ if err != nil || guild == nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
+ return nil
+ }
+
+ return d.Client.makeAvatarForGuild(guild)
+ }
+
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(ch.Icon),
+ Get: func(ctx context.Context) ([]byte, error) {
+ url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
+ return simpleDownload(ctx, url, "group dm icon")
+ },
+ Remove: ch.Icon == "",
+ }
+}
+
+func (d *DiscordChatResync) privateChannelMemberList() bridgev2.ChatMemberList {
+ ch := d.channel
+
+ var members bridgev2.ChatMemberList
+ members.IsFull = true
+ members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
+ if len(ch.Recipients) > 0 {
+ selfEventSender := d.Client.selfEventSender()
+
+ // Private channels' array of participants doesn't include ourselves,
+ // so inject ourselves as a member.
+ members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
+
+ for _, recipient := range ch.Recipients {
+ sender := d.Client.makeEventSender(recipient)
+ members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
+ }
+
+ members.TotalMemberCount = len(ch.Recipients)
+ }
+
+ return members
+}
+
+func (d *DiscordChatResync) memberList() bridgev2.ChatMemberList {
+ if d.isPrivate() {
+ return d.privateChannelMemberList()
+ }
+
+ // TODO we're _always_ sending partial member lists for guilds; we can probably
+ // do better
+ selfEventSender := d.Client.selfEventSender()
+
+ return bridgev2.ChatMemberList{
+ IsFull: false,
+ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
+ selfEventSender.Sender: {EventSender: selfEventSender},
+ },
+ }
+}
+
+func (d *DiscordChatResync) isPrivate() bool {
+ ch := d.channel
+ return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
+}
+
+func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
+ ch := d.channel
+
+ var roomType database.RoomType
+
+ switch ch.Type {
+ case discordgo.ChannelTypeDM:
+ roomType = database.RoomTypeDM
+ case discordgo.ChannelTypeGroupDM:
+ roomType = database.RoomTypeGroupDM
+ }
+
+ info := &bridgev2.ChatInfo{
+ Name: &ch.Name,
+ Members: ptr.Ptr(d.memberList()),
+ Avatar: d.avatar(ctx),
+ Type: &roomType,
+ CanBackfill: true,
+ ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
+ meta := portal.Metadata.(*discordid.PortalMetadata)
+ if meta.GuildID != ch.GuildID {
+ meta.GuildID = ch.GuildID
+ changed = true
+ }
+
+ return
+ },
+ }
+
+ if !d.isPrivate() {
+ // Channel belongs to a guild; associate it with the respective space.
+ info.ParentID = ptr.Ptr(d.Client.guildPortalKeyFromID(ch.GuildID).ID)
+ }
+
+ return info, nil
+}
+
+func (d *DiscordChatResync) ShouldCreatePortal() bool {
+ return true
+}
+
+func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
+ if latestBridged == nil {
+ zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
+ return false, nil
+ }
+ return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
+}
diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml
new file mode 100644
index 0000000..bfd5c3c
--- /dev/null
+++ b/pkg/connector/example-config.yaml
@@ -0,0 +1,6 @@
+# Configuration options related to Discord guilds (also known as "servers").
+guilds:
+ # UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure
+ # during bridge development. If no guild IDs are specified, then no guilds
+ # are bridged at all.
+ bridging_guild_ids: []
diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go
new file mode 100644
index 0000000..b9d6136
--- /dev/null
+++ b/pkg/connector/handlediscord.go
@@ -0,0 +1,242 @@
+// 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 (
+ "context"
+ "fmt"
+ "runtime/debug"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type DiscordEventMeta struct {
+ Type bridgev2.RemoteEventType
+ PortalKey networkid.PortalKey
+ LogContext func(c zerolog.Context) zerolog.Context
+}
+
+func (em *DiscordEventMeta) AddLogContext(c zerolog.Context) zerolog.Context {
+ if em.LogContext == nil {
+ return c
+ }
+ c = em.LogContext(c)
+ return c
+}
+
+func (em *DiscordEventMeta) GetType() bridgev2.RemoteEventType {
+ return em.Type
+}
+
+func (em *DiscordEventMeta) GetPortalKey() networkid.PortalKey {
+ return em.PortalKey
+}
+
+type DiscordMessage struct {
+ *DiscordEventMeta
+ Data *discordgo.Message
+ Client *DiscordClient
+}
+
+var (
+ _ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
+ // _ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
+ // _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
+)
+
+func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
+ return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil
+}
+
+func (m *DiscordMessage) GetID() networkid.MessageID {
+ return discordid.MakeMessageID(m.Data.ID)
+}
+
+func (m *DiscordMessage) GetSender() bridgev2.EventSender {
+ return m.Client.makeEventSender(m.Data.Author)
+}
+
+func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) DiscordMessage {
+ return DiscordMessage{
+ DiscordEventMeta: &DiscordEventMeta{
+ Type: bridgev2.RemoteEventMessage,
+ PortalKey: networkid.PortalKey{
+ ID: discordid.MakePortalID(evt.ChannelID),
+ Receiver: d.UserLogin.ID,
+ },
+ },
+ Data: evt.Message,
+ Client: d,
+ }
+}
+
+type DiscordReaction struct {
+ *DiscordEventMeta
+ Reaction *discordgo.MessageReaction
+ Client *DiscordClient
+}
+
+func (r *DiscordReaction) GetSender() bridgev2.EventSender {
+ return r.Client.makeEventSenderWithID(r.Reaction.UserID)
+}
+
+func (r *DiscordReaction) GetTargetMessage() networkid.MessageID {
+ return discordid.MakeMessageID(r.Reaction.MessageID)
+}
+
+func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID {
+ return discordid.MakeEmojiID(r.Reaction.Emoji.Name)
+}
+
+var (
+ _ bridgev2.RemoteReaction = (*DiscordReaction)(nil)
+ _ bridgev2.RemoteReactionRemove = (*DiscordReaction)(nil)
+ _ bridgev2.RemoteReactionWithExtraContent = (*DiscordReaction)(nil)
+)
+
+func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) {
+ // name is either a grapheme cluster consisting of a Unicode emoji, or the
+ // name of a custom emoji.
+ name := r.Reaction.Emoji.Name
+ return name, discordid.MakeEmojiID(name)
+}
+
+func (r *DiscordReaction) GetReactionExtraContent() map[string]any {
+ extra := make(map[string]any)
+
+ reaction := r.Reaction
+ emoji := reaction.Emoji
+
+ if emoji.ID != "" {
+ // The emoji is a custom emoji.
+
+ extra["fi.mau.discord.reaction"] = map[string]any{
+ "id": emoji.ID,
+ "name": emoji.Name,
+ // FIXME Handle custom emoji.
+ // "mxc": reaction,
+ }
+
+ wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name)
+ extra["com.beeper.reaction.shortcode"] = wrappedShortcode
+ }
+
+ return extra
+}
+
+func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction, beingAdded bool) DiscordReaction {
+ evtType := bridgev2.RemoteEventReaction
+ if !beingAdded {
+ evtType = bridgev2.RemoteEventReactionRemove
+ }
+
+ return DiscordReaction{
+ DiscordEventMeta: &DiscordEventMeta{
+ Type: evtType,
+ PortalKey: networkid.PortalKey{
+ ID: discordid.MakePortalID(reaction.ChannelID),
+ Receiver: d.UserLogin.ID,
+ },
+ },
+ Reaction: reaction,
+ Client: d,
+ }
+}
+
+func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
+ if d.UserLogin == nil {
+ // Our event handlers are able to assume that a UserLogin is available.
+ // We respond to special events like READY outside of this function,
+ // by virtue of methods like Session.Open only returning control flow
+ // after RESUME or READY.
+ log := zerolog.Ctx(context.TODO())
+ log.Trace().Msg("Dropping Discord event received before UserLogin creation")
+ return
+ }
+
+ if d.Session == nil || d.Session.State == nil || d.Session.State.User == nil {
+ // Our event handlers are able to assume that we've fully connected to the
+ // gateway.
+ d.UserLogin.Log.Debug().Msg("Dropping Discord event received before READY or RESUMED")
+ return
+ }
+
+ defer func() {
+ err := recover()
+ if err != nil {
+ d.UserLogin.Log.Error().
+ Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
+ Any(zerolog.ErrorFieldName, err).
+ Msg("Panic in Discord event handler")
+ }
+ }()
+
+ log := d.UserLogin.Log.With().Str("action", "handle discord event").
+ Type("event_type", rawEvt).
+ Logger()
+
+ switch evt := rawEvt.(type) {
+ case *discordgo.Connect:
+ log.Info().Msg("Discord gateway connected")
+ d.markConnected()
+ case *discordgo.Disconnect:
+ log.Info().Msg("Discord gateway disconnected")
+ d.scheduleTransientDisconnect("")
+ case *discordgo.InvalidAuth:
+ log.Warn().Msg("Discord gateway reported invalid auth")
+ d.markInvalidAuth("You have been logged out of Discord, please reconnect")
+ case *discordgo.Ready:
+ log.Info().Msg("Received READY dispatch from discordgo")
+ d.markConnected()
+ case *discordgo.Resumed:
+ log.Info().Msg("Received RESUMED dispatch from discordgo")
+ d.markConnected()
+ case *discordgo.MessageCreate:
+ if evt.Author == nil {
+ log.Trace().Int("message_type", int(evt.Message.Type)).
+ Str("guild_id", evt.GuildID).
+ Str("message_id", evt.ID).
+ Str("channel_id", evt.ChannelID).
+ Msg("Dropping message that lacks an author")
+ return
+ }
+ wrappedEvt := d.wrapDiscordMessage(evt)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ case *discordgo.MessageReactionAdd:
+ wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ case *discordgo.MessageReactionRemove:
+ wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, false)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ // TODO case *discordgo.MessageReactionRemoveAll:
+ // TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
+ case *discordgo.PresenceUpdate:
+ return
+ case *discordgo.Event:
+ // For presently unknown reasons sometimes discordgo won't unmarshal
+ // events into their proper corresponding structs.
+ if evt.Type == "PRESENCE_UPDATE" || evt.Type == "PASSIVE_UPDATE_V2" || evt.Type == "CONVERSATION_SUMMARY_UPDATE" {
+ return
+ }
+ log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event")
+ }
+}
diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go
new file mode 100644
index 0000000..2449fa9
--- /dev/null
+++ b/pkg/connector/handlematrix.go
@@ -0,0 +1,234 @@
+// 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 (
+ "context"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var (
+ _ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil)
+)
+
+func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
+ if d.Session == nil {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ portal := msg.Portal
+ guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
+ channelID := discordid.ParsePortalID(portal.ID)
+
+ sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg)
+ if err != nil {
+ return nil, err
+ }
+
+ var options []discordgo.RequestOption
+ // TODO: When supporting threads (and not a bot user), send a thread referer.
+ options = append(options, discordgo.WithChannelReferer(guildID, channelID))
+
+ sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParsePortalID(msg.Portal.ID), sendReq, options...)
+ if err != nil {
+ d.handlePossible40002(err)
+ return nil, err
+ }
+ sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID)
+
+ return &bridgev2.MatrixMessageResponse{
+ DB: &database.Message{
+ ID: discordid.MakeMessageID(sentMsg.ID),
+ SenderID: discordid.MakeUserID(sentMsg.Author.ID),
+ Timestamp: sentMsgTimestamp,
+ },
+ }, nil
+}
+
+func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
+ key := reaction.Content.RelatesTo.Key
+ // TODO: Handle custom emoji.
+
+ return bridgev2.MatrixReactionPreResponse{
+ SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID),
+ EmojiID: discordid.MakeEmojiID(key),
+ }, nil
+}
+
+func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
+ relatesToKey := reaction.Content.RelatesTo.Key
+ portal := reaction.Portal
+ meta := portal.Metadata.(*discordid.PortalMetadata)
+
+ err := d.Session.MessageReactionAddUser(meta.GuildID, discordid.ParsePortalID(portal.ID), discordid.ParseMessageID(reaction.TargetMessage.ID), relatesToKey)
+ if err != nil {
+ d.handlePossible40002(err)
+ }
+ return nil, err
+}
+
+func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
+ removing := removal.TargetReaction
+ emojiID := removing.EmojiID
+ channelID := discordid.ParsePortalID(removing.Room.ID)
+ guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+
+ err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID))
+ if err != nil {
+ d.handlePossible40002(err)
+ }
+ return err
+}
+
+func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
+ channelID := discordid.ParsePortalID(removal.Portal.ID)
+ messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
+ err := d.Session.ChannelMessageDelete(channelID, messageID)
+ if err != nil {
+ d.handlePossible40002(err)
+ }
+ return err
+}
+
+func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
+ // TODO: Support threads.
+ log := msg.Portal.Log.With().
+ Str("event_id", string(msg.EventID)).
+ Str("action", "matrix read receipt").Logger()
+
+ var targetMessageID string
+
+ // Figure out the ID of the Discord message that we'll mark as read. If the
+ // receipt didn't exactly correspond with a message, try finding one close
+ // by to use as the target.
+ if msg.ExactMessage != nil {
+ targetMessageID = discordid.ParseMessageID(msg.ExactMessage.ID)
+ log = log.With().
+ Str("message_id", targetMessageID).
+ Logger()
+ } else {
+ closestMessage, err := d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo)
+
+ if err != nil {
+ log.Err(err).Msg("Failed to find closest message part")
+ return err
+ } else if closestMessage != nil {
+ // The read receipt didn't specify an exact message but we were able to
+ // find one close by.
+
+ targetMessageID = discordid.ParseMessageID(closestMessage.ID)
+ log = log.With().
+ Str("closest_message_id", targetMessageID).
+ Str("closest_event_id", closestMessage.MXID.String()).
+ Logger()
+ log.Debug().
+ Msg("Read receipt target event not found, using closest message")
+ } else {
+ log.Debug().Msg("Dropping read receipt: no messages found")
+ return nil
+ }
+ }
+
+ // TODO: Support threads.
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ channelID := discordid.ParsePortalID(msg.Portal.ID)
+ resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID))
+ if err != nil {
+ d.handlePossible40002(err)
+ log.Err(err).Msg("Failed to send read receipt to Discord")
+ return err
+ } else if resp.Token != nil {
+ log.Debug().
+ Str("unexpected_resp_token", *resp.Token).
+ Msg("Marked message as read on Discord (and got unexpected non-nil token)")
+ } else {
+ log.Debug().Msg("Marked message as read on Discord")
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error {
+ if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" {
+ // Only private channels need this logic.
+ return nil
+ }
+
+ d.markedOpenedLock.Lock()
+ defer d.markedOpenedLock.Unlock()
+
+ channelID := discordid.ParsePortalID(portal.ID)
+ log := zerolog.Ctx(ctx).With().
+ Str("channel_id", channelID).Logger()
+
+ lastMarkedOpenedTs := d.markedOpened[channelID]
+ if lastMarkedOpenedTs.IsZero() {
+ d.markedOpened[channelID] = time.Now()
+
+ err := d.Session.MarkViewing(channelID)
+
+ if err != nil {
+ d.handlePossible40002(err)
+ log.Error().Err(err).Msg("Failed to mark user as viewing channel")
+ return err
+ }
+
+ log.Trace().Msg("Marked channel as being viewed")
+ } else {
+ log.Trace().Str("channel_id", channelID).
+ Msg("Already marked channel as viewed, not doing so")
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
+ log := zerolog.Ctx(ctx)
+
+ // Don't mind if this fails.
+ _ = d.viewingChannel(ctx, msg.Portal)
+
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ channelID := discordid.ParsePortalID(msg.Portal.ID)
+ // TODO: Support threads properly when sending the referer.
+ err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID))
+
+ if err != nil {
+ d.handlePossible40002(err)
+ log.Warn().Err(err).Msg("Failed to mark user as typing")
+ return err
+ }
+
+ log.Debug().Msg("Marked user as typing")
+ return nil
+}
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
new file mode 100644
index 0000000..40a92d7
--- /dev/null
+++ b/pkg/connector/login.go
@@ -0,0 +1,64 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginStepIDComplete = "fi.mau.discord.login.complete"
+
+func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow {
+ return []bridgev2.LoginFlow{
+ {
+ ID: LoginFlowIDBrowser,
+ Name: "Browser",
+ Description: "Log in to your Discord account in a web browser.",
+ },
+ {
+ ID: LoginFlowIDRemoteAuth,
+ Name: "QR Code",
+ Description: "Scan a QR code with the Discord mobile app to log in.",
+ },
+ {
+ ID: LoginFlowIDToken,
+ Name: "Token",
+ Description: "Provide a Discord user token to connect with.",
+ },
+ }
+}
+
+func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
+ login := DiscordGenericLogin{
+ connector: d,
+ User: user,
+ }
+
+ switch flowID {
+ case LoginFlowIDToken:
+ return &DiscordTokenLogin{DiscordGenericLogin: &login}, nil
+ case LoginFlowIDRemoteAuth:
+ return &DiscordRemoteAuthLogin{DiscordGenericLogin: &login}, nil
+ case LoginFlowIDBrowser:
+ return &DiscordBrowserLogin{DiscordGenericLogin: &login}, nil
+ default:
+ return nil, fmt.Errorf("unknown discord login flow id")
+ }
+}
diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go
new file mode 100644
index 0000000..43f2a9e
--- /dev/null
+++ b/pkg/connector/login_browser.go
@@ -0,0 +1,97 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginFlowIDBrowser = "token"
+
+type DiscordBrowserLogin struct {
+ *DiscordGenericLogin
+}
+
+var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil)
+
+const ExtractDiscordTokenJS = `
+new Promise((resolve) => {
+ let mautrixDiscordTokenCheckInterval
+
+ const iframe = document.createElement('iframe')
+ document.head.append(iframe)
+
+ mautrixDiscordTokenCheckInterval = setInterval(() => {
+ const token = iframe.contentWindow.localStorage.token
+ if (token) {
+ resolve({ token: token.slice(1, -1) })
+ clearInterval(mautrixDiscordTokenCheckInterval)
+ }
+ }, 200)
+})
+`
+
+func (dl *DiscordBrowserLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeCookies,
+ StepID: "fi.mau.discord.cookies",
+ Instructions: "Log in with Discord.",
+ CookiesParams: &bridgev2.LoginCookiesParams{
+ URL: "https://discord.com/login",
+ UserAgent: "",
+ Fields: []bridgev2.LoginCookieField{{
+ ID: "token",
+ Required: true,
+ Sources: []bridgev2.LoginCookieFieldSource{{
+ Type: bridgev2.LoginCookieTypeSpecial,
+ Name: "fi.mau.discord.token",
+ }},
+ }},
+ ExtractJS: ExtractDiscordTokenJS,
+ },
+ }, nil
+}
+
+func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
+ log := zerolog.Ctx(ctx)
+
+ token := cookies["token"]
+ if token == "" {
+ log.Error().Msg("Received empty token")
+ return nil, fmt.Errorf("received empty token")
+ }
+ log.Debug().Msg("Logging in with submitted cookie")
+
+ ul, err := dl.FinalizeCreatingLogin(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't log in via browser: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go
new file mode 100644
index 0000000..a04db97
--- /dev/null
+++ b/pkg/connector/login_generic.go
@@ -0,0 +1,120 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+// DiscordGenericLogin is embedded within each struct that implements
+// bridgev2.LoginProcess in order to encapsulate the common behavior that needs
+// to occur after procuring a valid user token. Namely, creating a gateway
+// connection to Discord and an associated UserLogin to wrap things up.
+//
+// It also implements a baseline Cancel method that closes the gateway
+// connection.
+type DiscordGenericLogin struct {
+ User *bridgev2.User
+ connector *DiscordConnector
+
+ Session *discordgo.Session
+
+ // The Discord user we've authenticated as. This is only non-nil if
+ // a call to FinalizeCreatingLogin has succeeded.
+ DiscordUser *discordgo.User
+}
+
+func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
+ session, err := NewDiscordSession(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create discord session: %w", err)
+ }
+
+ client := DiscordClient{
+ connector: dl.connector,
+ Session: session,
+ }
+ client.SetUp(ctx, nil)
+
+ err = client.connect(ctx)
+ if err != nil {
+ dl.Cancel()
+ return nil, err
+ }
+ // At this point we've opened a WebSocket connection to the gateway, received
+ // a READY packet, and know who we are.
+ user := session.State.User
+ dl.DiscordUser = user
+
+ dl.Session = session
+ ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
+ ID: discordid.MakeUserLoginID(user.ID),
+ Metadata: &discordid.UserLoginMetadata{
+ Token: token,
+ HeartbeatSession: session.HeartbeatSession,
+ },
+ }, &bridgev2.NewLoginParams{
+ LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error {
+ login.Client = &client
+ client.UserLogin = login
+
+ // Only now that we have a UserLogin can we begin syncing.
+ client.BeginSyncingIfUserLoginPresent(ctx)
+ return nil
+ },
+ DeleteOnConflict: true,
+ DontReuseExisting: false,
+ })
+ if err != nil {
+ dl.Cancel()
+ return nil, fmt.Errorf("couldn't create login: %w", err)
+ }
+
+ zerolog.Ctx(ctx).Info().
+ Str("user_id", user.ID).
+ Str("user_username", user.Username).
+ Msg("Logged in to Discord")
+
+ // We already opened the gateway session before creating the UserLogin,
+ // which means the initial READY/CONNECT event was dropped. Send Connected
+ // here so infra gets login status for new logins.
+ client.markConnected()
+
+ return ul, nil
+}
+
+func (dl *DiscordGenericLogin) CompleteInstructions() string {
+ return fmt.Sprintf("Logged in as %s", dl.DiscordUser.Username)
+}
+
+func (dl *DiscordGenericLogin) Cancel() {
+ if dl.Session != nil {
+ dl.User.Log.Debug().Msg("Login cancelled, closing session")
+ err := dl.Session.Close()
+ if err != nil {
+ dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation")
+ }
+ }
+}
diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go
new file mode 100644
index 0000000..e116090
--- /dev/null
+++ b/pkg/connector/login_remoteauth.go
@@ -0,0 +1,141 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/remoteauth"
+)
+
+const LoginFlowIDRemoteAuth = "qr"
+
+type DiscordRemoteAuthLogin struct {
+ *DiscordGenericLogin
+
+ hasClosed bool
+ remoteAuthClient *remoteauth.Client
+ qrChan chan string
+ doneChan chan struct{}
+}
+
+var _ bridgev2.LoginProcessDisplayAndWait = (*DiscordRemoteAuthLogin)(nil)
+
+func (dl *DiscordRemoteAuthLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ log := zerolog.Ctx(ctx)
+
+ log.Debug().Msg("Creating new remoteauth client")
+ client, err := remoteauth.New()
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create Discord remoteauth client: %w", err)
+ }
+
+ dl.remoteAuthClient = client
+
+ dl.qrChan = make(chan string)
+ dl.doneChan = make(chan struct{})
+
+ log.Info().Msg("Starting the QR code login process")
+ err = client.Dial(ctx, dl.qrChan, dl.doneChan)
+ if err != nil {
+ log.Err(err).Msg("Couldn't connect to Discord remoteauth websocket")
+ close(dl.qrChan)
+ close(dl.doneChan)
+ return nil, fmt.Errorf("couldn't connect to Discord remoteauth websocket: %w", err)
+ }
+
+ log.Info().Msg("Waiting for QR code to be ready")
+
+ select {
+ case qrCode := <-dl.qrChan:
+ log.Info().Int("qr_code_data_len", len(qrCode)).Msg("Received QR code, creating login step")
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeDisplayAndWait,
+ StepID: "fi.mau.discord.qr",
+ Instructions: "On your phone, find “Scan QR Code” in Discord’s settings.",
+ DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
+ Type: bridgev2.LoginDisplayTypeQR,
+ Data: qrCode,
+ },
+ }, nil
+ case <-ctx.Done():
+ log.Debug().Msg("Cancelled while waiting for QR code")
+ return nil, nil
+ }
+}
+
+// Wait implements bridgev2.LoginProcessDisplayAndWait.
+func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
+ if dl.doneChan == nil {
+ panic("can't wait for discord remoteauth without a doneChan")
+ }
+
+ log := zerolog.Ctx(ctx)
+
+ log.Debug().Msg("Waiting for remoteauth")
+ select {
+ case <-dl.doneChan:
+ user, err := dl.remoteAuthClient.Result()
+ if err != nil {
+ log.Err(err).Msg("Discord remoteauth failed")
+ return nil, fmt.Errorf("discord remoteauth failed: %w", err)
+ }
+ log.Debug().Msg("Discord remoteauth succeeded")
+
+ return dl.finalizeSuccessfulLogin(ctx, user)
+ case <-ctx.Done():
+ log.Debug().Msg("Cancelled while waiting for remoteauth to complete")
+ return nil, nil
+ }
+}
+
+func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) {
+ ul, err := dl.FinalizeCreatingLogin(ctx, user.Token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't log in via remoteauth: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
+
+func (dl *DiscordRemoteAuthLogin) Cancel() {
+ // Tolerate multiple attempts to cancel.
+ if dl.hasClosed {
+ return
+ }
+ dl.hasClosed = true
+
+ dl.User.Log.Debug().Msg("Discord remoteauth cancelled")
+ dl.DiscordGenericLogin.Cancel()
+
+ // remoteauth.Client doesn't seem to expose a cancellation method.
+ close(dl.doneChan)
+ close(dl.qrChan)
+}
diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go
new file mode 100644
index 0000000..bee7b98
--- /dev/null
+++ b/pkg/connector/login_token.go
@@ -0,0 +1,72 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginFlowIDToken = "DEBUG_USERINPUT_token"
+
+type DiscordTokenLogin struct {
+ *DiscordGenericLogin
+}
+
+var _ bridgev2.LoginProcessUserInput = (*DiscordTokenLogin)(nil)
+
+func (dl *DiscordTokenLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: "fi.mau.discord.enter_token",
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypePassword,
+ ID: "token",
+ Name: "Discord user account token",
+ // Cribbed from https://regex101.com/r/1GMR0y/1.
+ Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`,
+ },
+ },
+ },
+ }, nil
+}
+
+func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
+ token := input["token"]
+ if token == "" {
+ return nil, fmt.Errorf("no token provided")
+ }
+
+ ul, err := dl.FinalizeCreatingLogin(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't login from token: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go
new file mode 100644
index 0000000..0b2d56e
--- /dev/null
+++ b/pkg/connector/provisioning.go
@@ -0,0 +1,206 @@
+// 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 (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exhttp"
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+const (
+ ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
+ ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
+ ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
+ ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
+ ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
+ ErrCodeGuildBridgeFailed = "M_UNKNOWN"
+ ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
+ ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
+ ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
+ ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
+ ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
+ ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
+)
+
+type ProvisioningAPI struct {
+ log zerolog.Logger
+ connector *DiscordConnector
+ prov bridgev2.IProvisioningAPI
+}
+
+func (d *DiscordConnector) setUpProvisioningAPIs() error {
+ c, ok := d.Bridge.Matrix.(bridgev2.MatrixConnectorWithProvisioning)
+ if !ok {
+ return errors.New("matrix connector doesn't support provisioning; not setting up")
+ }
+
+ prov := c.GetProvisioning()
+ r := prov.GetRouter()
+ if r == nil {
+ return errors.New("matrix connector's provisioning api didn't return a router")
+ }
+
+ log := d.Bridge.Log.With().Str("component", "provisioning").Logger()
+ p := &ProvisioningAPI{
+ connector: d,
+ log: log,
+ prov: prov,
+ }
+
+ // NOTE: aim to provide backwards compatibility with v1 provisioning APIs
+ r.HandleFunc("GET /v1/guilds", p.makeHandler(p.guildsList))
+ r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild))
+ r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild))
+
+ return nil
+}
+
+type guildEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+
+ // TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
+ AvatarURL string `json:"avatar_url"`
+
+ // v1-compatible fields:
+ MXID string `json:"mxid"`
+ AutoBridge bool `json:"auto_bridge_channels"`
+ BridgingMode string `json:"bridging_mode"`
+
+ Available bool `json:"available"`
+}
+type respGuildsList struct {
+ Guilds []guildEntry `json:"guilds"`
+}
+
+func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient)) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ user := p.prov.GetUser(r)
+ logins := user.GetUserLogins()
+
+ if len(logins) < 1 {
+ mautrix.RespError{
+ ErrCode: ErrCodeNotConnected,
+ Err: "user has no logins",
+ }.Write(w)
+ return
+ }
+
+ login := logins[0]
+ client := login.Client.(*DiscordClient)
+
+ handler(w, r, login, client)
+ }
+}
+
+func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
+
+ var resp respGuildsList
+ resp.Guilds = []guildEntry{}
+ for _, guild := range client.Session.State.Guilds {
+ resp.Guilds = append(resp.Guilds, guildEntry{
+ ID: guild.ID,
+ Name: guild.Name,
+ AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
+
+ BridgingMode: "everything",
+
+ Available: !guild.Unavailable,
+ })
+ }
+
+ exhttp.WriteJSONResponse(w, 200, resp)
+}
+
+func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ guildID := r.PathValue("guildID")
+ if guildID == "" {
+ mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
+ return
+ }
+
+ p.log.Info().
+ Str("login_id", discordid.ParseUserLoginID(login.ID)).
+ Str("guild_id", guildID).
+ Msg("requested to bridge guild via provisioning api")
+
+ // TODO detect guild already bridged
+ go client.bridgeGuild(context.TODO(), guildID)
+
+ exhttp.WriteJSONResponse(w, 201, nil)
+}
+
+func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ guildID := r.PathValue("guildID")
+ if guildID == "" {
+ mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
+ return
+ }
+
+ p.log.Info().
+ Str("login_id", discordid.ParseUserLoginID(login.ID)).
+ Str("guild_id", guildID).
+ Msg("requested to unbridge guild via provisioning api")
+
+ ctx := context.TODO()
+
+ portalKey := client.guildPortalKeyFromID(guildID)
+ portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ p.log.Err(err).Msg("Failed to get guild portal")
+ mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w)
+ return
+ }
+ if portal == nil || portal.MXID == "" {
+ mautrix.RespError{
+ ErrCode: ErrCodeGuildNotBridged,
+ Err: "guild is not bridged",
+ }.Write(w)
+ return
+ }
+
+ children, err := p.connector.Bridge.GetChildPortals(ctx, portalKey)
+ if err != nil {
+ p.log.Err(err).Msg("Failed to get child portals")
+ mautrix.MUnknown.WithMessage("failed to get children: %v", err).Write(w)
+ return
+ }
+
+ portalsToDelete := append(children, portal)
+ bridgev2.DeleteManyPortals(ctx, portalsToDelete, func(portal *bridgev2.Portal, del bool, err error) {
+ p.log.Err(err).
+ Stringer("portal_mxid", portal.MXID).
+ Bool("delete_room", del).
+ Msg("Failed during portal cleanup")
+ })
+
+ p.log.Info().Int("children", len(children)).Msg("Finished unbridging")
+ exhttp.WriteJSONResponse(w, 200, map[string]any{
+ "success": true,
+ "deleted_portals": len(children) + 1,
+ })
+}
diff --git a/pkg/connector/session.go b/pkg/connector/session.go
new file mode 100644
index 0000000..fef9592
--- /dev/null
+++ b/pkg/connector/session.go
@@ -0,0 +1,44 @@
+// 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 (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+)
+
+func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, error) {
+ log := zerolog.Ctx(ctx)
+
+ session, err := discordgo.New(token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create discord session: %w", err)
+ }
+
+ // Set up logging.
+ session.LogLevel = discordgo.LogInformational
+ session.Logger = func(msgL, caller int, format string, a ...any) {
+ // FIXME(skip): Hook up zerolog properly.
+ log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
+ }
+
+ return session, nil
+}
diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go
new file mode 100644
index 0000000..eb3e7b1
--- /dev/null
+++ b/pkg/connector/userinfo.go
@@ -0,0 +1,71 @@
+// 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 (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
+ // We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map
+ // directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison.
+ return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID)
+}
+
+func makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
+ url := u.AvatarURL("256")
+
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(url),
+ Get: func(ctx context.Context) ([]byte, error) {
+ return simpleDownload(ctx, url, "user avatar")
+ },
+ }
+}
+
+func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
+ log := zerolog.Ctx(ctx)
+
+ if ghost.ID == "" {
+ log.Warn().Msg("Tried to get user info for ghost with no ID")
+ return nil, nil
+ }
+
+ // FIXME(skip): This won't work for users in guilds.
+
+ user, ok := d.usersFromReady[discordid.ParseUserID(ghost.ID)]
+ if !ok {
+ log.Error().Str("ghost_id", discordid.ParseUserID(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost")
+ return nil, nil
+ }
+
+ return &bridgev2.UserInfo{
+ Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)},
+ Name: ptr.Ptr(user.DisplayName()),
+ Avatar: makeUserAvatar(user),
+ IsBot: &user.Bot,
+ }, nil
+}
diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go
new file mode 100644
index 0000000..1f2b939
--- /dev/null
+++ b/pkg/discordid/dbmeta.go
@@ -0,0 +1,33 @@
+// 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 discordid
+
+import "github.com/bwmarrin/discordgo"
+
+type PortalMetadata struct {
+ // The ID of the Discord guild that the channel corresponding to this portal
+ // belongs to.
+ //
+ // For private channels (DMs and group DMs), this will be the zero value
+ // (an empty string).
+ GuildID string `json:"guild_id"`
+}
+
+type UserLoginMetadata struct {
+ Token string `json:"token"`
+ HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"`
+}
diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go
new file mode 100644
index 0000000..42d4321
--- /dev/null
+++ b/pkg/discordid/id.go
@@ -0,0 +1,112 @@
+// 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 discordid
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+)
+
+func MakeUserID(userID string) networkid.UserID {
+ return networkid.UserID(userID)
+}
+
+func ParseUserID(userID networkid.UserID) string {
+ return string(userID)
+}
+
+func MakeUserLoginID(userID string) networkid.UserLoginID {
+ return networkid.UserLoginID(userID)
+}
+
+func ParseUserLoginID(id networkid.UserLoginID) string {
+ return string(id)
+}
+
+// UserLoginIDToUserID converts a UserLoginID to a UserID. In Discord, both
+// are the same underlying snowflake.
+func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
+ return networkid.UserID(id)
+}
+
+func MakePortalID(channelID string) networkid.PortalID {
+ return networkid.PortalID(channelID)
+}
+
+func ParsePortalID(portalID networkid.PortalID) string {
+ return string(portalID)
+}
+
+func MakeMessageID(messageID string) networkid.MessageID {
+ return networkid.MessageID(messageID)
+}
+
+func ParseMessageID(messageID networkid.MessageID) string {
+ return string(messageID)
+}
+
+func MakeEmojiID(emojiName string) networkid.EmojiID {
+ return networkid.EmojiID(emojiName)
+}
+
+func ParseEmojiID(emojiID networkid.EmojiID) string {
+ return string(emojiID)
+}
+
+func MakeAvatarID(avatar string) networkid.AvatarID {
+ return networkid.AvatarID(avatar)
+}
+
+// The string prepended to [networkid.PortalKey]s identifying spaces that
+// bridge Discord guilds.
+//
+// Every Discord guild created before August 2017 contained a channel
+// having _the same ID as the guild itself_. This channel also functioned as
+// the "default channel" in that incoming members would view this channel by
+// default. It was also impossible to delete.
+//
+// After this date, these "default channels" became deletable, and fresh guilds
+// were no longer created with a channel that exactly corresponded to the guild
+// ID.
+//
+// To accommodate Discord guilds created before this API change that have also
+// never deleted the default channel, we need a way to distinguish between the
+// guild and the default channel, as we wouldn't be able to bridge the guild
+// as a space otherwise.
+//
+// "*" was chosen as the asterisk character is used to filter by guilds in
+// the quick switcher (in Discord's first-party clients).
+//
+// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
+const GuildPortalKeySigil = "*"
+
+func MakeGuildPortalID(guildID string) networkid.PortalID {
+ return networkid.PortalID(GuildPortalKeySigil + guildID)
+}
+
+func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
+ key.ID = MakePortalID(ch.ID)
+ if wantReceiver {
+ key.Receiver = userLoginID
+ }
+ return
+}
+
+func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
+ key.ID = MakePortalID(channelID)
+ return
+}
diff --git a/pkg/msgconv/attachments.go b/pkg/msgconv/attachments.go
new file mode 100644
index 0000000..035b5f1
--- /dev/null
+++ b/pkg/msgconv/attachments.go
@@ -0,0 +1,147 @@
+// 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 msgconv
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+type ReuploadedAttachment struct {
+ MXC id.ContentURIString
+ File *event.EncryptedFileInfo
+ Size int
+ FileName string
+ MimeType string
+}
+
+func (d *MessageConverter) ReuploadUnknownMedia(
+ ctx context.Context,
+ url string,
+ allowEncryption bool,
+) (*ReuploadedAttachment, error) {
+ return d.ReuploadMedia(ctx, url, "", "", -1, allowEncryption)
+}
+
+func mib(size int64) float64 {
+ return float64(size) / 1024 / 1024
+}
+
+func (d *MessageConverter) ReuploadMedia(
+ ctx context.Context,
+ downloadURL string,
+ mimeType string,
+ fileName string,
+ estimatedSize int,
+ allowEncryption bool,
+) (*ReuploadedAttachment, error) {
+ if fileName == "" {
+ parsedURL, err := url.Parse(downloadURL)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't parse URL to detect file name: %w", err)
+ }
+ fileName = path.Base(parsedURL.Path)
+ }
+
+ sess := ctx.Value(contextKeyDiscordClient).(*discordgo.Session)
+ httpClient := sess.Client
+ intent := ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI)
+ var roomID id.RoomID
+ if allowEncryption {
+ roomID = ctx.Value(contextKeyPortal).(*bridgev2.Portal).MXID
+ }
+
+ req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ if sess.IsUser {
+ for key, value := range discordgo.DroidDownloadHeaders {
+ req.Header.Set(key, value)
+ }
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode > 300 {
+ errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ logEvt := zerolog.Ctx(ctx).Error().
+ Str("media_url", downloadURL).
+ Int("status_code", resp.StatusCode)
+ if json.Valid(errBody) {
+ logEvt.RawJSON("error_json", errBody)
+ } else {
+ logEvt.Bytes("error_body", errBody)
+ }
+ logEvt.Msg("Media download failed")
+ return nil, fmt.Errorf("%w: unexpected status code %d", bridgev2.ErrMediaDownloadFailed, resp.StatusCode)
+ } else if resp.ContentLength > d.MaxFileSize {
+ return nil, fmt.Errorf("%w (%.2f MiB > %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(resp.ContentLength), mib(d.MaxFileSize))
+ }
+
+ requireFile := mimeType == ""
+ var size int64
+ mxc, file, err := intent.UploadMediaStream(ctx, roomID, int64(estimatedSize), requireFile, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
+ var mbe *http.MaxBytesError
+ size, err = io.Copy(file, http.MaxBytesReader(nil, resp.Body, d.MaxFileSize))
+ if err != nil {
+ if errors.As(err, &mbe) {
+ return nil, fmt.Errorf("%w (over %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(d.MaxFileSize))
+ }
+ return nil, err
+ }
+ if mimeType == "" {
+ mimeBuf := make([]byte, 512)
+ n, err := file.(*os.File).ReadAt(mimeBuf, 0)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, fmt.Errorf("couldn't read file for mime detection: %w", err)
+ }
+ mimeType = http.DetectContentType(mimeBuf[:n])
+ }
+ return &bridgev2.FileStreamResult{
+ FileName: fileName,
+ MimeType: mimeType,
+ }, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &ReuploadedAttachment{
+ Size: int(size),
+ MXC: mxc,
+ File: file,
+ FileName: fileName,
+ MimeType: mimeType,
+ }, nil
+}
diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go
new file mode 100644
index 0000000..0bb6921
--- /dev/null
+++ b/pkg/msgconv/embed.go
@@ -0,0 +1,97 @@
+// 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 msgconv
+
+import (
+ "regexp"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+type BridgeEmbedType int
+
+const (
+ EmbedUnknown BridgeEmbedType = iota
+ EmbedRich
+ EmbedLinkPreview
+ EmbedVideo
+)
+
+const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
+
+// Discord links start with http:// or https://, contain at least two characters afterwards,
+// don't contain < or whitespace anywhere, and don't end with "'),.:;]
+//
+// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
+var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
+var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
+
+func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
+ // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
+ // so this is a hacky way to detect those.
+ return embed.Video != nil && embed.Video.ProxyURL == ""
+}
+
+// isPlainGifMessage returns whether a Discord message consists entirely of a
+// link to a GIF-like animated image. A single embed must also be present on the
+// message.
+//
+// This helps replicate Discord first-party client behavior, where the link is
+// hidden when these same conditions are fulfilled.
+func isPlainGifMessage(msg *discordgo.Message) bool {
+ if len(msg.Embeds) != 1 {
+ return false
+ }
+ embed := msg.Embeds[0]
+ isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
+ isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == ""
+ contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
+ return contentIsOnlyURL && (isGifVideo || isGifImage)
+}
+
+// getEmbedType determines how a Discord embed should be bridged to Matrix by
+// returning a BridgeEmbedType.
+func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
+ switch embed.Type {
+ case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
+ return EmbedLinkPreview
+ case discordgo.EmbedTypeVideo:
+ if isActuallyLinkPreview(embed) {
+ return EmbedLinkPreview
+ }
+ return EmbedVideo
+ case discordgo.EmbedTypeGifv:
+ return EmbedVideo
+ case discordgo.EmbedTypeImage:
+ if msg != nil && isPlainGifMessage(msg) {
+ return EmbedVideo
+ } else if embed.Image == nil && embed.Thumbnail != nil {
+ return EmbedLinkPreview
+ }
+ return EmbedRich
+ case discordgo.EmbedTypeRich:
+ return EmbedRich
+ default:
+ return EmbedUnknown
+ }
+}
+
+var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`)
+
+func isReplyEmbed(embed *discordgo.MessageEmbed) bool {
+ return hackyReplyPattern.MatchString(embed.Description)
+}
diff --git a/formatter.go b/pkg/msgconv/formatter.go
similarity index 58%
rename from formatter.go
rename to pkg/msgconv/formatter.go
index 2112b04..465cd63 100644
--- a/formatter.go
+++ b/pkg/msgconv/formatter.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
+// 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
@@ -14,24 +14,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package main
+package msgconv
import (
"fmt"
"regexp"
+ "slices"
"strings"
- "github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
- "go.mau.fi/util/variationselector"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
- "maunium.net/go/mautrix/id"
)
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
@@ -74,7 +71,16 @@ var discordRendererWithInlineLinks = goldmark.New(
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
-func (portal *Portal) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string {
+// renderDiscordMarkdownOnlyHTML converts Discord-flavored Markdown text to HTML.
+//
+// After conversion, if the text is surrounded by a single outermost paragraph
+// tag, it is unwrapped.
+func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
+ return format.UnwrapSingleParagraph(mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, text, allowInlineLinks))
+}
+
+// renderDiscordMarkdownOnlyHTMLNoUnwrap converts Discord-flavored Markdown text to HTML.
+func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder
@@ -91,84 +97,11 @@ func (portal *Portal) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowIn
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 {
- if item == newItem {
- return arr
- }
- }
- return append(arr, newItem)
-}
-
-func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
- if len(mxid) == 0 {
- return displayname
- }
- if mxid[0] == '#' {
- alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
- if err != nil {
- return displayname
- }
- mxid = alias.RoomID.String()
- }
- if mxid[0] == '!' {
- portal := br.GetPortalByMXID(id.RoomID(mxid))
- if portal != nil {
- if eventID == "" {
- //currentPortal := ctx[formatterContextPortalKey].(*Portal)
- return fmt.Sprintf("<#%s>", portal.Key.ChannelID)
- //if currentPortal.GuildID == portal.GuildID {
- //} else if portal.GuildID != "" {
- // return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name)
- //} else {
- // // TODO is mentioning private channels possible at all?
- //}
- } else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
- guildID := portal.GuildID
- if guildID == "" {
- guildID = "@me"
- }
- return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID)
- }
- }
- } else if mxid[0] == '@' {
- allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID)
- if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) {
- return displayname
- }
- mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
- parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
- if ok {
- mentions.Users = appendIfNotContains(mentions.Users, parsedID)
- return fmt.Sprintf("<@%s>", parsedID)
- }
- mentionedUser := br.GetUserByMXID(id.UserID(mxid))
- if mentionedUser != nil && mentionedUser.DiscordID != "" {
- mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
- return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
- }
- }
- return displayname
-}
-
-const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
-
-// Discord links start with http:// or https://, contain at least two characters afterwards,
-// don't contain < or whitespace anywhere, and don't end with "'),.:;]
-//
-// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
-var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
-var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
-
var discordMarkdownEscaper = strings.NewReplacer(
`\`, `\\`,
`_`, `\_`,
@@ -238,23 +171,3 @@ var matrixHTMLParser = &format.HTMLParser{
}
},
}
-
-func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
- allowedMentions := &discordgo.MessageAllowedMentions{
- Parse: []discordgo.AllowedMentionType{},
- Users: []string{},
- RepliedUser: true,
- }
- 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 {
- ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
- }
- return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
- } else {
- return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
- }
-}
diff --git a/formatter_everyone.go b/pkg/msgconv/formatter_everyone.go
similarity index 98%
rename from formatter_everyone.go
rename to pkg/msgconv/formatter_everyone.go
index b1aed5a..6a2195f 100644
--- a/formatter_everyone.go
+++ b/pkg/msgconv/formatter_everyone.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
+// 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
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package main
+package msgconv
import (
"fmt"
diff --git a/formatter_tag.go b/pkg/msgconv/formatter_tag.go
similarity index 84%
rename from formatter_tag.go
rename to pkg/msgconv/formatter_tag.go
index fb7f741..3d93039 100644
--- a/formatter_tag.go
+++ b/pkg/msgconv/formatter_tag.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// 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
@@ -14,9 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package main
+package msgconv
import (
+ "context"
"fmt"
"math"
"regexp"
@@ -30,14 +31,15 @@ import (
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
+ "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
- "go.mau.fi/mautrix-discord/database"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
)
type astDiscordTag struct {
ast.BaseInline
- portal *Portal
+ portal *bridgev2.Portal
id int64
}
@@ -147,7 +149,7 @@ func (s *discordTagParser) Trigger() []byte {
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
- portal := pc.Get(parserContextPortal).(*Portal)
+ portal := pc.Get(parserContextPortal).(*bridgev2.Portal)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
@@ -261,51 +263,50 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
if !entering {
return
}
+
+ ctx := context.TODO()
+
switch node := n.(type) {
case *astDiscordUserMention:
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()
- }
+ if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, discordid.MakeUserID(strconv.FormatInt(node.id, 10))); ghost != nil {
+ mxid = ghost.Intent.GetMXID()
+ name = ghost.Name
}
_, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), name)
return
case *astDiscordRoleMention:
- role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
- if role != nil {
- _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name)
- return
- }
+ // FIXME(skip): Implement.
+ // role := node.portal.Bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
+ // if role != nil {
+ _, _ = fmt.Fprintf(w, `@unknown-role`)
+ // _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name)
+ return
+ // }
case *astDiscordChannelMention:
- portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
- ChannelID: strconv.FormatInt(node.id, 10),
- Receiver: "",
- })
- if portal != nil {
+ if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(
+ strconv.FormatInt(node.id, 10),
+ )); portal != nil {
if portal.MXID != "" {
- _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name)
+ _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.Bridge.Matrix.ServerName()).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
return
}
case *astDiscordCustomEmoji:
- reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
- if !reactionMXC.IsEmpty() {
- attrs := "data-mx-emoticon"
- if node.animated {
- attrs += " data-mau-animated-emoji"
- }
- _, _ = fmt.Fprintf(w, `
`, reactionMXC.String(), node.name, attrs)
- return
- }
+ // FIXME(skip): Implement.
+ _, _ = fmt.Fprintf(w, `(emoji)`)
+ // reactionMXC := node.portal.Bridge.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
+ // if !reactionMXC.IsEmpty() {
+ // attrs := "data-mx-emoticon"
+ // if node.animated {
+ // attrs += " data-mau-animated-emoji"
+ // }
+ // _, _ = fmt.Fprintf(w, `
`, reactionMXC.String(), node.name, attrs)
+ // return
+ // }
case *astDiscordTimestamp:
ts := time.Unix(node.timestamp, 0).UTC()
var formatted string
diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go
new file mode 100644
index 0000000..662dee7
--- /dev/null
+++ b/pkg/msgconv/from-discord.go
@@ -0,0 +1,648 @@
+// 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 msgconv
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exmaps"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type contextKey int
+
+const (
+ contextKeyPortal contextKey = iota
+ contextKeyIntent
+ contextKeyUserLogin
+ contextKeyDiscordClient
+)
+
+func (mc *MessageConverter) ToMatrix(
+ ctx context.Context,
+ portal *bridgev2.Portal,
+ intent bridgev2.MatrixAPI,
+ source *bridgev2.UserLogin,
+ session *discordgo.Session,
+ msg *discordgo.Message,
+) *bridgev2.ConvertedMessage {
+ ctx = context.WithValue(ctx, contextKeyUserLogin, source)
+ ctx = context.WithValue(ctx, contextKeyIntent, intent)
+ ctx = context.WithValue(ctx, contextKeyPortal, portal)
+ ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
+ predictedLength := len(msg.Attachments) + len(msg.StickerItems)
+ if msg.Content != "" {
+ predictedLength++
+ }
+ parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength)
+ if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil {
+ parts = append(parts, textPart)
+ }
+
+ ctx = zerolog.Ctx(ctx).With().
+ Str("action", "convert discord message to matrix").
+ Str("message_id", msg.ID).
+ Logger().WithContext(ctx)
+ log := zerolog.Ctx(ctx)
+ handledIDs := make(exmaps.Set[string])
+
+ for _, att := range msg.Attachments {
+ if !handledIDs.Add(att.ID) {
+ continue
+ }
+
+ log := log.With().Str("attachment_id", att.ID).Logger()
+ if part := mc.renderDiscordAttachment(log.WithContext(ctx), att); part != nil {
+ parts = append(parts, part)
+ }
+ }
+
+ for _, sticker := range msg.StickerItems {
+ if !handledIDs.Add(sticker.ID) {
+ continue
+ }
+
+ log := log.With().Str("sticker_id", sticker.ID).Logger()
+ if part := mc.renderDiscordSticker(log.WithContext(ctx), sticker); part != nil {
+ parts = append(parts, part)
+ }
+ }
+
+ for i, embed := range msg.Embeds {
+ // Ignore non-video embeds, they're handled in convertDiscordTextMessage
+ if getEmbedType(msg, embed) != EmbedVideo {
+ continue
+ }
+ // Discord deduplicates embeds by URL. It makes things easier for us too.
+ if !handledIDs.Add(embed.URL) {
+ continue
+ }
+
+ log := log.With().
+ Str("computed_embed_type", "video").
+ Str("embed_type", string(embed.Type)).
+ Int("embed_index", i).
+ Logger()
+ part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), embed)
+ if part != nil {
+ parts = append(parts, part)
+ }
+ }
+
+ if len(parts) == 0 && msg.Thread != nil {
+ parts = append(parts, &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
+ }})
+ }
+
+ // TODO(skip): Add extra metadata.
+ // for _, part := range parts {
+ // puppet.addWebhookMeta(part, msg)
+ // puppet.addMemberMeta(part, msg)
+ // }
+
+ // Assign incrementing part IDs.
+ for i, part := range parts {
+ part.ID = networkid.PartID(strconv.Itoa(i))
+ }
+
+ converted := &bridgev2.ConvertedMessage{Parts: parts}
+ // TODO This is sorta gross; it might be worth bundling these parameters
+ // into a struct.
+ mc.tryAddingReplyToConvertedMessage(
+ ctx,
+ converted,
+ portal,
+ source,
+ msg,
+ )
+
+ return converted
+}
+
+const forwardTemplateHTML = `
+↷ Forwarded
+%s
+%s
+
`
+
+const msgInteractionTemplateHTML = `
+%s used /%s
+
`
+
+const msgComponentTemplateHTML = `This message contains interactive elements. Use the Discord app to interact with the message.
`
+
+func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
+ ctx context.Context,
+ converted *bridgev2.ConvertedMessage,
+ portal *bridgev2.Portal,
+ source *bridgev2.UserLogin,
+ msg *discordgo.Message,
+) {
+ ref := msg.MessageReference
+ if ref == nil {
+ return
+ }
+ // TODO: Support threads.
+
+ log := zerolog.Ctx(ctx).With().
+ Str("referenced_channel_id", ref.ChannelID).
+ Str("referenced_guild_id", ref.GuildID).
+ Str("referenced_message_id", ref.MessageID).Logger()
+
+ // The portal containing the message that was replied to.
+ targetPortal := portal
+ if ref.ChannelID != discordid.ParsePortalID(portal.ID) {
+ var err error
+ targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID))
+ if err != nil {
+ log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
+ return
+ }
+
+ if targetPortal == nil {
+ return
+ }
+ }
+
+ messageID := discordid.MakeMessageID(ref.MessageID)
+ repliedToMatrixMsg, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, messageID)
+ if err != nil {
+ log.Err(err).Msg("Failed to query database for first message part; proceeding")
+ return
+ }
+ if repliedToMatrixMsg == nil {
+ log.Debug().Msg("Couldn't find a first message part for reply target; proceeding")
+ return
+ }
+
+ converted.ReplyTo = &networkid.MessageOptionalPartID{
+ MessageID: repliedToMatrixMsg.ID,
+ PartID: &repliedToMatrixMsg.PartID,
+ }
+ converted.ReplyToRoom = targetPortal.PortalKey
+ converted.ReplyToUser = repliedToMatrixMsg.SenderID
+}
+
+func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart {
+ log := zerolog.Ctx(ctx)
+ switch msg.Type {
+ case discordgo.MessageTypeCall:
+ return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
+ MsgType: event.MsgEmote,
+ Body: "started a call",
+ }}
+ case discordgo.MessageTypeGuildMemberJoin:
+ return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
+ MsgType: event.MsgEmote,
+ Body: "joined the server",
+ }}
+ }
+
+ var htmlParts []string
+
+ if msg.Interaction != nil {
+ ghost, err := mc.Bridge.GetGhostByID(ctx, discordid.MakeUserID(msg.Interaction.User.ID))
+ // TODO(skip): Try doing ghost.UpdateInfoIfNecessary.
+ if err == nil {
+ htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name))
+ } else {
+ log.Err(err).Msg("Couldn't get ghost by ID while bridging interaction")
+ }
+ }
+
+ if msg.Content != "" && !isPlainGifMessage(msg) {
+ // Bridge basic text messages.
+ htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(portal, msg.Content, true))
+ } else if msg.MessageReference != nil &&
+ msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
+ len(msg.MessageSnapshots) > 0 &&
+ msg.MessageSnapshots[0].Message != nil {
+ // Bridge forwarded messages.
+ htmlParts = append(htmlParts, mc.forwardedMessageHTMLPart(ctx, portal, source, msg))
+ }
+
+ previews := make([]*event.BeeperLinkPreview, 0)
+ for i, embed := range msg.Embeds {
+ if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
+ continue
+ }
+
+ with := log.With().
+ Str("embed_type", string(embed.Type)).
+ Int("embed_index", i)
+
+ switch getEmbedType(msg, embed) {
+ case EmbedRich:
+ log := with.Str("computed_embed_type", "rich").Logger()
+ htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), embed))
+ case EmbedLinkPreview:
+ log := with.Str("computed_embed_type", "link preview").Logger()
+ previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), embed))
+ case EmbedVideo:
+ // Video embeds are handled as separate messages via renderDiscordVideoEmbed.
+ default:
+ log := with.Logger()
+ log.Warn().Msg("Unknown embed type in message")
+ }
+ }
+
+ if len(msg.Components) > 0 {
+ htmlParts = append(htmlParts, msgComponentTemplateHTML)
+ }
+
+ if len(htmlParts) == 0 {
+ return nil
+ }
+
+ fullHTML := strings.Join(htmlParts, "\n")
+ if !msg.MentionEveryone {
+ fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
+ }
+
+ content := format.HTMLToContent(fullHTML)
+ extraContent := map[string]any{
+ "com.beeper.linkpreviews": previews,
+ }
+
+ return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent}
+}
+
+func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string {
+ log := zerolog.Ctx(ctx)
+
+ forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, 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)
+ if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
+ if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil {
+ // We've bridged the message that was forwarded, so we can link to it directly.
+ origLink = fmt.Sprintf(
+ `#%s • %s`,
+ forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()),
+ forwardedFromPortal.Name,
+ msgTSText,
+ )
+ } else if err != nil {
+ log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message")
+ } else if forwardedFromPortal.MXID != "" {
+ // We don't have the message but we have the portal, so link to that.
+ origLink = fmt.Sprintf(
+ `#%s • %s`,
+ forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()),
+ forwardedFromPortal.Name,
+ msgTSText,
+ )
+ } else if forwardedFromPortal.Name != "" {
+ // We only have the name of the portal.
+ origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText)
+ }
+ } else if err != nil {
+ log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message")
+ }
+
+ return fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink)
+}
+
+func mediaFailedMessage(err error) *event.MessageEventContent {
+ return &event.MessageEventContent{
+ Body: fmt.Sprintf("Failed to bridge media: %v", err),
+ MsgType: event.MsgNotice,
+ }
+}
+
+func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart {
+ var proxyURL string
+ if embed.Video != nil {
+ proxyURL = embed.Video.ProxyURL
+ } else if embed.Thumbnail != nil {
+ proxyURL = embed.Thumbnail.ProxyURL
+ } else {
+ zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
+ return &bridgev2.ConvertedMessagePart{
+ Type: event.EventMessage,
+ Content: &event.MessageEventContent{
+ Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
+ MsgType: event.MsgNotice,
+ },
+ }
+ }
+
+ reupload, err := mc.ReuploadUnknownMedia(ctx, proxyURL, true)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
+ return &bridgev2.ConvertedMessagePart{
+ Type: event.EventMessage,
+ Content: mediaFailedMessage(err),
+ }
+ }
+
+ content := &event.MessageEventContent{
+ Body: embed.URL,
+ URL: reupload.MXC,
+ File: reupload.File,
+ Info: &event.FileInfo{
+ MimeType: reupload.MimeType,
+ Size: reupload.Size,
+ },
+ }
+
+ if embed.Video != nil {
+ content.MsgType = event.MsgVideo
+ content.Info.Width = embed.Video.Width
+ content.Info.Height = embed.Video.Height
+ } else {
+ content.MsgType = event.MsgImage
+ content.Info.Width = embed.Thumbnail.Width
+ content.Info.Height = embed.Thumbnail.Height
+ }
+
+ extra := map[string]any{}
+ if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
+ extra["info"] = map[string]any{
+ "fi.mau.discord.gifv": true,
+ "fi.mau.gif": true,
+ "fi.mau.loop": true,
+ "fi.mau.autoplay": true,
+ "fi.mau.hide_controls": true,
+ "fi.mau.no_audio": true,
+ }
+ }
+
+ return &bridgev2.ConvertedMessagePart{
+ Type: event.EventMessage,
+ Content: content,
+ Extra: extra,
+ }
+}
+
+func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart {
+ panic("unimplemented")
+}
+
+const (
+ embedHTMLWrapper = `%s
`
+ embedHTMLWrapperColor = `%s
`
+ embedHTMLAuthorWithImage = `
%s
`
+ embedHTMLAuthorPlain = `%s
`
+ embedHTMLAuthorLink = `%s`
+ embedHTMLTitleWithLink = `%s
`
+ embedHTMLTitlePlain = `%s
`
+ embedHTMLDescription = `%s
`
+ embedHTMLFieldName = `%s | `
+ embedHTMLFieldValue = `%s | `
+ embedHTMLFields = ``
+ embedHTMLLinearField = `%s
%s
`
+ embedHTMLImage = `
`
+ embedHTMLFooterWithImage = ``
+ embedHTMLFooterPlain = ``
+ embedHTMLFooterOnlyDate = ``
+ embedHTMLDate = ``
+ embedFooterDateSeparator = ` • `
+)
+
+func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, embed *discordgo.MessageEmbed) string {
+ log := zerolog.Ctx(ctx)
+ var htmlParts []string
+ if embed.Author != nil {
+ var authorHTML string
+ authorNameHTML := html.EscapeString(embed.Author.Name)
+ if embed.Author.URL != "" {
+ authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
+ }
+ authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
+ if embed.Author.ProxyIconURL != "" {
+ reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Author.ProxyIconURL, false)
+
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
+ } else {
+ authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, reupload.MXC, authorNameHTML)
+ }
+ }
+ htmlParts = append(htmlParts, authorHTML)
+ }
+
+ portal := ctx.Value(contextKeyPortal).(*bridgev2.Portal)
+ if embed.Title != "" {
+ var titleHTML string
+ baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false)
+ if embed.URL != "" {
+ titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
+ } else {
+ titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
+ }
+ htmlParts = append(htmlParts, titleHTML)
+ }
+
+ if embed.Description != "" {
+ htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(portal, embed.Description, true)))
+ }
+
+ for i := 0; i < len(embed.Fields); i++ {
+ item := embed.Fields[i]
+ // TODO(skip): Port EmbedFieldsAsTables.
+ if false {
+ splitItems := []*discordgo.MessageEmbedField{item}
+ if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
+ splitItems = append(splitItems, embed.Fields[i+1])
+ i++
+ if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
+ splitItems = append(splitItems, embed.Fields[i+1])
+ i++
+ }
+ }
+ headerParts := make([]string, len(splitItems))
+ contentParts := make([]string, len(splitItems))
+ for j, splitItem := range splitItems {
+ headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false))
+ contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true))
+ }
+ htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
+ } else {
+ htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
+ strconv.FormatBool(item.Inline),
+ mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false),
+ mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true),
+ ))
+ }
+ }
+
+ if embed.Image != nil {
+ reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Image.ProxyURL, false)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to reupload image in embed")
+ } else {
+ htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, reupload.MXC))
+ }
+ }
+
+ var embedDateHTML string
+ if embed.Timestamp != "" {
+ formattedTime := embed.Timestamp
+ parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
+ } else {
+ formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
+ }
+ embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
+ }
+
+ if embed.Footer != nil {
+ var footerHTML string
+ var datePart string
+ if embedDateHTML != "" {
+ datePart = embedFooterDateSeparator + embedDateHTML
+ }
+ footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
+ if embed.Footer.ProxyIconURL != "" {
+ reupload, err := mc.ReuploadUnknownMedia(ctx, embed.Footer.ProxyIconURL, false)
+
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
+ } else {
+ footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, reupload.MXC, html.EscapeString(embed.Footer.Text), datePart)
+ }
+ }
+ htmlParts = append(htmlParts, footerHTML)
+ } else if embed.Timestamp != "" {
+ htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
+ }
+
+ if len(htmlParts) == 0 {
+ return ""
+ }
+
+ compiledHTML := strings.Join(htmlParts, "")
+ if embed.Color != 0 {
+ compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
+ } else {
+ compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
+ }
+ return compiledHTML
+}
+
+func (mc *MessageConverter) renderDiscordLinkEmbedImage(
+ ctx context.Context, url string, width, height int, preview *event.BeeperLinkPreview,
+) {
+ reupload, err := mc.ReuploadUnknownMedia(ctx, url, true)
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring")
+ return
+ }
+
+ if width != 0 || height != 0 {
+ preview.ImageWidth = event.IntOrString(width)
+ preview.ImageHeight = event.IntOrString(height)
+ }
+ preview.ImageSize = event.IntOrString(reupload.Size)
+ preview.ImageType = reupload.MimeType
+ preview.ImageURL, preview.ImageEncryption = reupload.MXC, reupload.File
+}
+
+func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview {
+ var preview event.BeeperLinkPreview
+ preview.MatchedURL = embed.URL
+ preview.Title = embed.Title
+ preview.Description = embed.Description
+ if embed.Image != nil {
+ mc.renderDiscordLinkEmbedImage(ctx, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
+ } else if embed.Thumbnail != nil {
+ mc.renderDiscordLinkEmbedImage(ctx, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
+ }
+ return &preview
+}
+
+func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart {
+ // TODO(skip): Support direct media.
+ reupload, err := mc.ReuploadMedia(ctx, att.URL, att.ContentType, att.Filename, att.Size, true)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
+ return &bridgev2.ConvertedMessagePart{
+ Type: event.EventMessage,
+ Content: mediaFailedMessage(err),
+ }
+ }
+
+ content := &event.MessageEventContent{
+ Body: reupload.FileName,
+ Info: &event.FileInfo{
+ Width: att.Width,
+ Height: att.Height,
+ MimeType: reupload.MimeType,
+ Size: reupload.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 = reupload.FileName
+ }
+
+ switch strings.ToLower(strings.Split(content.Info.MimeType, "/")[0]) {
+ case "audio":
+ content.MsgType = event.MsgAudio
+ if att.Waveform != nil {
+ // Bridge a voice message.
+
+ // TODO convert waveform
+ extra["org.matrix.msc1767.audio"] = map[string]any{
+ "duration": int(att.DurationSeconds * 1000),
+ }
+ extra["org.matrix.msc3245.voice"] = map[string]any{}
+ }
+ case "image":
+ content.MsgType = event.MsgImage
+ case "video":
+ content.MsgType = event.MsgVideo
+ default:
+ content.MsgType = event.MsgFile
+ }
+
+ content.URL, content.File = reupload.MXC, reupload.File
+ content.Info.Size = reupload.Size
+ if content.Info.Width == 0 && content.Info.Height == 0 {
+ content.Info.Width = att.Width
+ content.Info.Height = att.Height
+ }
+
+ return &bridgev2.ConvertedMessagePart{
+ Type: event.EventMessage,
+ Content: content,
+ Extra: extra,
+ }
+}
diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go
new file mode 100644
index 0000000..8e328f8
--- /dev/null
+++ b/pkg/msgconv/from-matrix.go
@@ -0,0 +1,204 @@
+// 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 msgconv
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/variationselector"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+const discordEpochMillis = 1420070400000
+
+func generateMessageNonce() string {
+ snowflake := (time.Now().UnixMilli() - discordEpochMillis) << 22
+ // Nonce snowflakes don't have internal IDs or increments
+ return strconv.FormatInt(snowflake, 10)
+}
+
+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 uploadDiscordAttachment(cli *http.Client, url string, data []byte) error {
+ req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+
+ for key, value := range discordgo.DroidBaseHeaders {
+ req.Header.Set(key, value)
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+ req.Header.Set("Referer", "https://discord.com/")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Site", "cross-site")
+
+ resp, err := cli.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode > 300 {
+ respData, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData)
+ }
+ return nil
+}
+
+// ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate
+// for bridging the message to Discord.
+func (mc *MessageConverter) ToDiscord(
+ ctx context.Context,
+ session *discordgo.Session,
+ msg *bridgev2.MatrixMessage,
+) (*discordgo.MessageSend, error) {
+ ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
+ ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
+ var req discordgo.MessageSend
+ req.Nonce = generateMessageNonce()
+ log := zerolog.Ctx(ctx)
+
+ if msg.ReplyTo != nil {
+ req.Reference = &discordgo.MessageReference{
+ ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID),
+ MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
+ }
+ }
+
+ portal := msg.Portal
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ channelID := discordid.ParsePortalID(portal.ID)
+ content := msg.Content
+
+ convertMatrix := func() {
+ req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
+ if content.MsgType == event.MsgEmote {
+ req.Content = fmt.Sprintf("_%s_", req.Content)
+ }
+ }
+
+ switch content.MsgType {
+ case event.MsgText, event.MsgEmote, event.MsgNotice:
+ convertMatrix()
+ case event.MsgAudio, event.MsgFile, event.MsgVideo:
+ mediaData, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
+ if err != nil {
+ log.Err(err).Msg("Failed to download Matrix attachment for bridging")
+ return nil, bridgev2.ErrMediaDownloadFailed
+ }
+
+ filename := content.Body
+ if content.FileName != "" && content.FileName != content.Body {
+ filename = content.FileName
+ convertMatrix()
+ }
+ if msg.Event.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true {
+ filename = "SPOILER_" + filename
+ }
+
+ // TODO: Support attachments for relay/webhook. (A branch was removed here.)
+ att := &discordgo.MessageAttachment{
+ ID: "0",
+ Filename: filename,
+ }
+
+ upload_id := mc.NextDiscordUploadID()
+ log.Debug().Str("upload_id", upload_id).Msg("Preparing attachment")
+ prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{
+ Files: []*discordgo.FilePrepare{{
+ Size: len(mediaData),
+ Name: att.Filename,
+ ID: mc.NextDiscordUploadID(),
+ }},
+ // TODO: Support threads.
+ }, discordgo.WithChannelReferer(guildID, channelID))
+
+ 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)
+ }
+
+ // TODO: Handle (silent) replies and allowed mentions.
+
+ return &req, nil
+}
+
+func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
+ allowedMentions := &discordgo.MessageAllowedMentions{
+ Parse: []discordgo.AllowedMentionType{},
+ Users: []string{},
+ RepliedUser: true,
+ }
+
+ if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
+ ctx := format.NewContext(ctx)
+ ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
+ ctx.ReturnData[formatterContextPortalKey] = portal
+ ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
+ if content.Mentions != nil {
+ ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
+ }
+ return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
+ } else {
+ return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
+ }
+}
diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go
new file mode 100644
index 0000000..9636ac7
--- /dev/null
+++ b/pkg/msgconv/msgconv.go
@@ -0,0 +1,48 @@
+// 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 msgconv
+
+import (
+ "math/rand"
+ "strconv"
+ "sync/atomic"
+
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+type MessageConverter struct {
+ Bridge *bridgev2.Bridge
+
+ nextDiscordUploadID atomic.Int32
+
+ MaxFileSize int64
+}
+
+func NewMessageConverter(bridge *bridgev2.Bridge) *MessageConverter {
+ mc := &MessageConverter{
+ Bridge: bridge,
+ MaxFileSize: 50 * 1024 * 1024,
+ }
+
+ mc.nextDiscordUploadID.Store(rand.Int31n(100))
+ return mc
+}
+
+func (mc *MessageConverter) NextDiscordUploadID() string {
+ val := mc.nextDiscordUploadID.Add(2)
+ return strconv.Itoa(int(val))
+}
diff --git a/remoteauth/README.md b/pkg/remoteauth/README.md
similarity index 100%
rename from remoteauth/README.md
rename to pkg/remoteauth/README.md
diff --git a/remoteauth/client.go b/pkg/remoteauth/client.go
similarity index 100%
rename from remoteauth/client.go
rename to pkg/remoteauth/client.go
diff --git a/remoteauth/clientpackets.go b/pkg/remoteauth/clientpackets.go
similarity index 100%
rename from remoteauth/clientpackets.go
rename to pkg/remoteauth/clientpackets.go
diff --git a/remoteauth/serverpackets.go b/pkg/remoteauth/serverpackets.go
similarity index 98%
rename from remoteauth/serverpackets.go
rename to pkg/remoteauth/serverpackets.go
index b7376d3..5e44037 100644
--- a/remoteauth/serverpackets.go
+++ b/pkg/remoteauth/serverpackets.go
@@ -103,6 +103,7 @@ func (h *serverHello) process(client *Client) error {
ticker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
go func() {
defer ticker.Stop()
+ //lint:ignore S1000 -
for {
select {
// case <-client.ctx.Done():
@@ -126,7 +127,7 @@ func (h *serverHello) process(client *Client) error {
<-time.After(duration)
client.Lock()
- client.err = fmt.Errorf("Timed out after %s", duration)
+ client.err = fmt.Errorf("timed out after %s", duration)
client.close()
client.Unlock()
}()
diff --git a/remoteauth/user.go b/pkg/remoteauth/user.go
similarity index 100%
rename from remoteauth/user.go
rename to pkg/remoteauth/user.go
diff --git a/portal.go b/portal.go
deleted file mode 100644
index db26a0e..0000000
--- a/portal.go
+++ /dev/null
@@ -1,2673 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "reflect"
- "regexp"
- "slices"
- "strconv"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "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"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/crypto/attachment"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-type portalDiscordMessage struct {
- msg interface{}
- user *User
-
- thread *Thread
-}
-
-type portalMatrixMessage struct {
- evt *event.Event
- user *User
-}
-
-var relayClient, _ = discordgo.New("")
-
-type Portal struct {
- *database.Portal
-
- Parent *Portal
- Guild *Guild
-
- bridge *DiscordBridge
- log zerolog.Logger
-
- roomCreateLock sync.Mutex
- encryptLock sync.Mutex
-
- discordMessages chan portalDiscordMessage
- matrixMessages chan portalMatrixMessage
-
- recentMessages *exsync.RingBuffer[string, *discordgo.Message]
-
- commands map[string]*discordgo.ApplicationCommand
- commandsLock sync.RWMutex
-
- forwardBackfillLock sync.Mutex
-
- currentlyTyping []id.UserID
- currentlyTypingLock sync.Mutex
-}
-
-const recentMessageBufferSize = 32
-
-var _ bridge.Portal = (*Portal)(nil)
-var _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil)
-var _ bridge.MembershipHandlingPortal = (*Portal)(nil)
-var _ bridge.TypingPortal = (*Portal)(nil)
-
-//var _ bridge.MetaHandlingPortal = (*Portal)(nil)
-//var _ bridge.DisappearingPortal = (*Portal)(nil)
-
-func (portal *Portal) IsEncrypted() bool {
- return portal.Encrypted
-}
-
-func (portal *Portal) MarkEncrypted() {
- portal.Encrypted = true
- portal.Update()
-}
-
-func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) {
- if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.RelayWebhookID != "" {
- portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt}
- }
-}
-
-var (
- portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
-)
-
-func (br *DiscordBridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey, chanType discordgo.ChannelType) *Portal {
- if dbPortal == nil {
- if key == nil || chanType < 0 {
- return nil
- }
-
- dbPortal = br.DB.Portal.New()
- dbPortal.Key = *key
- dbPortal.Type = chanType
- dbPortal.Insert()
- }
-
- portal := br.NewPortal(dbPortal)
-
- br.portalsByID[portal.Key] = portal
- if portal.MXID != "" {
- br.portalsByMXID[portal.MXID] = portal
- }
-
- if portal.GuildID != "" {
- portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true)
- }
- if portal.ParentID != "" {
- parentKey := database.NewPortalKey(portal.ParentID, "")
- var ok bool
- portal.Parent, ok = br.portalsByID[parentKey]
- if !ok {
- portal.Parent = br.loadPortal(br.DB.Portal.GetByID(parentKey), nil, -1)
- }
- }
-
- return portal
-}
-
-func (br *DiscordBridge) GetPortalByMXID(mxid id.RoomID) *Portal {
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
-
- portal, ok := br.portalsByMXID[mxid]
- if !ok {
- return br.loadPortal(br.DB.Portal.GetByMXID(mxid), nil, -1)
- }
-
- return portal
-}
-
-func (user *User) GetPortalByMeta(meta *discordgo.Channel) *Portal {
- return user.GetPortalByID(meta.ID, meta.Type)
-}
-
-func (user *User) GetExistingPortalByID(id string) *Portal {
- return user.bridge.GetExistingPortalByID(database.NewPortalKey(id, user.DiscordID))
-}
-
-func (user *User) GetPortalByID(id string, chanType discordgo.ChannelType) *Portal {
- return user.bridge.GetPortalByID(database.NewPortalKey(id, user.DiscordID), chanType)
-}
-
-func (user *User) FindPrivateChatWith(userID string) *Portal {
- user.bridge.portalsLock.Lock()
- defer user.bridge.portalsLock.Unlock()
- dbPortal := user.bridge.DB.Portal.FindPrivateChatBetween(userID, user.DiscordID)
- if dbPortal == nil {
- return nil
- }
- existing, ok := user.bridge.portalsByID[dbPortal.Key]
- if ok {
- return existing
- }
- return user.bridge.loadPortal(dbPortal, nil, discordgo.ChannelTypeDM)
-}
-
-func (br *DiscordBridge) GetExistingPortalByID(key database.PortalKey) *Portal {
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- portal, ok := br.portalsByID[key]
- if !ok {
- if key.Receiver != "" {
- portal, ok = br.portalsByID[database.NewPortalKey(key.ChannelID, "")]
- }
- if !ok {
- return br.loadPortal(br.DB.Portal.GetByID(key), nil, -1)
- }
- }
-
- return portal
-}
-
-func (br *DiscordBridge) GetPortalByID(key database.PortalKey, chanType discordgo.ChannelType) *Portal {
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- if chanType != discordgo.ChannelTypeDM {
- key.Receiver = ""
- }
-
- portal, ok := br.portalsByID[key]
- if !ok {
- return br.loadPortal(br.DB.Portal.GetByID(key), &key, chanType)
- }
-
- return portal
-}
-
-func (br *DiscordBridge) GetAllPortals() []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.GetAll())
-}
-
-func (br *DiscordBridge) GetAllPortalsInGuild(guildID string) []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.GetAllInGuild(guildID))
-}
-
-func (br *DiscordBridge) GetAllIPortals() (iportals []bridge.Portal) {
- portals := br.GetAllPortals()
- iportals = make([]bridge.Portal, len(portals))
- for i, portal := range portals {
- iportals[i] = portal
- }
- return iportals
-}
-
-func (br *DiscordBridge) GetDMPortalsWith(otherUserID string) []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(otherUserID))
-}
-
-func (br *DiscordBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal {
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
-
- output := make([]*Portal, len(dbPortals))
- for index, dbPortal := range dbPortals {
- if dbPortal == nil {
- continue
- }
-
- portal, ok := br.portalsByID[dbPortal.Key]
- if !ok {
- portal = br.loadPortal(dbPortal, nil, -1)
- }
-
- output[index] = portal
- }
-
- return output
-}
-
-func (br *DiscordBridge) NewPortal(dbPortal *database.Portal) *Portal {
- portal := &Portal{
- Portal: dbPortal,
- bridge: br,
- log: br.ZLog.With().
- Str("channel_id", dbPortal.Key.ChannelID).
- Str("channel_receiver", dbPortal.Key.Receiver).
- Str("room_id", dbPortal.MXID.String()).
- Logger(),
-
- discordMessages: make(chan portalDiscordMessage, br.Config.Bridge.PortalMessageBuffer),
- matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer),
-
- recentMessages: exsync.NewRingBuffer[string, *discordgo.Message](recentMessageBufferSize),
-
- commands: make(map[string]*discordgo.ApplicationCommand),
- }
-
- go portal.messageLoop()
-
- return portal
-}
-
-func (portal *Portal) messageLoop() {
- for {
- select {
- case msg := <-portal.matrixMessages:
- portal.handleMatrixMessages(msg)
- case msg := <-portal.discordMessages:
- portal.handleDiscordMessages(msg)
- }
- }
-}
-
-func (portal *Portal) IsPrivateChat() bool {
- return portal.Type == discordgo.ChannelTypeDM
-}
-
-func (portal *Portal) MainIntent() *appservice.IntentAPI {
- if portal.IsPrivateChat() && portal.OtherUserID != "" {
- return portal.bridge.GetPuppetByID(portal.OtherUserID).DefaultIntent()
- }
-
- return portal.bridge.Bot
-}
-
-type CustomBridgeInfoContent struct {
- event.BridgeEventContent
- RoomType string `json:"com.beeper.room_type,omitempty"`
- RoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"`
-}
-
-func init() {
- event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{})
- event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{})
-}
-
-func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) {
- bridgeInfo := event.BridgeEventContent{
- BridgeBot: portal.bridge.Bot.UserID,
- Creator: portal.MainIntent().UserID,
- Protocol: event.BridgeInfoSection{
- ID: "discordgo",
- DisplayName: "Discord",
- AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
- ExternalURL: "https://discord.com/",
- },
- Channel: event.BridgeInfoSection{
- ID: portal.Key.ChannelID,
- DisplayName: portal.Name,
- },
- }
- var bridgeInfoStateKey string
- if portal.GuildID == "" {
- bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/dm/%s", portal.Key.ChannelID)
- bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/@me/%s", portal.Key.ChannelID)
- } else {
- bridgeInfo.Network = &event.BridgeInfoSection{
- ID: portal.GuildID,
- }
- if portal.Guild != nil {
- bridgeInfo.Network.DisplayName = portal.Guild.Name
- bridgeInfo.Network.AvatarURL = portal.Guild.AvatarURL.CUString()
- // TODO is it possible to find the URL?
- }
- bridgeInfoStateKey = fmt.Sprintf("fi.mau.discord://discord/%s/%s", portal.GuildID, portal.Key.ChannelID)
- bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://discord.com/channels/%s/%s", portal.GuildID, portal.Key.ChannelID)
- }
- var roomType string
- if portal.Type == discordgo.ChannelTypeDM || portal.Type == discordgo.ChannelTypeGroupDM {
- roomType = "dm"
- }
- 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() {
- if len(portal.MXID) == 0 {
- portal.log.Debug().Msg("Not updating bridge info: no Matrix room created")
- return
- }
- portal.log.Debug().Msg("Updating bridge info...")
- stateKey, content := portal.getBridgeInfo()
- _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content)
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to update m.bridge")
- }
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content)
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge")
- }
-}
-
-func (portal *Portal) shouldSetDMRoomMetadata() bool {
- return !portal.IsPrivateChat() ||
- portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" ||
- (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never")
-}
-
-func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) {
- evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
- if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom {
- evt.RotationPeriodMillis = rot.Milliseconds
- evt.RotationPeriodMessages = rot.Messages
- }
- return
-}
-
-func (portal *Portal) CreateMatrixRoom(user *User, channel *discordgo.Channel) error {
- portal.roomCreateLock.Lock()
- defer portal.roomCreateLock.Unlock()
- if portal.MXID != "" {
- portal.ensureUserInvited(user, false)
- return nil
- }
- portal.log.Info().Msg("Creating Matrix room for channel")
-
- channel = portal.UpdateInfo(user, channel)
- if channel == nil {
- return fmt.Errorf("didn't find channel metadata")
- }
-
- intent := portal.MainIntent()
- if err := intent.EnsureRegistered(); err != nil {
- return err
- }
-
- bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo()
- initialState := []*event.Event{{
- Type: event.StateBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }, {
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- Type: event.StateHalfShotBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }}
-
- var invite []id.UserID
-
- if portal.bridge.Config.Bridge.Encryption.Default {
- initialState = append(initialState, &event.Event{
- Type: event.StateEncryption,
- Content: event.Content{
- Parsed: portal.GetEncryptionEventContent(),
- },
- })
- portal.Encrypted = true
-
- if portal.IsPrivateChat() {
- invite = append(invite, portal.bridge.Bot.UserID)
- }
- }
-
- if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() {
- initialState = append(initialState, &event.Event{
- Type: event.StateRoomAvatar,
- Content: event.Content{Parsed: &event.RoomAvatarEventContent{
- URL: portal.AvatarURL,
- }},
- })
- portal.AvatarSet = true
- } else {
- portal.AvatarSet = false
- }
-
- creationContent := make(map[string]interface{})
- if portal.Type == discordgo.ChannelTypeGuildCategory {
- creationContent["type"] = event.RoomTypeSpace
- }
- if !portal.bridge.Config.Bridge.FederateRooms {
- creationContent["m.federate"] = false
- }
- spaceID := portal.ExpectedSpaceID()
- if spaceID != "" {
- spaceIDStr := spaceID.String()
- initialState = append(initialState, &event.Event{
- Type: event.StateSpaceParent,
- StateKey: &spaceIDStr,
- Content: event.Content{Parsed: &event.SpaceParentEventContent{
- Via: []string{portal.bridge.AS.HomeserverDomain},
- Canonical: true,
- }},
- })
- }
- if portal.bridge.Config.Bridge.RestrictedRooms && portal.Guild != nil && portal.Guild.MXID != "" {
- // TODO don't do this for private channels in guilds
- initialState = append(initialState, &event.Event{
- Type: event.StateJoinRules,
- Content: event.Content{Parsed: &event.JoinRulesEventContent{
- JoinRule: event.JoinRuleRestricted,
- Allow: []event.JoinRuleAllow{{
- RoomID: portal.Guild.MXID,
- Type: event.JoinRuleAllowRoomMembership,
- }},
- }},
- })
- }
-
- req := &mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: portal.Name,
- Topic: portal.Topic,
- Invite: invite,
- Preset: "private_chat",
- IsDirect: portal.IsPrivateChat(),
- InitialState: initialState,
- CreationContent: creationContent,
- RoomVersion: "11",
- }
- if !portal.shouldSetDMRoomMetadata() && !portal.FriendNick {
- req.Name = ""
- }
-
- var backfillStarted bool
- portal.forwardBackfillLock.Lock()
- defer func() {
- if !backfillStarted {
- portal.log.Debug().Msg("Backfill wasn't started, unlocking forward backfill lock")
- portal.forwardBackfillLock.Unlock()
- }
- }()
-
- resp, err := intent.CreateRoom(req)
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to create room")
- return err
- }
-
- portal.NameSet = len(req.Name) > 0
- portal.TopicSet = len(req.Topic) > 0
- portal.MXID = resp.RoomID
- 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.bridge.portalsLock.Lock()
- portal.bridge.portalsByMXID[portal.MXID] = portal
- portal.bridge.portalsLock.Unlock()
- portal.Update()
- portal.log.Info().Msg("Matrix room created")
-
- if portal.Encrypted && portal.IsPrivateChat() {
- err = portal.bridge.Bot.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client})
- if err != nil {
- portal.log.Err(err).Msg("Failed to ensure bridge bot is joined to encrypted private chat portal")
- }
- }
-
- if portal.GuildID == "" {
- user.addPrivateChannelToSpace(portal)
- } else {
- portal.updateSpace(user)
- }
- portal.ensureUserInvited(user, true)
- user.syncChatDoublePuppetDetails(portal, true)
-
- portal.syncParticipants(user, channel.Recipients)
-
- if portal.IsPrivateChat() {
- puppet := user.bridge.GetPuppetByID(portal.Key.Receiver)
-
- chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}
- user.updateDirectChats(chats)
- }
-
- firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, portalCreationDummyEvent, struct{}{})
- if err != nil {
- portal.log.Err(err).Msg("Failed to send dummy event to mark portal creation")
- } else {
- portal.FirstEventID = firstEventResp.EventID
- portal.Update()
- }
-
- go portal.forwardBackfillInitial(user, nil)
- backfillStarted = true
-
- return nil
-}
-
-func (portal *Portal) handleDiscordMessages(msg portalDiscordMessage) {
- if portal.MXID == "" {
- msgCreate, ok := msg.msg.(*discordgo.MessageCreate)
- if !ok {
- portal.log.Warn().Msg("Can't create Matrix room from non new message event")
- return
- }
-
- portal.log.Debug().
- Str("message_id", msgCreate.ID).
- Msg("Creating Matrix room from incoming message")
- if err := portal.CreateMatrixRoom(msg.user, nil); err != nil {
- portal.log.Err(err).Msg("Failed to create portal room")
- return
- }
- }
- portal.forwardBackfillLock.Lock()
- defer portal.forwardBackfillLock.Unlock()
-
- switch convertedMsg := msg.msg.(type) {
- case *discordgo.MessageCreate:
- portal.handleDiscordMessageCreate(msg.user, convertedMsg.Message, msg.thread)
- case *discordgo.MessageUpdate:
- portal.handleDiscordMessageUpdate(msg.user, convertedMsg.Message)
- case *discordgo.MessageDelete:
- portal.handleDiscordMessageDelete(msg.user, convertedMsg.Message)
- case *discordgo.MessageDeleteBulk:
- portal.handleDiscordMessageDeleteBulk(msg.user, convertedMsg.Messages)
- case *discordgo.MessageReactionAdd:
- portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, true, msg.thread, convertedMsg.Member)
- case *discordgo.MessageReactionRemove:
- portal.handleDiscordReaction(msg.user, convertedMsg.MessageReaction, false, msg.thread, nil)
- default:
- portal.log.Warn().Type("message_type", msg.msg).Msg("Unknown message type in handleDiscordMessages")
- }
-}
-
-func (portal *Portal) ensureUserInvited(user *User, ignoreCache bool) bool {
- return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat(), ignoreCache)
-}
-
-func (portal *Portal) markMessageHandled(discordID string, authorID string, timestamp time.Time, threadID string, senderMXID id.UserID, parts []database.MessagePart) *database.Message {
- msg := portal.bridge.DB.Message.New()
- msg.Channel = portal.Key
- msg.DiscordID = discordID
- msg.SenderID = authorID
- msg.Timestamp = timestamp
- msg.ThreadID = threadID
- msg.SenderMXID = senderMXID
- msg.MassInsertParts(parts)
- msg.MXID = parts[0].MXID
- msg.AttachmentID = parts[0].AttachmentID
- return msg
-}
-
-func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message, thread *Thread) {
- switch msg.Type {
- case discordgo.MessageTypeChannelNameChange, discordgo.MessageTypeChannelIconChange, discordgo.MessageTypeChannelPinnedMessage:
- // These are handled via channel updates
- return
- }
-
- log := portal.log.With().
- Str("message_id", msg.ID).
- Int("message_type", int(msg.Type)).
- Str("author_id", msg.Author.ID).
- Str("action", "discord message create").
- Logger()
- ctx := log.WithContext(context.Background())
-
- portal.recentMessages.Push(msg.ID, msg)
-
- existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID)
- if existing != nil {
- log.Debug().Msg("Dropping duplicate message")
- return
- }
-
- handlingStartTime := time.Now()
- puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
- puppet.UpdateInfo(user, msg.Author, msg)
- intent := puppet.IntentFor(portal)
-
- var discordThreadID string
- var threadRootEvent, lastThreadEvent id.EventID
- if thread != nil {
- discordThreadID = thread.ID
- threadRootEvent = thread.RootMXID
- lastThreadEvent = threadRootEvent
- lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
- if lastInThread != nil {
- lastThreadEvent = lastInThread.MXID
- }
- }
- replyTo := portal.getReplyTarget(user, discordThreadID, msg.MessageReference, msg.Embeds, false)
- mentions := portal.convertDiscordMentions(msg, true)
-
- ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
- parts := portal.convertDiscordMessage(ctx, puppet, intent, msg)
- dbParts := make([]database.MessagePart, 0, len(parts))
- eventIDs := zerolog.Dict()
- for i, part := range parts {
- if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
- part.Content.RelatesTo = &event.RelatesTo{}
- }
- if threadRootEvent != "" {
- part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
- }
- if replyTo != nil {
- part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
- if replyTo.UnstableRoomID != "" {
- part.Content.RelatesTo.InReplyTo.UnstableRoomID = replyTo.UnstableRoomID
- }
- // Only set reply for first event
- replyTo = nil
- }
-
- part.Content.Mentions = mentions
- // Only set mentions for first event, but keep empty object for rest
- mentions = &event.Mentions{}
-
- resp, err := portal.sendMatrixMessage(intent, part.Type, part.Content, part.Extra, ts.UnixMilli())
- if err != nil {
- log.Err(err).
- Int("part_index", i).
- Str("attachment_id", part.AttachmentID).
- Msg("Failed to send part of message to Matrix")
- continue
- }
- lastThreadEvent = resp.EventID
- dbParts = append(dbParts, database.MessagePart{AttachmentID: part.AttachmentID, MXID: resp.EventID})
- eventIDs.Str(part.AttachmentID, resp.EventID.String())
- }
-
- log = log.With().Dur("handling_time", time.Since(handlingStartTime)).Logger()
- if len(parts) == 0 {
- log.Warn().Msg("Unhandled message")
- } else if len(dbParts) == 0 {
- log.Warn().Msg("All parts of message failed to send to Matrix")
- } else {
- log.Debug().Dict("event_ids", eventIDs).Msg("Finished handling Discord message")
- firstDBMessage := portal.markMessageHandled(msg.ID, msg.Author.ID, ts, discordThreadID, intent.UserID, dbParts)
- if msg.Flags == discordgo.MessageFlagsHasThread {
- portal.bridge.threadFound(ctx, user, firstDBMessage, msg.ID, msg.Thread)
- }
- }
-}
-
-var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`)
-
-func isReplyEmbed(embed *discordgo.MessageEmbed) bool {
- return hackyReplyPattern.MatchString(embed.Description)
-}
-
-func (portal *Portal) getReplyTarget(source *User, threadID string, ref *discordgo.MessageReference, embeds []*discordgo.MessageEmbed, allowNonExistent bool) *event.InReplyTo {
- if ref == nil && len(embeds) > 0 {
- match := hackyReplyPattern.FindStringSubmatch(embeds[0].Description)
- if match != nil && match[1] == portal.GuildID && (match[2] == portal.Key.ChannelID || match[2] == threadID) {
- ref = &discordgo.MessageReference{
- MessageID: match[3],
- ChannelID: match[2],
- GuildID: match[1],
- }
- }
- }
- if ref == nil {
- return nil
- }
- // TODO add config option for cross-room replies
- crossRoomReplies := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry
-
- targetPortal := portal
- if ref.ChannelID != portal.Key.ChannelID && ref.ChannelID != threadID && crossRoomReplies {
- targetPortal = portal.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: ref.ChannelID, Receiver: source.DiscordID})
- if targetPortal == nil {
- return nil
- }
- }
- replyToMsg := portal.bridge.DB.Message.GetByDiscordID(targetPortal.Key, ref.MessageID)
- if len(replyToMsg) > 0 {
- if !crossRoomReplies {
- return &event.InReplyTo{EventID: replyToMsg[0].MXID}
- }
- return &event.InReplyTo{
- EventID: replyToMsg[0].MXID,
- UnstableRoomID: targetPortal.MXID,
- }
- } else if allowNonExistent {
- return &event.InReplyTo{
- EventID: targetPortal.deterministicEventID(ref.MessageID, ""),
- UnstableRoomID: targetPortal.MXID,
- }
- }
- return nil
-}
-
-const JoinThreadReaction = "join thread"
-
-func (portal *Portal) sendThreadCreationNotice(ctx context.Context, thread *Thread) {
- thread.creationNoticeLock.Lock()
- defer thread.creationNoticeLock.Unlock()
- if thread.CreationNoticeMXID != "" {
- return
- }
- creationNotice := "Thread created. React to this message with \"join thread\" to join the thread on Discord."
- if portal.bridge.Config.Bridge.AutojoinThreadOnOpen {
- creationNotice = "Thread created. Opening this thread will auto-join you to it on Discord."
- }
- log := zerolog.Ctx(ctx)
- resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
- Body: creationNotice,
- MsgType: event.MsgNotice,
- RelatesTo: (&event.RelatesTo{}).SetThread(thread.RootMXID, thread.RootMXID),
- }, nil, time.Now().UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send thread creation notice")
- return
- }
- portal.bridge.threadsLock.Lock()
- thread.CreationNoticeMXID = resp.EventID
- portal.bridge.threadsByCreationNoticeMXID[resp.EventID] = thread
- portal.bridge.threadsLock.Unlock()
- thread.Update()
- log.Debug().
- Str("creation_notice_mxid", thread.CreationNoticeMXID.String()).
- Msg("Sent thread creation notice")
-
- resp, err = portal.MainIntent().SendMessageEvent(portal.MXID, event.EventReaction, &event.ReactionEventContent{
- RelatesTo: event.RelatesTo{
- Type: event.RelAnnotation,
- EventID: thread.CreationNoticeMXID,
- Key: JoinThreadReaction,
- },
- })
- if err != nil {
- log.Err(err).Msg("Failed to send prefilled reaction to thread creation notice")
- } else {
- log.Debug().
- Str("reaction_event_id", resp.EventID.String()).
- Str("creation_notice_mxid", thread.CreationNoticeMXID.String()).
- Msg("Sent prefilled reaction to thread creation notice")
- }
-}
-
-func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Message) {
- log := portal.log.With().
- Str("message_id", msg.ID).
- Str("action", "discord message update").
- Logger()
- ctx := log.WithContext(context.Background())
- if portal.MXID == "" {
- log.Warn().Msg("handle message called without a valid portal")
- return
- }
-
- existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID)
- if existing == nil {
- log.Warn().Msg("Dropping update of unknown message")
- return
- }
- if msg.EditedTimestamp != nil && !msg.EditedTimestamp.After(existing[0].EditTimestamp) {
- log.Debug().
- Time("received_edit_ts", *msg.EditedTimestamp).
- Time("db_edit_ts", existing[0].EditTimestamp).
- Msg("Dropping update of message with older or equal edit timestamp")
- return
- }
-
- if msg.Flags == discordgo.MessageFlagsHasThread {
- portal.bridge.threadFound(ctx, user, existing[0], msg.ID, msg.Thread)
- }
-
- if msg.Author == nil {
- creationMessage, ok := portal.recentMessages.Get(msg.ID)
- if !ok {
- log.Debug().Msg("Dropping edit with no author of non-recent message")
- return
- } else if creationMessage.Type == discordgo.MessageTypeCall {
- log.Debug().Msg("Dropping edit with of call message")
- return
- }
- log.Debug().Msg("Found original message in cache for edit without author")
- if len(msg.Embeds) > 0 {
- creationMessage.Embeds = msg.Embeds
- }
- if len(msg.Attachments) > 0 {
- creationMessage.Attachments = msg.Attachments
- }
- if len(msg.Components) > 0 {
- creationMessage.Components = msg.Components
- }
- // TODO are there other fields that need copying?
- msg = creationMessage
- } else {
- portal.recentMessages.Replace(msg.ID, msg)
- }
- if msg.Author.ID == portal.RelayWebhookID {
- log.Debug().
- Str("message_id", msg.ID).
- Str("author_id", msg.Author.ID).
- Msg("Dropping edit from relay webhook")
- return
- }
-
- puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
- intent := puppet.IntentFor(portal)
-
- redactions := zerolog.Dict()
- attachmentMap := map[string]*database.Message{}
- for _, existingPart := range existing {
- if existingPart.AttachmentID != "" {
- attachmentMap[existingPart.AttachmentID] = existingPart
- }
- }
- for _, remainingAttachment := range msg.Attachments {
- if _, found := attachmentMap[remainingAttachment.ID]; found {
- delete(attachmentMap, remainingAttachment.ID)
- }
- }
- for _, remainingSticker := range msg.StickerItems {
- if _, found := attachmentMap[remainingSticker.ID]; found {
- delete(attachmentMap, remainingSticker.ID)
- }
- }
- for _, remainingEmbed := range msg.Embeds {
- // Other types of embeds are sent inline with the text message part
- if getEmbedType(nil, remainingEmbed) != EmbedVideo {
- continue
- }
- embedID := "video_" + remainingEmbed.URL
- if _, found := attachmentMap[embedID]; found {
- delete(attachmentMap, embedID)
- }
- }
- for _, deletedAttachment := range attachmentMap {
- resp, err := intent.RedactEvent(portal.MXID, deletedAttachment.MXID)
- if err != nil {
- log.Err(err).
- Str("event_id", deletedAttachment.MXID.String()).
- Msg("Failed to redact attachment")
- } else {
- redactions.Str(deletedAttachment.AttachmentID, resp.EventID.String())
- }
- deletedAttachment.Delete()
- }
-
- var converted *ConvertedMessage
- // Slightly hacky special case: messages with gif links will get an embed with the gif.
- // The link isn't rendered on Discord, so just edit the link message into a gif message on Matrix too.
- if isPlainGifMessage(msg) {
- converted = portal.convertDiscordVideoEmbed(ctx, intent, msg.Embeds[0])
- } else {
- converted = portal.convertDiscordTextMessage(ctx, intent, msg)
- }
- if converted == nil {
- log.Debug().
- Bool("has_message_on_matrix", existing[0].AttachmentID == "").
- Bool("has_text_on_discord", len(msg.Content) > 0).
- Msg("Dropping non-text edit")
- return
- }
- puppet.addWebhookMeta(converted, msg)
- puppet.addMemberMeta(converted, msg)
- converted.Content.Mentions = portal.convertDiscordMentions(msg, false)
- converted.Content.SetEdit(existing[0].MXID)
- // Never actually mention new users of edits, only include mentions inside m.new_content
- converted.Content.Mentions = &event.Mentions{}
- if converted.Extra != nil {
- converted.Extra = map[string]any{
- "m.new_content": converted.Extra,
- }
- }
-
- var editTS int64
- if msg.EditedTimestamp != nil {
- editTS = msg.EditedTimestamp.UnixMilli()
- }
- // TODO figure out some way to deduplicate outgoing edits
- resp, err := portal.sendMatrixMessage(intent, event.EventMessage, converted.Content, converted.Extra, editTS)
- if err != nil {
- log.Err(err).Msg("Failed to send edit to Matrix")
- return
- }
-
- portal.sendDeliveryReceipt(resp.EventID)
-
- if msg.EditedTimestamp != nil {
- existing[0].UpdateEditTimestamp(*msg.EditedTimestamp)
- }
- log.Debug().
- Str("event_id", resp.EventID.String()).
- Dict("redacted_attachments", redactions).
- Msg("Finished handling Discord edit")
-}
-
-func (portal *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message) {
- lastResp := portal.redactAllParts(portal.MainIntent(), msg.ID)
- if lastResp != "" {
- portal.sendDeliveryReceipt(lastResp)
- }
-}
-
-func (portal *Portal) handleDiscordMessageDeleteBulk(user *User, messages []string) {
- intent := portal.MainIntent()
- var lastResp id.EventID
- for _, msgID := range messages {
- newLastResp := portal.redactAllParts(intent, msgID)
- if newLastResp != "" {
- lastResp = newLastResp
- }
- }
- if lastResp != "" {
- portal.sendDeliveryReceipt(lastResp)
- }
-}
-
-func (portal *Portal) redactAllParts(intent *appservice.IntentAPI, msgID string) (lastResp id.EventID) {
- existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msgID)
- for _, dbMsg := range existing {
- resp, err := intent.RedactEvent(portal.MXID, dbMsg.MXID)
- if err != nil {
- portal.log.Err(err).
- Str("message_id", msgID).
- Str("event_id", dbMsg.MXID.String()).
- Msg("Failed to redact Matrix message")
- } else if resp != nil && resp.EventID != "" {
- lastResp = resp.EventID
- }
- dbMsg.Delete()
- }
- return
-}
-
-func (portal *Portal) handleDiscordTyping(evt *discordgo.TypingStart) {
- puppet := portal.bridge.GetPuppetByID(evt.UserID)
- if puppet.Name == "" {
- // Puppet hasn't been synced yet
- return
- }
- log := portal.log.With().
- Str("ghost_mxid", puppet.MXID.String()).
- Str("action", "discord typing").
- Logger()
- intent := puppet.IntentFor(portal)
- err := intent.EnsureJoined(portal.MXID)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to ensure ghost is joined for typing notification")
- return
- }
- _, err = intent.UserTyping(portal.MXID, true, 12*time.Second)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to send typing notification to Matrix")
- }
-}
-
-func (portal *Portal) syncParticipant(source *User, participant *discordgo.User, remove bool) {
- puppet := portal.bridge.GetPuppetByID(participant.ID)
- puppet.UpdateInfo(source, participant, nil)
- log := portal.log.With().
- Str("participant_id", participant.ID).
- Str("ghost_mxid", puppet.MXID.String()).
- Logger()
-
- user := portal.bridge.GetUserByID(participant.ID)
- if user != nil {
- log.Debug().Msg("Ensuring Matrix user is invited or joined to room")
- portal.ensureUserInvited(user, false)
- }
-
- if remove {
- _, err := puppet.DefaultIntent().LeaveRoom(portal.MXID)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to make ghost leave room after member remove event")
- }
- } else if user == nil || !puppet.IntentFor(portal).IsCustomPuppet {
- if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil {
- log.Warn().Err(err).Msg("Failed to add ghost to room")
- }
- }
-}
-
-func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) {
- for _, participant := range participants {
- puppet := portal.bridge.GetPuppetByID(participant.ID)
- puppet.UpdateInfo(source, participant, nil)
-
- var user *User
- if participant.ID != portal.OtherUserID {
- user = portal.bridge.GetUserByID(participant.ID)
- if user != nil {
- portal.ensureUserInvited(user, false)
- }
- }
-
- if user == nil || !puppet.IntentFor(portal).IsCustomPuppet {
- if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil {
- portal.log.Warn().Err(err).
- Str("participant_id", participant.ID).
- Msg("Failed to add ghost to room")
- }
- }
- }
-}
-
-func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) {
- if !portal.Encrypted || portal.bridge.Crypto == nil {
- return eventType, nil
- }
- intent.AddDoublePuppetValue(content)
- // TODO maybe the locking should be inside mautrix-go?
- portal.encryptLock.Lock()
- err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content)
- portal.encryptLock.Unlock()
- if err != nil {
- return eventType, fmt.Errorf("failed to encrypt event: %w", err)
- }
- return event.EventEncrypted, nil
-}
-
-func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
- wrappedContent := event.Content{Parsed: content, Raw: extraContent}
- var err error
- eventType, err = portal.encrypt(intent, &wrappedContent, eventType)
- if err != nil {
- return nil, err
- }
-
- _, _ = intent.UserTyping(portal.MXID, false, 0)
- if timestamp == 0 {
- return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
- } else {
- return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
- }
-}
-
-func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) {
- portal.forwardBackfillLock.Lock()
- defer portal.forwardBackfillLock.Unlock()
- switch msg.evt.Type {
- case event.EventMessage, event.EventSticker:
- portal.handleMatrixMessage(msg.user, msg.evt)
- case event.EventRedaction:
- portal.handleMatrixRedaction(msg.user, msg.evt)
- case event.EventReaction:
- portal.handleMatrixReaction(msg.user, msg.evt)
- default:
- portal.log.Warn().Str("event_type", msg.evt.Type.Type).Msg("Unknown event type in handleMatrixMessages")
- }
-}
-
-const discordEpoch = 1420070400000
-
-func generateNonce() string {
- snowflake := (time.Now().UnixMilli() - discordEpoch) << 22
- // Nonce snowflakes don't have internal IDs or increments
- return strconv.FormatInt(snowflake, 10)
-}
-
-func (portal *Portal) getEvent(mxid id.EventID) (*event.Event, error) {
- evt, err := portal.MainIntent().GetEvent(portal.MXID, mxid)
- if err != nil {
- return nil, err
- }
- _ = evt.Content.ParseRaw(evt.Type)
- if evt.Type == event.EventEncrypted {
- decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt)
- if err != nil {
- return nil, fmt.Errorf("failed to decrypt event: %w", err)
- } else {
- evt = decryptedEvt
- }
- }
- return evt, nil
-}
-
-func genThreadName(evt *event.Event) string {
- body := evt.Content.AsMessage().Body
- if len(body) == 0 {
- return "thread"
- }
- fields := strings.Fields(body)
- var title string
- for _, field := range fields {
- if len(title)+len(field) < 40 {
- title += field
- title += " "
- continue
- }
- if len(title) == 0 {
- title = field[:40]
- }
- break
- }
- return title
-}
-
-func (portal *Portal) startThreadFromMatrix(sender *User, threadRoot id.EventID) (string, error) {
- rootEvt, err := portal.getEvent(threadRoot)
- if err != nil {
- return "", fmt.Errorf("failed to get root event: %w", err)
- }
- threadName := genThreadName(rootEvt)
-
- existingMsg := portal.bridge.DB.Message.GetByMXID(portal.Key, threadRoot)
- if existingMsg == nil {
- return "", fmt.Errorf("unknown root event")
- } else if existingMsg.ThreadID != "" {
- return "", fmt.Errorf("root event is already in a thread")
- } else {
- var ch *discordgo.Channel
- ch, err = sender.Session.MessageThreadStartComplex(portal.Key.ChannelID, existingMsg.DiscordID, &discordgo.ThreadStart{
- Name: threadName,
- AutoArchiveDuration: 24 * 60,
- Type: discordgo.ChannelTypeGuildPublicThread,
- Location: "Message",
- }, portal.RefererOptIfUser(sender.Session, "")...)
- if err != nil {
- return "", fmt.Errorf("error starting thread: %v", err)
- }
- portal.log.Debug().
- Str("thread_root_mxid", threadRoot.String()).
- Str("thread_id", ch.ID).
- Msg("Created Discord thread")
- portal.bridge.GetThreadByID(existingMsg.DiscordID, existingMsg)
- return ch.ID, nil
- }
-}
-
-func (portal *Portal) sendErrorMessage(evt *event.Event, msgType, message string, confirmed bool) id.EventID {
- if !portal.bridge.Config.Bridge.MessageErrorNotices {
- return ""
- }
- certainty := "may not have been"
- if confirmed {
- certainty = "was not"
- }
- if portal.RelayWebhookSecret != "" {
- message = strings.ReplaceAll(message, portal.RelayWebhookSecret, "")
- }
- content := &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, message),
- }
- 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 ""
- }
- return resp.EventID
-}
-
-var (
- errUnknownMsgType = errors.New("unknown msgtype")
- errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
- errUserNotReceiver = errors.New("user is not portal receiver")
- errUserNotLoggedIn = errors.New("user is not logged in and portal doesn't have webhook")
- errUnknownEditTarget = errors.New("unknown edit target")
- errUnknownRelationType = errors.New("unknown relation type")
- errTargetNotFound = errors.New("target event not found")
- errUnknownEmoji = errors.New("unknown emoji")
- errRelationshipsNotReady = errors.New("can't direct message before receiving relationships")
- errDMingStranger = errors.New("can't direct message a stranger")
- errCantStartThread = errors.New("can't create thread without being logged into Discord")
-)
-
-func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string, checkpointError error) {
- var restErr *discordgo.RESTError
- switch {
- case errors.Is(err, errUnknownMsgType),
- errors.Is(err, errUnknownRelationType),
- errors.Is(err, errUnexpectedParsedContentType),
- errors.Is(err, errUnknownEmoji),
- errors.Is(err, id.InvalidContentURI),
- errors.Is(err, attachment.UnsupportedVersion),
- errors.Is(err, attachment.UnsupportedAlgorithm),
- errors.Is(err, errCantStartThread):
- return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "", nil
- case errors.Is(err, errDMingStranger):
- return event.MessageStatusGenericError, event.MessageStatusFail, true, true, "You can't message users who aren't on your friends list. Use the Discord app to chat or add them as a friend to continue.", nil
- case errors.Is(err, errRelationshipsNotReady):
- return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Still syncing your Discord friends list, please try again in a moment.", nil
- case errors.Is(err, attachment.HashMismatch),
- errors.Is(err, attachment.InvalidKey),
- errors.Is(err, attachment.InvalidInitVector):
- return event.MessageStatusUndecryptable, event.MessageStatusFail, true, true, "", nil
- case errors.Is(err, errUserNotReceiver), errors.Is(err, errUserNotLoggedIn):
- return event.MessageStatusNoPermission, event.MessageStatusFail, true, false, "", nil
- case errors.Is(err, errUnknownEditTarget):
- return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil
- case errors.Is(err, errTargetNotFound):
- return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "", nil
- case errors.As(err, &restErr):
- if restErr.Message != nil && (restErr.Message.Code != 0 || len(restErr.Message.Message) > 0) {
- reason, humanMessage = restErrorToStatusReason(restErr.Message)
- status = event.MessageStatusFail
- isCertain = true
- sendNotice = true
- checkpointError = fmt.Errorf("HTTP %d: %d: %s", restErr.Response.StatusCode, restErr.Message.Code, restErr.Message.Message)
- if len(restErr.Message.Errors) > 0 {
- jsonExtraErrors, _ := json.Marshal(restErr.Message.Errors)
- checkpointError = fmt.Errorf("%w (%s)", checkpointError, jsonExtraErrors)
- }
- return
- } else if restErr.Response.StatusCode == http.StatusBadRequest && bytes.HasPrefix(restErr.ResponseBody, []byte(`{"captcha_key"`)) {
- return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "Captcha error", errors.New("captcha required")
- } else if restErr.Response != nil && (restErr.Response.StatusCode == http.StatusServiceUnavailable || restErr.Response.StatusCode == http.StatusBadGateway || restErr.Response.StatusCode == http.StatusGatewayTimeout) {
- return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, fmt.Sprintf("HTTP %s", restErr.Response.Status), fmt.Errorf("HTTP %d", restErr.Response.StatusCode)
- }
- fallthrough
- case errors.Is(err, context.DeadlineExceeded):
- return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", context.DeadlineExceeded
- case strings.HasSuffix(err.Error(), "(Client.Timeout exceeded while awaiting headers)"):
- return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "", errors.New("HTTP request timed out")
- case errors.Is(err, syscall.ECONNRESET):
- return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", errors.New("connection reset")
- default:
- return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "", nil
- }
-}
-
-func restErrorToStatusReason(msg *discordgo.APIErrorMessage) (reason event.MessageStatusReason, humanMessage string) {
- switch msg.Code {
- case discordgo.ErrCodeRequestEntityTooLarge:
- return event.MessageStatusUnsupported, "Attachment is too large"
- case discordgo.ErrCodeUnknownEmoji:
- return event.MessageStatusUnsupported, "Unsupported emoji"
- case discordgo.ErrCodeMissingPermissions, discordgo.ErrCodeMissingAccess:
- return event.MessageStatusUnsupported, "You don't have the permissions to do that"
- case discordgo.ErrCodeCannotSendMessagesToThisUser:
- return event.MessageStatusUnsupported, "You can't send messages to this user"
- case discordgo.ErrCodeCannotSendMessagesInVoiceChannel:
- return event.MessageStatusUnsupported, "You can't send messages in a non-text channel"
- case discordgo.ErrCodeInvalidFormBody:
- contentErrs := msg.Errors["content"].Errors
- if len(contentErrs) == 1 && contentErrs[0].Code == "BASE_TYPE_MAX_LENGTH" {
- return event.MessageStatusUnsupported, "Message is too long: " + contentErrs[0].Message
- }
- }
- return event.MessageStatusGenericError, fmt.Sprintf("%d: %s", msg.Code, msg.Message)
-}
-
-func (portal *Portal) sendStatusEvent(evtID id.EventID, err error) {
- if !portal.bridge.Config.Bridge.MessageStatusEvents {
- return
- }
- intent := portal.bridge.Bot
- if !portal.Encrypted {
- // Bridge bot isn't present in unencrypted DMs
- intent = portal.MainIntent()
- }
- stateKey, _ := portal.getBridgeInfo()
- content := event.BeeperMessageStatusEventContent{
- Network: stateKey,
- RelatesTo: event.RelatesTo{
- Type: event.RelReference,
- EventID: evtID,
- },
- Status: event.MessageStatusSuccess,
- }
- if err == nil {
- content.Status = event.MessageStatusSuccess
- } else {
- var checkpointErr error
- content.Reason, content.Status, _, _, content.Message, checkpointErr = errorToStatusReason(err)
- if checkpointErr != nil {
- content.Error = checkpointErr.Error()
- } else {
- content.Error = err.Error()
- }
- }
- _, err = intent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content)
- if err != nil {
- portal.log.Err(err).Str("event_id", evtID.String()).Msg("Failed to send message status event")
- }
-}
-
-func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string) {
- var msgType string
- switch evt.Type {
- case event.EventMessage, event.EventSticker:
- msgType = "message"
- case event.EventReaction:
- msgType = "reaction"
- case event.EventRedaction:
- msgType = "redaction"
- default:
- msgType = "unknown event"
- }
- level := zerolog.DebugLevel
- if err != nil && part != "Ignoring" {
- level = zerolog.ErrorLevel
- }
- logEvt := portal.log.WithLevel(level).
- Str("action", "send matrix message metrics").
- Str("event_type", evt.Type.Type).
- Str("event_id", evt.ID.String()).
- Str("sender", evt.Sender.String())
- if evt.Type == event.EventRedaction {
- logEvt.Str("redacts", evt.Redacts.String())
- }
- if err != nil {
- logEvt.Err(err).
- Str("result", fmt.Sprintf("%s event", part)).
- Msg("Matrix event not handled")
- reason, statusCode, isCertain, sendNotice, humanMessage, checkpointErr := errorToStatusReason(err)
- if checkpointErr == nil {
- checkpointErr = err
- }
- checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode)
- portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, checkpointErr, checkpointStatus, 0)
- if sendNotice {
- if humanMessage == "" {
- humanMessage = err.Error()
- }
- portal.sendErrorMessage(evt, msgType, humanMessage, isCertain)
- }
- portal.sendStatusEvent(evt.ID, err)
- } else {
- logEvt.Err(err).Msg("Matrix event handled successfully")
- portal.sendDeliveryReceipt(evt.ID)
- portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0)
- portal.sendStatusEvent(evt.ID, nil)
- }
-}
-
-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
- if name == "" {
- name = sender.MXID.String()
- }
- mxc := member.AvatarURL.ParseOrIgnore()
- if !mxc.IsEmpty() && portal.bridge.Config.Bridge.PublicAddress != "" {
- avatarURL = portal.bridge.makeMediaProxyURL(mxc)
- }
- return
-}
-
-const replyEmbedMaxLines = 1
-const replyEmbedMaxChars = 72
-
-func cutBody(body string) string {
- lines := strings.Split(strings.TrimSpace(body), "\n")
- var output string
- for i, line := range lines {
- if i >= replyEmbedMaxLines {
- output += " […]"
- break
- }
- if i > 0 {
- output += "\n"
- }
- output += line
- if len(output) > replyEmbedMaxChars {
- output = output[:replyEmbedMaxChars] + "…"
- break
- }
- }
- return output
-}
-
-func (portal *Portal) convertReplyMessageToEmbed(eventID id.EventID, url string) (*discordgo.MessageEmbed, error) {
- evt, err := portal.getEvent(eventID)
- if err != nil {
- return nil, fmt.Errorf("failed to get reply target event: %w", err)
- }
- content, ok := evt.Content.Parsed.(*event.MessageEventContent)
- if !ok {
- return nil, fmt.Errorf("unsupported event type %s / %T", evt.Type.String(), evt.Content.Parsed)
- }
- content.RemoveReplyFallback()
- var targetUser string
-
- puppet := portal.bridge.GetPuppetByMXID(evt.Sender)
- if puppet != nil {
- targetUser = fmt.Sprintf("<@%s>", puppet.ID)
- } else if user := portal.bridge.GetUserByMXID(evt.Sender); user != nil && user.DiscordID != "" {
- targetUser = fmt.Sprintf("<@%s>", user.DiscordID)
- } else if member := portal.bridge.StateStore.GetMember(portal.MXID, evt.Sender); member != nil && member.Displayname != "" {
- targetUser = member.Displayname
- } else {
- targetUser = evt.Sender.String()
- }
- body := escapeDiscordMarkdown(cutBody(content.Body))
- body = fmt.Sprintf("**[Replying to](%s) %s**\n%s", url, targetUser, body)
- embed := &discordgo.MessageEmbed{Description: body}
- 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) {
- content, ok := evt.Content.Parsed.(*event.MessageEventContent)
- if !ok {
- go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring")
- return
- }
-
- channelID := portal.Key.ChannelID
- sess := sender.Session
- if sess == nil && portal.RelayWebhookID == "" {
- go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring")
- return
- }
- isWebhookSend := sess == nil
-
- if portal.IsPrivateChat() {
- if sender.DiscordID != portal.Key.Receiver {
- go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring")
- return
- }
-
- if portal.bridge.Config.Bridge.ForbidDMingStrangers && sess.IsUser {
- recipient := portal.bridge.GetPuppetByID(portal.OtherUserID)
-
- if !recipient.IsBot {
- sender.relationshipLock.RLock()
- if !sender.relationshipsReady {
- go portal.sendMessageMetrics(evt, errRelationshipsNotReady, "")
- sender.relationshipLock.RUnlock()
- return
- }
- relationship, hasRelationship := sender.relationships[portal.OtherUserID]
- sender.relationshipLock.RUnlock()
-
- if !hasRelationship || relationship.Type != discordgo.RelationshipFriend {
- go portal.sendMessageMetrics(evt, errDMingStranger, "")
- return
- }
- }
- }
- }
- var threadID string
-
- if editMXID := content.GetRelatesTo().GetReplaceID(); editMXID != "" && content.NewContent != nil {
- edits := portal.bridge.DB.Message.GetByMXID(portal.Key, editMXID)
- if edits != nil {
- 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 {
- // TODO save edit in message table
- msg, err = sess.ChannelMessageEdit(edits.DiscordProtoChannelID(), edits.DiscordID, discordContent)
- } else {
- msg, err = relayClient.WebhookMessageEdit(portal.RelayWebhookID, portal.RelayWebhookSecret, edits.DiscordID, &discordgo.WebhookEdit{
- Content: &discordContent,
- AllowedMentions: allowedMentions,
- })
- }
- go portal.sendMessageMetrics(evt, err, "Failed to edit")
- if msg != nil && msg.EditedTimestamp != nil {
- edits.UpdateEditTimestamp(*msg.EditedTimestamp)
- }
- } else {
- go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEditTarget, editMXID), "Ignoring")
- }
- return
- } else if threadRoot := content.GetRelatesTo().GetThreadParent(); threadRoot != "" {
- existingThread := portal.bridge.GetThreadByRootMXID(threadRoot)
- if existingThread != nil {
- threadID = existingThread.ID
- existingThread.initialBackfillAttempted = true
- } else {
- if isWebhookSend {
- // TODO start thread with bot?
- go portal.sendMessageMetrics(evt, errCantStartThread, "Dropping")
- return
- }
- var err error
- threadID, err = portal.startThreadFromMatrix(sender, threadRoot)
- if err != nil {
- portal.log.Warn().Err(err).
- Str("thread_root_mxid", threadRoot.String()).
- Msg("Failed to start thread from Matrix")
- }
- }
- }
- if threadID != "" {
- channelID = threadID
- }
-
- var sendReq discordgo.MessageSend
-
- var description string
- if evt.Type == event.EventSticker {
- content.MsgType = event.MsgImage
- if mimeData := mimetype.Lookup(content.Info.MimeType); mimeData != nil {
- description = content.Body
- content.Body = "sticker" + mimeData.Extension()
- }
- }
-
- 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)
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to convert reply message to embed for webhook send")
- } else if embed != nil {
- sendReq.Embeds = []*discordgo.MessageEmbed{embed}
- }
- } else {
- sendReq.Reference = &discordgo.MessageReference{
- ChannelID: channelID,
- MessageID: replyTo.DiscordID,
- }
- }
- }
- }
- switch content.MsgType {
- case event.MsgText, event.MsgEmote, event.MsgNotice:
- sendReq.Content, sendReq.AllowedMentions = portal.parseMatrixHTML(content, parseAllowedLinkPreviews(evt.Content.Raw))
- if content.MsgType == event.MsgEmote {
- sendReq.Content = fmt.Sprintf("_%s_", sendReq.Content)
- }
- case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
- data, err := downloadMatrixAttachment(portal.MainIntent(), content)
- if err != nil {
- go portal.sendMessageMetrics(evt, err, "Error downloading media in")
- return
- }
- filename := content.Body
- if content.FileName != "" && content.FileName != content.Body {
- filename = content.FileName
- 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,
- 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(sender.Session.Client, prepared.UploadURL, data)
- if err != nil {
- go portal.sendMessageMetrics(evt, err, "Error reuploading media in")
- return
- }
- } else {
- sendReq.Files = []*discordgo.File{{
- Name: filename,
- ContentType: content.Info.MimeType,
- Reader: bytes.NewReader(data),
- }}
- }
- default:
- 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.
- 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 {
- portal.log.Warn().Err(err).
- Str("user_id", sender.MXID.String()).
- Msg("Failed to get power levels to check if user can use @everyone")
- } else if powerLevels.GetUserLevel(sender.MXID) >= powerLevels.Notifications.Room() {
- sendReq.AllowedMentions.Parse = append(sendReq.AllowedMentions.Parse, discordgo.AllowedMentionTypeEveryone)
- }
- }
- sendReq.Nonce = generateNonce()
- var msg *discordgo.Message
- var err error
- if !isWebhookSend {
- 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,
- 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()
- dbMsg.Channel = portal.Key
- dbMsg.DiscordID = msg.ID
- if len(msg.Attachments) > 0 {
- dbMsg.AttachmentID = msg.Attachments[0].ID
- }
- dbMsg.MXID = evt.ID
- if sess != nil {
- dbMsg.SenderID = sender.DiscordID
- } else {
- dbMsg.SenderID = portal.RelayWebhookID
- }
- dbMsg.SenderMXID = sender.MXID
- dbMsg.Timestamp, _ = discordgo.SnowflakeTimestamp(msg.ID)
- dbMsg.ThreadID = threadID
- dbMsg.Insert()
- }
-}
-
-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)
- if err != nil {
- portal.log.Warn().Err(err).
- Str("event_id", eventID.String()).
- Msg("Failed to send delivery receipt")
- }
- }
-}
-
-func (portal *Portal) HandleMatrixLeave(brSender bridge.User) {
- sender := brSender.(*User)
- if portal.IsPrivateChat() && sender.DiscordID == portal.Key.Receiver {
- portal.log.Debug().Msg("User left private chat portal, cleaning up and deleting...")
- portal.cleanup(false)
- portal.RemoveMXID()
- } else {
- portal.cleanupIfEmpty()
- }
-}
-
-func (portal *Portal) HandleMatrixKick(brSender bridge.User, brTarget bridge.Ghost) {}
-func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brTarget bridge.Ghost) {}
-
-func (portal *Portal) Delete() {
- portal.Portal.Delete()
- portal.bridge.portalsLock.Lock()
- delete(portal.bridge.portalsByID, portal.Key)
- if portal.MXID != "" {
- delete(portal.bridge.portalsByMXID, portal.MXID)
- }
- portal.bridge.portalsLock.Unlock()
-}
-
-func (portal *Portal) cleanupIfEmpty() {
- if portal.MXID == "" {
- return
- }
-
- users, err := portal.getMatrixUsers()
- if err != nil {
- portal.log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up")
- return
- }
-
- if len(users) == 0 {
- portal.log.Info().Msg("Room seems to be empty, cleaning up...")
- portal.cleanup(false)
- portal.RemoveMXID()
- }
-}
-
-func (portal *Portal) RemoveMXID() {
- portal.bridge.portalsLock.Lock()
- defer portal.bridge.portalsLock.Unlock()
- if portal.MXID == "" {
- return
- }
- delete(portal.bridge.portalsByMXID, portal.MXID)
- portal.MXID = ""
- 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 = false
- portal.InSpace = ""
- portal.FirstEventID = ""
- portal.Update()
- portal.bridge.DB.Message.DeleteAll(portal.Key)
-}
-
-func (portal *Portal) cleanup(puppetsOnly bool) {
- if portal.MXID == "" {
- return
- }
- intent := portal.MainIntent()
- if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
- err := intent.BeeperDeleteRoom(portal.MXID)
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- portal.log.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint")
- }
- return
- }
-
- if portal.IsPrivateChat() {
- _, err := portal.MainIntent().LeaveRoom(portal.MXID)
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to leave private chat portal with main intent")
- }
- return
- }
-
- portal.bridge.cleanupRoom(intent, portal.MXID, puppetsOnly, portal.log)
-}
-
-func (br *DiscordBridge) cleanupRoom(intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool, log zerolog.Logger) {
- members, err := intent.JoinedMembers(mxid)
- if err != nil {
- log.Err(err).Msg("Failed to get portal members for cleanup")
- return
- }
-
- for member := range members.Joined {
- if member == intent.UserID {
- continue
- }
-
- puppet := br.GetPuppetByMXID(member)
- if puppet != nil {
- _, err = puppet.DefaultIntent().LeaveRoom(mxid)
- if err != nil {
- log.Err(err).Msg("Error leaving as puppet while cleaning up portal")
- }
- } else if !puppetsOnly {
- _, err = intent.KickUser(mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
- if err != nil {
- log.Err(err).Msg("Error kicking user while cleaning up portal")
- }
- }
- }
-
- _, err = intent.LeaveRoom(mxid)
- if err != nil {
- log.Err(err).Msg("Error leaving with main intent while cleaning up portal")
- }
-}
-
-func (portal *Portal) getMatrixUsers() ([]id.UserID, error) {
- members, err := portal.MainIntent().JoinedMembers(portal.MXID)
- if err != nil {
- return nil, fmt.Errorf("failed to get member list: %w", err)
- }
-
- var users []id.UserID
- for userID := range members.Joined {
- _, isPuppet := portal.bridge.ParsePuppetMXID(userID)
- if !isPuppet && userID != portal.bridge.Bot.UserID {
- users = append(users, userID)
- }
- }
-
- return users, nil
-}
-
-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
- }
-
- reaction := evt.Content.AsReaction()
- if reaction.RelatesTo.Type != event.RelAnnotation {
- go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownRelationType, reaction.RelatesTo.Type), "Ignoring")
- return
- }
-
- if reaction.RelatesTo.Key == JoinThreadReaction {
- thread := portal.bridge.GetThreadByRootOrCreationNoticeMXID(reaction.RelatesTo.EventID)
- if thread == nil {
- go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring thread join")
- return
- }
- thread.Join(sender)
- return
- }
-
- msg := portal.bridge.DB.Message.GetByMXID(portal.Key, reaction.RelatesTo.EventID)
- if msg == nil {
- go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring")
- return
- }
-
- firstMsg := msg
- if msg.AttachmentID != "" {
- firstMsg = portal.bridge.DB.Message.GetFirstByDiscordID(portal.Key, msg.DiscordID)
- // TODO should the emoji be rerouted to the first message if it's different?
- }
-
- // Figure out if this is a custom emoji or not.
- emojiID := reaction.RelatesTo.Key
- if strings.HasPrefix(emojiID, "mxc://") {
- uri, _ := id.ParseContentURI(emojiID)
- 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
- }
- } else {
- emojiID = variationselector.FullyQualify(emojiID)
- }
-
- existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, msg.DiscordID, sender.DiscordID, emojiID)
- if existing != nil {
- portal.log.Debug().
- Str("event_id", evt.ID.String()).
- Str("existing_reaction_mxid", existing.MXID.String()).
- Msg("Dropping duplicate Matrix reaction")
- go portal.sendMessageMetrics(evt, nil, "")
- return
- }
-
- 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()
- dbReaction.Channel = portal.Key
- dbReaction.MessageID = msg.DiscordID
- dbReaction.FirstAttachmentID = firstMsg.AttachmentID
- dbReaction.Sender = sender.DiscordID
- dbReaction.EmojiName = emojiID
- dbReaction.ThreadID = msg.ThreadID
- dbReaction.MXID = evt.ID
- dbReaction.Insert()
- }
-}
-
-func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool, thread *Thread, member *discordgo.Member) {
- puppet := portal.bridge.GetPuppetByID(reaction.UserID)
- if member != nil {
- puppet.UpdateInfo(user, member.User, nil)
- }
- intent := puppet.IntentFor(portal)
-
- log := portal.log.With().
- Str("message_id", reaction.MessageID).
- Str("author_id", reaction.UserID).
- Bool("add", add).
- Str("action", "discord reaction").
- Logger()
-
- var discordID string
- var matrixReaction string
-
- if reaction.Emoji.ID != "" {
- reactionMXC := portal.getEmojiMXCByDiscordID(reaction.Emoji.ID, reaction.Emoji.Name, reaction.Emoji.Animated)
- if reactionMXC.IsEmpty() {
- return
- }
- matrixReaction = reactionMXC.String()
- discordID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID)
- } else {
- discordID = reaction.Emoji.Name
- matrixReaction = variationselector.Add(reaction.Emoji.Name)
- }
-
- // Find the message that we're working with.
- message := portal.bridge.DB.Message.GetByDiscordID(portal.Key, reaction.MessageID)
- if message == nil {
- log.Debug().Msg("Failed to add reaction to message: message not found")
- return
- }
-
- // Lookup an existing reaction
- existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, message[0].DiscordID, reaction.UserID, discordID)
- if !add {
- if existing == nil {
- log.Debug().Msg("Failed to remove reaction: reaction not found")
- return
- }
-
- resp, err := intent.RedactEvent(portal.MXID, existing.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to remove reaction")
- } else {
- go portal.sendDeliveryReceipt(resp.EventID)
- }
-
- existing.Delete()
- return
- } else if existing != nil {
- log.Debug().Msg("Ignoring duplicate reaction")
- return
- }
-
- content := event.ReactionEventContent{
- RelatesTo: event.RelatesTo{
- EventID: message[0].MXID,
- Type: event.RelAnnotation,
- Key: matrixReaction,
- },
- }
- extraContent := map[string]any{}
- if reaction.Emoji.ID != "" {
- extraContent["fi.mau.discord.reaction"] = map[string]any{
- "id": reaction.Emoji.ID,
- "name": reaction.Emoji.Name,
- "mxc": matrixReaction,
- }
- wrappedShortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name)
- extraContent["com.beeper.reaction.shortcode"] = wrappedShortcode
- if !portal.bridge.Config.Bridge.CustomEmojiReactions {
- content.RelatesTo.Key = wrappedShortcode
- }
- }
-
- resp, err := intent.SendMessageEvent(portal.MXID, event.EventReaction, &event.Content{
- Parsed: &content,
- Raw: extraContent,
- })
- if err != nil {
- log.Err(err).Msg("Failed to send reaction")
- return
- }
-
- if existing == nil {
- dbReaction := portal.bridge.DB.Reaction.New()
- dbReaction.Channel = portal.Key
- dbReaction.MessageID = message[0].DiscordID
- dbReaction.FirstAttachmentID = message[0].AttachmentID
- dbReaction.Sender = reaction.UserID
- dbReaction.EmojiName = discordID
- dbReaction.MXID = resp.EventID
- if thread != nil {
- dbReaction.ThreadID = thread.ID
- }
- dbReaction.Insert()
- portal.sendDeliveryReceipt(dbReaction.MXID)
- }
-}
-
-func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) {
- 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
- if sess == nil && portal.RelayWebhookID == "" {
- go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring")
- return
- }
-
- message := portal.bridge.DB.Message.GetByMXID(portal.Key, evt.Redacts)
- if message != nil {
- var err error
- // TODO add support for deleting individual attachments from messages
- if sess != nil {
- 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)
- }
- go portal.sendMessageMetrics(evt, err, "Error sending")
- if err == nil {
- message.Delete()
- }
- return
- }
-
- if sess != nil {
- reaction := portal.bridge.DB.Reaction.GetByMXID(evt.Redacts)
- if reaction != nil && reaction.Channel == portal.Key {
- 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()
- }
- return
- }
- }
-
- go portal.sendMessageMetrics(evt, errTargetNotFound, "Ignoring")
-}
-
-func (portal *Portal) HandleMatrixReadReceipt(brUser bridge.User, eventID id.EventID, receipt event.ReadReceipt) {
- sender := brUser.(*User)
- if sender.Session == nil {
- return
- }
- var thread *Thread
- discordThreadID := ""
- if receipt.ThreadID != "" && receipt.ThreadID != event.ReadReceiptThreadMain {
- thread = portal.bridge.GetThreadByRootMXID(receipt.ThreadID)
- if thread != nil {
- discordThreadID = thread.ID
- }
- }
- log := portal.log.With().
- Str("sender", brUser.GetMXID().String()).
- Str("event_id", eventID.String()).
- Str("action", "matrix read receipt").
- Str("discord_thread_id", discordThreadID).
- Logger()
- if thread != nil {
- if portal.bridge.Config.Bridge.AutojoinThreadOnOpen {
- thread.Join(sender)
- }
- if eventID == thread.CreationNoticeMXID {
- log.Debug().Msg("Dropping read receipt for thread creation notice")
- return
- }
- }
- if !sender.Session.IsUser {
- // Drop read receipts from bot users (after checking for the thread auto-join stuff)
- return
- }
- msg := portal.bridge.DB.Message.GetByMXID(portal.Key, eventID)
- if msg == nil {
- msg = portal.bridge.DB.Message.GetClosestBefore(portal.Key, discordThreadID, receipt.Timestamp)
- if msg == nil {
- log.Debug().Msg("Dropping read receipt: no messages found")
- return
- } else {
- log = log.With().
- Str("closest_event_id", msg.MXID.String()).
- Str("closest_message_id", msg.DiscordID).
- Logger()
- log.Debug().Msg("Read receipt target event not found, using closest message")
- }
- } else {
- log = log.With().
- Str("message_id", msg.DiscordID).
- Logger()
- }
- if receipt.ThreadID != "" && msg.ThreadID != discordThreadID {
- log.Debug().
- Str("receipt_thread_event_id", receipt.ThreadID.String()).
- Str("message_discord_thread_id", msg.ThreadID).
- Msg("Dropping read receipt: thread ID mismatch")
- return
- }
- 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 {
- log.Debug().
- Str("unexpected_resp_token", *resp.Token).
- Msg("Marked message as read on Discord (and got unexpected non-nil token)")
- } else {
- log.Debug().Msg("Marked message as read on Discord")
- }
-}
-
-func typingDiff(prev, new []id.UserID) (started []id.UserID) {
-OuterNew:
- for _, userID := range new {
- for _, previousUserID := range prev {
- if userID == previousUserID {
- continue OuterNew
- }
- }
- started = append(started, userID)
- }
- return
-}
-
-func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) {
- portal.currentlyTypingLock.Lock()
- defer portal.currentlyTypingLock.Unlock()
- startedTyping := typingDiff(portal.currentlyTyping, newTyping)
- portal.currentlyTyping = newTyping
- for _, userID := range startedTyping {
- user := portal.bridge.GetUserByMXID(userID)
- if user != nil && user.Session != nil {
- user.ViewingChannel(portal)
- 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()).
- Msg("Failed to mark user as typing")
- } else {
- portal.log.Debug().
- Str("user_id", user.MXID.String()).
- Msg("Marked user as typing")
- }
- }
- }
-}
-
-func (portal *Portal) UpdateName(meta *discordgo.Channel) bool {
- var parentName, guildName string
- if portal.Parent != nil {
- parentName = portal.Parent.PlainName
- }
- if portal.Guild != nil {
- guildName = portal.Guild.PlainName
- }
- plainNameChanged := portal.PlainName != meta.Name
- portal.PlainName = meta.Name
- return portal.UpdateNameDirect(portal.bridge.Config.Bridge.FormatChannelName(config.ChannelNameParams{
- Name: meta.Name,
- ParentName: parentName,
- GuildName: guildName,
- NSFW: meta.NSFW,
- Type: meta.Type,
- }), false) || plainNameChanged
-}
-
-func (portal *Portal) UpdateNameDirect(name string, isFriendNick bool) bool {
- if portal.FriendNick && !isFriendNick {
- return false
- } else if portal.Name == name && (portal.NameSet || portal.MXID == "" || (!portal.shouldSetDMRoomMetadata() && !isFriendNick)) {
- return false
- }
- portal.log.Debug().
- Str("old_name", portal.Name).
- Str("new_name", name).
- Msg("Updating portal name")
- portal.Name = name
- portal.NameSet = false
- portal.updateRoomName()
- return true
-}
-
-func (portal *Portal) updateRoomName() {
- if portal.MXID != "" && (portal.shouldSetDMRoomMetadata() || portal.FriendNick) {
- _, err := portal.MainIntent().SetRoomName(portal.MXID, portal.Name)
- if err != nil {
- portal.log.Err(err).Msg("Failed to update room name")
- } else {
- portal.NameSet = true
- }
- }
-}
-
-func (portal *Portal) UpdateAvatarFromPuppet(puppet *Puppet) bool {
- if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (puppet.Avatar == "" || portal.AvatarSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) {
- return false
- }
- portal.log.Debug().
- Str("old_avatar_id", portal.Avatar).
- Str("new_avatar_id", puppet.Avatar).
- Msg("Updating avatar from puppet")
- portal.Avatar = puppet.Avatar
- portal.AvatarURL = puppet.AvatarURL
- portal.AvatarSet = false
- portal.updateRoomAvatar()
- return true
-}
-
-func (portal *Portal) UpdateGroupDMAvatar(iconID string) bool {
- if portal.Avatar == iconID && (iconID == "") == portal.AvatarURL.IsEmpty() && (iconID == "" || portal.AvatarSet || portal.MXID == "") {
- return false
- }
- portal.log.Debug().
- Str("old_avatar_id", portal.Avatar).
- Str("new_avatar_id", portal.Avatar).
- Msg("Updating group DM avatar")
- portal.Avatar = iconID
- portal.AvatarSet = false
- portal.AvatarURL = id.ContentURI{}
- if portal.Avatar != "" {
- // TODO direct media support
- copied, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), discordgo.EndpointGroupIcon(portal.Key.ChannelID, portal.Avatar), false, AttachmentMeta{
- AttachmentID: fmt.Sprintf("private_channel_avatar/%s/%s", portal.Key.ChannelID, iconID),
- })
- if err != nil {
- portal.log.Err(err).Str("avatar_id", iconID).Msg("Failed to reupload channel avatar")
- return true
- }
- portal.AvatarURL = copied.MXC
- }
- portal.updateRoomAvatar()
- return true
-}
-
-func (portal *Portal) updateRoomAvatar() {
- if portal.MXID == "" || portal.AvatarURL.IsEmpty() || !portal.shouldSetDMRoomMetadata() {
- return
- }
- _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL)
- if err != nil {
- portal.log.Err(err).Msg("Failed to update room avatar")
- } else {
- portal.AvatarSet = true
- }
-}
-
-func (portal *Portal) UpdateTopic(topic string) bool {
- if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") {
- return false
- }
- portal.log.Debug().
- Str("old_topic", portal.Topic).
- Str("new_topic", topic).
- Msg("Updating portal topic")
- portal.Topic = topic
- portal.TopicSet = false
- portal.updateRoomTopic()
- return true
-}
-
-func (portal *Portal) updateRoomTopic() {
- if portal.MXID != "" {
- _, err := portal.MainIntent().SetRoomTopic(portal.MXID, portal.Topic)
- if err != nil {
- portal.log.Err(err).Msg("Failed to update room topic")
- } else {
- portal.TopicSet = true
- }
- }
-}
-
-func (portal *Portal) removeFromSpace() {
- if portal.InSpace == "" {
- return
- }
-
- log := portal.log.With().Str("space_mxid", portal.InSpace.String()).Logger()
- log.Debug().Msg("Removing room from space")
- _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, portal.InSpace.String(), struct{}{})
- if err != nil {
- log.Warn().Err(err).Msg("Failed to clear m.space.parent event in room")
- }
- _, err = portal.bridge.Bot.SendStateEvent(portal.InSpace, event.StateSpaceChild, portal.MXID.String(), struct{}{})
- if err != nil {
- log.Warn().Err(err).Msg("Failed to clear m.space.child event in space")
- }
- portal.InSpace = ""
-}
-
-func (portal *Portal) addToSpace(mxid id.RoomID) bool {
- if portal.InSpace == mxid {
- return false
- }
- portal.removeFromSpace()
- if mxid == "" {
- return true
- }
-
- log := portal.log.With().Str("space_mxid", mxid.String()).Logger()
- _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, mxid.String(), &event.SpaceParentEventContent{
- Via: []string{portal.bridge.AS.HomeserverDomain},
- Canonical: true,
- })
- if err != nil {
- log.Warn().Err(err).Msg("Failed to set m.space.parent event in room")
- }
-
- _, err = portal.bridge.Bot.SendStateEvent(mxid, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
- Via: []string{portal.bridge.AS.HomeserverDomain},
- // TODO order
- })
- if err != nil {
- log.Warn().Err(err).Msg("Failed to set m.space.child event in space")
- } else {
- portal.InSpace = mxid
- }
- return true
-}
-
-func (portal *Portal) UpdateParent(parentID string) bool {
- if portal.ParentID == parentID {
- return false
- }
- portal.log.Debug().
- Str("old_parent_id", portal.ParentID).
- Str("new_parent_id", parentID).
- Msg("Updating parent ID")
- portal.ParentID = parentID
- if portal.ParentID != "" {
- portal.Parent = portal.bridge.GetPortalByID(database.NewPortalKey(parentID, ""), discordgo.ChannelTypeGuildCategory)
- } else {
- portal.Parent = nil
- }
- return true
-}
-
-func (portal *Portal) ExpectedSpaceID() id.RoomID {
- if portal.Parent != nil {
- return portal.Parent.MXID
- } else if portal.Guild != nil {
- return portal.Guild.MXID
- }
- return ""
-}
-
-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)
- }
- return false
-}
-
-func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discordgo.Channel {
- changed := false
-
- log := portal.log.With().
- Str("action", "update info").
- Str("through_user_mxid", source.MXID.String()).
- Str("through_user_dcid", source.DiscordID).
- Logger()
-
- if meta == nil {
- log.Debug().Msg("UpdateInfo called without metadata, fetching from user's state cache")
- meta, _ = source.Session.State.Channel(portal.Key.ChannelID)
- if meta == nil {
- log.Warn().Msg("No metadata found in state cache, fetching from server via user")
- var err error
- meta, err = source.Session.Channel(portal.Key.ChannelID)
- if err != nil {
- log.Err(err).Msg("Failed to fetch meta via user")
- return nil
- }
- }
- }
-
- if portal.Type != meta.Type {
- log.Warn().
- Int("old_type", int(portal.Type)).
- Int("new_type", int(meta.Type)).
- Msg("Portal type changed")
- portal.Type = meta.Type
- changed = true
- }
- if portal.OtherUserID == "" && portal.IsPrivateChat() {
- if len(meta.Recipients) == 0 {
- var err error
- meta, err = source.Session.Channel(meta.ID)
- if err != nil {
- log.Err(err).Msg("Failed to fetch DM channel info to find other user ID")
- }
- }
- if len(meta.Recipients) > 0 {
- portal.OtherUserID = meta.Recipients[0].ID
- log.Info().Str("other_user_id", portal.OtherUserID).Msg("Found other user ID")
- changed = true
- }
- }
- if meta.GuildID != "" && portal.GuildID == "" {
- portal.GuildID = meta.GuildID
- portal.Guild = portal.bridge.GetGuildByID(portal.GuildID, true)
- changed = true
- }
-
- switch portal.Type {
- case discordgo.ChannelTypeDM:
- if portal.OtherUserID != "" {
- puppet := portal.bridge.GetPuppetByID(portal.OtherUserID)
- changed = portal.UpdateAvatarFromPuppet(puppet) || changed
- source.relationshipLock.RLock()
- if rel, ok := source.relationships[portal.OtherUserID]; ok && rel.Nickname != "" {
- portal.FriendNick = true
- changed = portal.UpdateNameDirect(rel.Nickname, true) || changed
- } else {
- portal.FriendNick = false
- changed = portal.UpdateNameDirect(puppet.Name, false) || changed
- }
- source.relationshipLock.RUnlock()
- }
- if portal.MXID != "" {
- portal.syncParticipants(source, meta.Recipients)
- }
- case discordgo.ChannelTypeGroupDM:
- changed = portal.UpdateGroupDMAvatar(meta.Icon) || changed
- if portal.MXID != "" {
- portal.syncParticipants(source, meta.Recipients)
- }
- fallthrough
- default:
- changed = portal.UpdateName(meta) || changed
- if portal.MXID != "" {
- portal.ensureUserInvited(source, false)
- }
- }
- changed = portal.UpdateTopic(meta.Topic) || changed
- 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(source) || changed
- }
- if changed {
- portal.UpdateBridgeInfo()
- portal.Update()
- }
- 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()
-}
diff --git a/portal_convert.go b/portal_convert.go
deleted file mode 100644
index 6823e2c..0000000
--- a/portal_convert.go
+++ /dev/null
@@ -1,779 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "html"
- "strconv"
- "strings"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/rs/zerolog"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-type ConvertedMessage struct {
- AttachmentID string
-
- Type event.Type
- Content *event.MessageEventContent
- Extra map[string]any
-}
-
-func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
- return &event.MessageEventContent{
- Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
- MsgType: event.MsgNotice,
- }
-}
-
-const DiscordStickerSize = 160
-
-func (portal *Portal) convertDiscordFile(ctx context.Context, typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
- meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
- if typeName == "sticker" && content.Info.MimeType == "application/json" {
- meta.Converter = portal.bridge.convertLottie
- }
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
- return portal.createMediaFailedMessage(err)
- }
- if typeName == "sticker" && content.Info.MimeType == "application/json" {
- content.Info.MimeType = dbFile.MimeType
- }
- content.Info.Size = dbFile.Size
- if content.Info.Width == 0 && content.Info.Height == 0 {
- content.Info.Width = dbFile.Width
- content.Info.Height = dbFile.Height
- }
- if dbFile.DecryptionInfo != nil {
- content.File = &event.EncryptedFileInfo{
- EncryptedFile: *dbFile.DecryptionInfo,
- URL: dbFile.MXC.CUString(),
- }
- } else {
- content.URL = dbFile.MXC.CUString()
- }
- return content
-}
-
-func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
- if content.Info == nil {
- return
- }
- if content.Info.Width == 0 && content.Info.Height == 0 {
- content.Info.Width = DiscordStickerSize
- content.Info.Height = DiscordStickerSize
- } else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
- if content.Info.Width > content.Info.Height {
- content.Info.Height /= content.Info.Width / DiscordStickerSize
- content.Info.Width = DiscordStickerSize
- } else if content.Info.Width < content.Info.Height {
- content.Info.Width /= content.Info.Height / DiscordStickerSize
- content.Info.Height = DiscordStickerSize
- } else {
- content.Info.Width = DiscordStickerSize
- content.Info.Height = DiscordStickerSize
- }
- }
-}
-
-func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.StickerItem) *ConvertedMessage {
- var mime string
- switch sticker.FormatType {
- case discordgo.StickerFormatTypePNG:
- mime = "image/png"
- case discordgo.StickerFormatTypeAPNG:
- mime = "image/apng"
- case discordgo.StickerFormatTypeLottie:
- mime = "application/json"
- case discordgo.StickerFormatTypeGIF:
- mime = "image/gif"
- default:
- zerolog.Ctx(ctx).Warn().
- Int("sticker_format", int(sticker.FormatType)).
- Str("sticker_id", sticker.ID).
- Msg("Unknown sticker format")
- }
- content := &event.MessageEventContent{
- Body: sticker.Name, // TODO find description from somewhere?
- Info: &event.FileInfo{
- MimeType: mime,
- },
- }
-
- mxc := portal.bridge.DMA.StickerMXC(sticker.ID, sticker.FormatType)
- // TODO add config option to use direct media even for lottie stickers
- if mxc.IsEmpty() && mime != "application/json" {
- content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
- } else {
- content.URL = mxc.CUString()
- }
- portal.cleanupConvertedStickerInfo(content)
- return &ConvertedMessage{
- AttachmentID: sticker.ID,
- Type: event.EventSticker,
- Content: content,
- }
-}
-
-func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, messageID string, att *discordgo.MessageAttachment) *ConvertedMessage {
- content := &event.MessageEventContent{
- Body: att.Filename,
- Info: &event.FileInfo{
- Height: att.Height,
- MimeType: att.ContentType,
- Width: att.Width,
-
- // This gets overwritten later after the file is uploaded to the homeserver
- Size: att.Size,
- },
- }
-
- var extra = make(map[string]any)
-
- if strings.HasPrefix(att.Filename, "SPOILER_") {
- extra["page.codeberg.everypizza.msc4193.spoiler"] = true
- }
-
- if att.Description != "" {
- content.Body = att.Description
- content.FileName = att.Filename
- }
-
- switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
- case "audio":
- content.MsgType = event.MsgAudio
- if att.Waveform != nil {
- // TODO convert waveform
- extra["org.matrix.msc1767.audio"] = map[string]any{
- "duration": int(att.DurationSeconds * 1000),
- }
- extra["org.matrix.msc3245.voice"] = map[string]any{}
- }
- case "image":
- content.MsgType = event.MsgImage
- case "video":
- content.MsgType = event.MsgVideo
- default:
- content.MsgType = event.MsgFile
- }
- mxc := portal.bridge.DMA.AttachmentMXC(portal.Key.ChannelID, messageID, att)
- if mxc.IsEmpty() {
- content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
- } else {
- content.URL = mxc.CUString()
- }
- return &ConvertedMessage{
- AttachmentID: att.ID,
- Type: event.EventMessage,
- Content: content,
- Extra: extra,
- }
-}
-
-func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
- attachmentID := fmt.Sprintf("video_%s", embed.URL)
- var proxyURL string
- if embed.Video != nil {
- proxyURL = embed.Video.ProxyURL
- } else if embed.Thumbnail != nil {
- proxyURL = embed.Thumbnail.ProxyURL
- } else {
- zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
- return &ConvertedMessage{
- AttachmentID: attachmentID,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
- MsgType: event.MsgNotice,
- },
- }
- }
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
- return &ConvertedMessage{
- AttachmentID: attachmentID,
- Type: event.EventMessage,
- Content: portal.createMediaFailedMessage(err),
- }
- }
-
- content := &event.MessageEventContent{
- Body: embed.URL,
- Info: &event.FileInfo{
- MimeType: dbFile.MimeType,
- Size: dbFile.Size,
- },
- }
- if embed.Video != nil {
- content.MsgType = event.MsgVideo
- content.Info.Width = embed.Video.Width
- content.Info.Height = embed.Video.Height
- } else {
- content.MsgType = event.MsgImage
- content.Info.Width = embed.Thumbnail.Width
- content.Info.Height = embed.Thumbnail.Height
- }
- if content.Info.Width == 0 && content.Info.Height == 0 {
- content.Info.Width = dbFile.Width
- content.Info.Height = dbFile.Height
- }
- if dbFile.DecryptionInfo != nil {
- content.File = &event.EncryptedFileInfo{
- EncryptedFile: *dbFile.DecryptionInfo,
- URL: dbFile.MXC.CUString(),
- }
- } else {
- content.URL = dbFile.MXC.CUString()
- }
- extra := map[string]any{}
- if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
- extra["info"] = map[string]any{
- "fi.mau.discord.gifv": true,
- "fi.mau.gif": true,
- "fi.mau.loop": true,
- "fi.mau.autoplay": true,
- "fi.mau.hide_controls": true,
- "fi.mau.no_audio": true,
- }
- }
- return &ConvertedMessage{
- AttachmentID: attachmentID,
- Type: event.EventMessage,
- Content: content,
- Extra: extra,
- }
-}
-
-func (portal *Portal) convertDiscordMessage(ctx context.Context, puppet *Puppet, intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
- predictedLength := len(msg.Attachments) + len(msg.StickerItems)
- if msg.Content != "" {
- predictedLength++
- }
- parts := make([]*ConvertedMessage, 0, predictedLength)
- if textPart := portal.convertDiscordTextMessage(ctx, intent, msg); textPart != nil {
- parts = append(parts, textPart)
- }
- log := zerolog.Ctx(ctx)
- handledIDs := make(map[string]struct{})
- for _, att := range msg.Attachments {
- if _, handled := handledIDs[att.ID]; handled {
- continue
- }
- handledIDs[att.ID] = struct{}{}
- log := log.With().Str("attachment_id", att.ID).Logger()
- if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil {
- parts = append(parts, part)
- }
- }
- for _, sticker := range msg.StickerItems {
- if _, handled := handledIDs[sticker.ID]; handled {
- continue
- }
- handledIDs[sticker.ID] = struct{}{}
- log := log.With().Str("sticker_id", sticker.ID).Logger()
- if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
- parts = append(parts, part)
- }
- }
- for i, embed := range msg.Embeds {
- // Ignore non-video embeds, they're handled in convertDiscordTextMessage
- if getEmbedType(msg, embed) != EmbedVideo {
- continue
- }
- // Discord deduplicates embeds by URL. It makes things easier for us too.
- if _, handled := handledIDs[embed.URL]; handled {
- continue
- }
- handledIDs[embed.URL] = struct{}{}
- log := log.With().
- Str("computed_embed_type", "video").
- Str("embed_type", string(embed.Type)).
- Int("embed_index", i).
- Logger()
- part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
- if part != nil {
- parts = append(parts, part)
- }
- }
- if len(parts) == 0 && msg.Thread != nil {
- parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
- }})
- }
- for _, part := range parts {
- puppet.addWebhookMeta(part, msg)
- puppet.addMemberMeta(part, msg)
- }
- return parts
-}
-
-func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
- if msg.Member == nil {
- return
- }
- if part.Extra == nil {
- part.Extra = make(map[string]any)
- }
- var avatarURL id.ContentURI
- var discordAvatarURL string
- if msg.Member.Avatar != "" {
- var err error
- avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Member.Avatar)
- if err != nil {
- puppet.log.Warn().Err(err).
- Str("avatar_id", msg.Member.Avatar).
- Msg("Failed to reupload guild user avatar")
- }
- }
- part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
- "nick": msg.Member.Nick,
- "avatar_id": msg.Member.Avatar,
- "avatar_url": discordAvatarURL,
- "avatar_mxc": avatarURL.String(),
- }
- if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
- perMessageProfile := map[string]any{
- "id": fmt.Sprintf("%s_%s", msg.GuildID, msg.Author.ID),
- "displayname": msg.Member.Nick,
- "avatar_url": avatarURL.String(),
- }
- if msg.Member.Nick == "" {
- perMessageProfile["displayname"] = puppet.Name
- }
- if avatarURL.IsEmpty() {
- perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
- }
- part.Extra["com.beeper.per_message_profile"] = perMessageProfile
- }
-}
-
-func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
- if msg.WebhookID == "" {
- return
- }
- if part.Extra == nil {
- part.Extra = make(map[string]any)
- }
- var avatarURL id.ContentURI
- if msg.Author.Avatar != "" {
- var err error
- avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
- if err != nil {
- puppet.log.Warn().Err(err).
- Str("avatar_id", msg.Author.Avatar).
- Msg("Failed to reupload webhook avatar")
- }
- }
- part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
- "id": msg.WebhookID,
- "name": msg.Author.Username,
- "avatar_id": msg.Author.Avatar,
- "avatar_url": msg.Author.AvatarURL(""),
- "avatar_mxc": avatarURL.String(),
- }
- profileID := sha256.Sum256(fmt.Appendf(nil, "%s:%s", msg.Author.Username, msg.Author.Avatar))
- hasFallback := false
- if msg.ApplicationID == "" &&
- puppet.bridge.Config.Bridge.PrefixWebhookMessages &&
- (part.Content.MsgType == event.MsgText || part.Content.MsgType == event.MsgNotice || (part.Content.FileName != "" && part.Content.FileName != part.Content.Body)) {
- part.Content.EnsureHasHTML()
- part.Content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, part.Content.Body)
- part.Content.FormattedBody = fmt.Sprintf("%s: %s", html.EscapeString(msg.Author.Username), part.Content.FormattedBody)
- hasFallback = true
- }
- part.Extra["com.beeper.per_message_profile"] = map[string]any{
- "id": hex.EncodeToString(profileID[:]),
- "avatar_url": avatarURL.String(),
- "displayname": msg.Author.Username,
- "has_fallback": hasFallback,
- }
-}
-
-const (
- embedHTMLWrapper = `%s
`
- embedHTMLWrapperColor = `%s
`
- embedHTMLAuthorWithImage = `
%s
`
- embedHTMLAuthorPlain = `%s
`
- embedHTMLAuthorLink = `%s`
- embedHTMLTitleWithLink = `%s
`
- embedHTMLTitlePlain = `%s
`
- embedHTMLDescription = `%s
`
- embedHTMLFieldName = `%s | `
- embedHTMLFieldValue = `%s | `
- embedHTMLFields = ``
- embedHTMLLinearField = `%s
%s
`
- embedHTMLImage = `
`
- embedHTMLFooterWithImage = ``
- embedHTMLFooterPlain = ``
- embedHTMLFooterOnlyDate = ``
- embedHTMLDate = ``
- embedFooterDateSeparator = ` • `
-)
-
-func (portal *Portal) convertDiscordRichEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
- log := zerolog.Ctx(ctx)
- var htmlParts []string
- if embed.Author != nil {
- var authorHTML string
- authorNameHTML := html.EscapeString(embed.Author.Name)
- if embed.Author.URL != "" {
- authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
- }
- authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
- if embed.Author.ProxyIconURL != "" {
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
- } else {
- authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
- }
- }
- htmlParts = append(htmlParts, authorHTML)
- }
- if embed.Title != "" {
- var titleHTML string
- baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
- if embed.URL != "" {
- titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
- } else {
- titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
- }
- htmlParts = append(htmlParts, titleHTML)
- }
- if embed.Description != "" {
- htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
- }
- for i := 0; i < len(embed.Fields); i++ {
- item := embed.Fields[i]
- if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
- splitItems := []*discordgo.MessageEmbedField{item}
- if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
- splitItems = append(splitItems, embed.Fields[i+1])
- i++
- if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
- splitItems = append(splitItems, embed.Fields[i+1])
- i++
- }
- }
- headerParts := make([]string, len(splitItems))
- contentParts := make([]string, len(splitItems))
- for j, splitItem := range splitItems {
- headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
- contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
- }
- htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
- } else {
- htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
- strconv.FormatBool(item.Inline),
- portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
- portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
- ))
- }
- }
- if embed.Image != nil {
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to reupload image in embed")
- } else {
- htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
- }
- }
- var embedDateHTML string
- if embed.Timestamp != "" {
- formattedTime := embed.Timestamp
- parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
- } else {
- formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
- }
- embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
- }
- if embed.Footer != nil {
- var footerHTML string
- var datePart string
- if embedDateHTML != "" {
- datePart = embedFooterDateSeparator + embedDateHTML
- }
- footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
- if embed.Footer.ProxyIconURL != "" {
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
- } else {
- footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
- }
- }
- htmlParts = append(htmlParts, footerHTML)
- } else if embed.Timestamp != "" {
- htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
- }
-
- if len(htmlParts) == 0 {
- return ""
- }
-
- compiledHTML := strings.Join(htmlParts, "")
- if embed.Color != 0 {
- compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
- } else {
- compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
- }
- return compiledHTML
-}
-
-type BeeperLinkPreview struct {
- mautrix.RespPreviewURL
- MatchedURL string `json:"matched_url"`
- ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
-}
-
-func (portal *Portal) convertDiscordLinkEmbedImage(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
- return
- }
- if width != 0 || height != 0 {
- preview.ImageWidth = width
- preview.ImageHeight = height
- } else {
- preview.ImageWidth = dbFile.Width
- preview.ImageHeight = dbFile.Height
- }
- preview.ImageSize = dbFile.Size
- preview.ImageType = dbFile.MimeType
- if dbFile.Encrypted {
- preview.ImageEncryption = &event.EncryptedFileInfo{
- EncryptedFile: *dbFile.DecryptionInfo,
- URL: dbFile.MXC.CUString(),
- }
- } else {
- preview.ImageURL = dbFile.MXC.CUString()
- }
-}
-
-func (portal *Portal) convertDiscordLinkEmbedToBeeper(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
- var preview BeeperLinkPreview
- preview.MatchedURL = embed.URL
- preview.Title = embed.Title
- preview.Description = embed.Description
- if embed.Image != nil {
- portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
- } else if embed.Thumbnail != nil {
- portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
- }
- return &preview
-}
-
-const msgInteractionTemplateHTML = `
-%s used /%s
-
`
-
-const msgComponentTemplateHTML = `This message contains interactive elements. Use the Discord app to interact with the message.
`
-
-type BridgeEmbedType int
-
-const (
- EmbedUnknown BridgeEmbedType = iota
- EmbedRich
- EmbedLinkPreview
- EmbedVideo
-)
-
-func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
- // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
- // so this is a hacky way to detect those.
- return embed.Video != nil && embed.Video.ProxyURL == ""
-}
-
-func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
- switch embed.Type {
- case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
- return EmbedLinkPreview
- case discordgo.EmbedTypeVideo:
- if isActuallyLinkPreview(embed) {
- return EmbedLinkPreview
- }
- return EmbedVideo
- case discordgo.EmbedTypeGifv:
- return EmbedVideo
- case discordgo.EmbedTypeImage:
- if msg != nil && isPlainGifMessage(msg) {
- return EmbedVideo
- } else if embed.Image == nil && embed.Thumbnail != nil {
- return EmbedLinkPreview
- }
- return EmbedRich
- case discordgo.EmbedTypeRich:
- return EmbedRich
- default:
- return EmbedUnknown
- }
-}
-
-func isPlainGifMessage(msg *discordgo.Message) bool {
- if len(msg.Embeds) != 1 {
- return false
- }
- embed := msg.Embeds[0]
- isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
- isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == ""
- contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
- return contentIsOnlyURL && (isGifVideo || isGifImage)
-}
-
-func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
- var matrixMentions event.Mentions
- for _, mention := range msg.Mentions {
- puppet := portal.bridge.GetPuppetByID(mention.ID)
- if syncGhosts {
- puppet.UpdateInfo(nil, mention, nil)
- }
- user := portal.bridge.GetUserByID(mention.ID)
- if user != nil {
- matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
- } else {
- matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
- }
- }
- slices.Sort(matrixMentions.UserIDs)
- matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
- if msg.MentionEveryone {
- matrixMentions.Room = true
- }
- return &matrixMentions
-}
-
-const forwardTemplateHTML = `
-↷ Forwarded
-%s
-%s
-
`
-
-func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
- log := zerolog.Ctx(ctx)
- if msg.Type == discordgo.MessageTypeCall {
- return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
- MsgType: event.MsgEmote,
- Body: "started a call",
- }}
- } else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
- return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
- MsgType: event.MsgEmote,
- Body: "joined the server",
- }}
- }
- var htmlParts []string
- if msg.Interaction != nil {
- puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
- puppet.UpdateInfo(nil, msg.Interaction.User, nil)
- htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
- }
- if msg.Content != "" && !isPlainGifMessage(msg) {
- htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, true))
- } else if msg.MessageReference != nil &&
- msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
- len(msg.MessageSnapshots) > 0 &&
- msg.MessageSnapshots[0].Message != nil {
- forwardedHTML := portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true)
- msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
- origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
- forwardedFromPortal := portal.bridge.GetExistingPortalByID(database.NewPortalKey(msg.MessageReference.ChannelID, ""))
- if forwardedFromPortal != nil {
- origMessage := portal.bridge.DB.Message.GetFirstByDiscordID(forwardedFromPortal.Key, msg.MessageReference.MessageID)
- if origMessage != nil {
- origLink = fmt.Sprintf(
- `#%s • %s`,
- forwardedFromPortal.MXID.EventURI(origMessage.MXID, portal.bridge.AS.HomeserverDomain),
- forwardedFromPortal.PlainName,
- msgTSText,
- )
- } else if forwardedFromPortal.MXID != "" {
- origLink = fmt.Sprintf(
- `#%s • %s`,
- forwardedFromPortal.MXID.URI(portal.bridge.AS.HomeserverDomain),
- forwardedFromPortal.PlainName,
- msgTSText,
- )
- } else if forwardedFromPortal.PlainName != "" {
- origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.PlainName, msgTSText)
- }
- }
-
- htmlParts = append(htmlParts, fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink))
- }
- previews := make([]*BeeperLinkPreview, 0)
- for i, embed := range msg.Embeds {
- if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
- continue
- }
- with := log.With().
- Str("embed_type", string(embed.Type)).
- Int("embed_index", i)
- switch getEmbedType(msg, embed) {
- case EmbedRich:
- log := with.Str("computed_embed_type", "rich").Logger()
- htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
- case EmbedLinkPreview:
- log := with.Str("computed_embed_type", "link preview").Logger()
- previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
- case EmbedVideo:
- // Ignore video embeds, they're handled as separate messages
- default:
- log := with.Logger()
- log.Warn().Msg("Unknown embed type in message")
- }
- }
-
- if len(msg.Components) > 0 {
- htmlParts = append(htmlParts, msgComponentTemplateHTML)
- }
-
- if len(htmlParts) == 0 {
- return nil
- }
-
- fullHTML := strings.Join(htmlParts, "\n")
- if !msg.MentionEveryone {
- fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
- }
-
- content := format.HTMLToContent(fullHTML)
- extraContent := map[string]any{
- "com.beeper.linkpreviews": previews,
- }
-
- return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
-}
diff --git a/provisioning.go b/provisioning.go
deleted file mode 100644
index c9ff3ab..0000000
--- a/provisioning.go
+++ /dev/null
@@ -1,552 +0,0 @@
-package main
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "errors"
- "net"
- "net/http"
- _ "net/http/pprof"
- "strings"
- "time"
-
- "github.com/gorilla/mux"
- "github.com/gorilla/websocket"
- log "maunium.net/go/maulogger/v2"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
- "go.mau.fi/mautrix-discord/remoteauth"
-)
-
-const (
- SecWebSocketProtocol = "com.gitlab.beeper.discord"
-)
-
-const (
- ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
- ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
- ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
- ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
- ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
- ErrCodeGuildBridgeFailed = "M_UNKNOWN"
- ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
- ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
- ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
- ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
- ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
- ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
-)
-
-type ProvisioningAPI struct {
- bridge *DiscordBridge
- log log.Logger
-}
-
-func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
- p := &ProvisioningAPI{
- bridge: br,
- log: br.Log.Sub("Provisioning"),
- }
-
- prefix := br.Config.Bridge.Provisioning.Prefix
-
- p.log.Debugln("Enabling provisioning API at", prefix)
-
- r := br.AS.Router.PathPrefix(prefix).Subrouter()
-
- r.Use(p.authMiddleware)
-
- r.HandleFunc("/v1/disconnect", p.disconnect).Methods(http.MethodPost)
- r.HandleFunc("/v1/ping", p.ping).Methods(http.MethodGet)
- r.HandleFunc("/v1/login/qr", p.qrLogin).Methods(http.MethodGet)
- r.HandleFunc("/v1/login/token", p.tokenLogin).Methods(http.MethodPost)
- r.HandleFunc("/v1/logout", p.logout).Methods(http.MethodPost)
- r.HandleFunc("/v1/reconnect", p.reconnect).Methods(http.MethodPost)
-
- r.HandleFunc("/v1/guilds", p.guildsList).Methods(http.MethodGet)
- 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
-}
-
-func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
- w.Header().Add("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(response)
-}
-
-// Response structs
-type Response struct {
- Success bool `json:"success"`
- Status string `json:"status"`
-}
-
-type Error struct {
- Success bool `json:"success"`
- Error string `json:"error"`
- ErrCode string `json:"errcode"`
-}
-
-// Wrapped http.ResponseWriter to capture the status code
-type responseWrap struct {
- http.ResponseWriter
- statusCode int
-}
-
-var _ http.Hijacker = (*responseWrap)(nil)
-
-func (rw *responseWrap) WriteHeader(statusCode int) {
- rw.ResponseWriter.WriteHeader(statusCode)
- rw.statusCode = statusCode
-}
-
-func (rw *responseWrap) Hijack() (net.Conn, *bufio.ReadWriter, error) {
- hijacker, ok := rw.ResponseWriter.(http.Hijacker)
- if !ok {
- return nil, nil, errors.New("response does not implement http.Hijacker")
- }
- return hijacker.Hijack()
-}
-
-// Middleware
-func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := r.Header.Get("Authorization")
-
- // Special case the login endpoint to use the discord qrcode auth
- if auth == "" && strings.HasSuffix(r.URL.Path, "/login") {
- authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
- for _, part := range authParts {
- part = strings.TrimSpace(part)
- if strings.HasPrefix(part, SecWebSocketProtocol+"-") {
- auth = part[len(SecWebSocketProtocol+"-"):]
-
- break
- }
- }
- } else if strings.HasPrefix(auth, "Bearer ") {
- auth = auth[len("Bearer "):]
- }
-
- if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret {
- jsonResponse(w, http.StatusUnauthorized, map[string]interface{}{
- "error": "Invalid auth token",
- "errcode": mautrix.MUnknownToken.ErrCode,
- })
-
- return
- }
-
- userID := r.URL.Query().Get("user_id")
- user := p.bridge.GetUserByMXID(id.UserID(userID))
-
- start := time.Now()
- wWrap := &responseWrap{w, 200}
- h.ServeHTTP(wWrap, r.WithContext(context.WithValue(r.Context(), "user", user)))
- duration := time.Now().Sub(start).Seconds()
-
- p.log.Infofln("%s %s from %s took %.2f seconds and returned status %d", r.Method, r.URL.Path, user.MXID, duration, wWrap.statusCode)
- })
-}
-
-// websocket upgrader
-var upgrader = websocket.Upgrader{
- CheckOrigin: func(r *http.Request) bool {
- return true
- },
- Subprotocols: []string{SecWebSocketProtocol},
-}
-
-// Handlers
-func (p *ProvisioningAPI) disconnect(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
-
- if !user.Connected() {
- jsonResponse(w, http.StatusConflict, Error{
- Error: "You're not connected to discord",
- ErrCode: ErrCodeNotConnected,
- })
- return
- }
-
- if err := user.Disconnect(); err != nil {
- p.log.Errorfln("Failed to disconnect %s: %v", user.MXID, err)
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Failed to disconnect from discord",
- ErrCode: ErrCodeDisconnectFailed,
- })
- } else {
- jsonResponse(w, http.StatusOK, Response{
- Success: true,
- Status: "Disconnected from Discord",
- })
- }
-}
-
-type respPing struct {
- Discord struct {
- ID string `json:"id,omitempty"`
- LoggedIn bool `json:"logged_in"`
- Connected bool `json:"connected"`
- Conn struct {
- LastHeartbeatAck int64 `json:"last_heartbeat_ack,omitempty"`
- LastHeartbeatSent int64 `json:"last_heartbeat_sent,omitempty"`
- } `json:"conn"`
- }
- MXID id.UserID `json:"mxid"`
- ManagementRoom id.RoomID `json:"management_room"`
-}
-
-func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
-
- resp := respPing{
- MXID: user.MXID,
- ManagementRoom: user.ManagementRoom,
- }
- resp.Discord.LoggedIn = user.IsLoggedIn()
- resp.Discord.Connected = user.Connected()
- resp.Discord.ID = user.DiscordID
- if user.Session != nil {
- resp.Discord.Conn.LastHeartbeatAck = user.Session.LastHeartbeatAck.UnixMilli()
- resp.Discord.Conn.LastHeartbeatSent = user.Session.LastHeartbeatSent.UnixMilli()
- }
- jsonResponse(w, http.StatusOK, resp)
-}
-
-func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- var msg string
- if user.DiscordID != "" {
- msg = "Logged out successfully."
- } else {
- msg = "User wasn't logged in."
- }
- user.Logout(false)
- jsonResponse(w, http.StatusOK, Response{true, msg})
-}
-
-func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
- userID := r.URL.Query().Get("user_id")
- user := p.bridge.GetUserByMXID(id.UserID(userID))
-
- c, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- p.log.Errorln("Failed to upgrade connection to websocket:", err)
- return
- }
-
- log := p.log.Sub("QRLogin").Sub(user.MXID.String())
-
- defer func() {
- err := c.Close()
- if err != nil {
- log.Debugln("Error closing websocket:", err)
- }
- }()
-
- go func() {
- // Read everything so SetCloseHandler() works
- for {
- _, _, err := c.ReadMessage()
- if err != nil {
- break
- }
- }
- }()
-
- ctx, cancel := context.WithCancel(context.Background())
- c.SetCloseHandler(func(code int, text string) error {
- log.Debugfln("Login websocket closed (%d), cancelling login", code)
- cancel()
- return nil
- })
-
- if user.IsLoggedIn() {
- _ = c.WriteJSON(Error{
- Error: "You're already logged into Discord",
- ErrCode: ErrCodeAlreadyLoggedIn,
- })
- return
- }
-
- client, err := remoteauth.New()
- if err != nil {
- log.Errorln("Failed to prepare login:", err)
- _ = c.WriteJSON(Error{
- Error: "Failed to prepare login",
- ErrCode: ErrCodeLoginPrepareFailed,
- })
- return
- }
-
- qrChan := make(chan string)
- doneChan := make(chan struct{})
-
- log.Debugln("Started login via provisioning API")
-
- err = client.Dial(ctx, qrChan, doneChan)
- if err != nil {
- log.Errorln("Failed to connect to Discord login websocket:", err)
- close(qrChan)
- close(doneChan)
- _ = c.WriteJSON(Error{
- Error: "Failed to connect to Discord login websocket",
- ErrCode: ErrCodeLoginConnectionFailed,
- })
- return
- }
-
- for {
- select {
- case qrCode, ok := <-qrChan:
- if !ok {
- continue
- }
- err = c.WriteJSON(map[string]interface{}{
- "code": qrCode,
- "timeout": 120, // TODO: move this to the library or something
- })
- if err != nil {
- log.Errorln("Failed to write QR code to websocket:", err)
- }
- case <-doneChan:
- var discordUser remoteauth.User
- discordUser, err = client.Result()
- if err != nil {
- log.Errorln("Discord login websocket returned error:", err)
- _ = c.WriteJSON(Error{
- Error: "Failed to log in",
- ErrCode: ErrCodeLoginFailed,
- })
- return
- }
-
- log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
-
- if err = user.Login(discordUser.Token); err != nil {
- log.Errorln("Failed to connect after logging in:", err)
- _ = c.WriteJSON(Error{
- Error: "Failed to connect to Discord after logging in",
- ErrCode: ErrCodePostLoginConnFailed,
- })
- return
- }
-
- err = c.WriteJSON(respLogin{
- Success: true,
- ID: user.DiscordID,
- Username: discordUser.Username,
- Discriminator: discordUser.Discriminator,
- })
- if err != nil {
- log.Errorln("Failed to write login success to websocket:", err)
- }
- return
- case <-ctx.Done():
- return
- }
- }
-}
-
-type respLogin struct {
- Success bool `json:"success"`
- ID string `json:"id"`
- Username string `json:"username"`
- Discriminator string `json:"discriminator"`
-}
-
-type reqTokenLogin struct {
- Token string `json:"token"`
-}
-
-func (p *ProvisioningAPI) tokenLogin(w http.ResponseWriter, r *http.Request) {
- userID := r.URL.Query().Get("user_id")
- user := p.bridge.GetUserByMXID(id.UserID(userID))
- log := p.log.Sub("TokenLogin").Sub(user.MXID.String())
- if user.IsLoggedIn() {
- jsonResponse(w, http.StatusConflict, Error{
- Error: "You're already logged into Discord",
- ErrCode: ErrCodeAlreadyLoggedIn,
- })
- return
- }
- var body reqTokenLogin
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- log.Errorln("Failed to parse login request:", err)
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "Failed to parse request body",
- ErrCode: mautrix.MBadJSON.ErrCode,
- })
- return
- }
- if err := user.Login(body.Token); err != nil {
- log.Errorln("Failed to connect with provided token:", err)
- jsonResponse(w, http.StatusUnauthorized, Error{
- Error: "Failed to connect to Discord",
- ErrCode: ErrCodePostLoginConnFailed,
- })
- return
- }
- log.Infoln("Successfully logged in")
- jsonResponse(w, http.StatusOK, respLogin{
- Success: true,
- ID: user.DiscordID,
- Username: user.Session.State.User.Username,
- Discriminator: user.Session.State.User.Discriminator,
- })
-}
-
-func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
-
- if user.Connected() {
- jsonResponse(w, http.StatusConflict, Error{
- Error: "You're already connected to discord",
- ErrCode: ErrCodeAlreadyConnected,
- })
-
- return
- }
-
- if err := user.Connect(); err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Failed to connect to discord",
- ErrCode: ErrCodeConnectFailed,
- })
- } else {
- jsonResponse(w, http.StatusOK, Response{
- Success: true,
- Status: "Connected to Discord",
- })
- }
-}
-
-type guildEntry struct {
- ID string `json:"id"`
- Name string `json:"name"`
- AvatarURL id.ContentURI `json:"avatar_url"`
- MXID id.RoomID `json:"mxid"`
- AutoBridge bool `json:"auto_bridge_channels"`
- BridgingMode string `json:"bridging_mode"`
-}
-
-type respGuildsList struct {
- Guilds []guildEntry `json:"guilds"`
-}
-
-func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
-
- var resp respGuildsList
- resp.Guilds = []guildEntry{}
- for _, userGuild := range user.GetPortals() {
- guild := p.bridge.GetGuildByID(userGuild.DiscordID, false)
- if guild == nil {
- continue
- }
- resp.Guilds = append(resp.Guilds, guildEntry{
- ID: guild.ID,
- Name: guild.PlainName,
- AvatarURL: guild.AvatarURL,
- MXID: guild.MXID,
- AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
- BridgingMode: guild.BridgingMode.String(),
- })
- }
-
- jsonResponse(w, http.StatusOK, resp)
-}
-
-type reqBridgeGuild struct {
- AutoCreateChannels bool `json:"auto_create_channels"`
-}
-
-type respBridgeGuild struct {
- Success bool `json:"success"`
- MXID id.RoomID `json:"mxid"`
-}
-
-func (p *ProvisioningAPI) guildsBridge(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- guildID := mux.Vars(r)["guildID"]
-
- var body reqBridgeGuild
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- p.log.Errorln("Failed to parse bridge request:", err)
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "Failed to parse request body",
- ErrCode: mautrix.MBadJSON.ErrCode,
- })
- return
- }
-
- guild := user.bridge.GetGuildByID(guildID, false)
- if guild == nil {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "Guild not found",
- ErrCode: mautrix.MNotFound.ErrCode,
- })
- return
- }
- alreadyExists := guild.MXID == ""
- if err := user.bridgeGuild(guildID, body.AutoCreateChannels); err != nil {
- p.log.Errorfln("Error bridging %s: %v", guildID, err)
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Internal error while trying to bridge guild",
- ErrCode: ErrCodeGuildBridgeFailed,
- })
- } else if alreadyExists {
- jsonResponse(w, http.StatusOK, respBridgeGuild{
- Success: true,
- MXID: guild.MXID,
- })
- } else {
- jsonResponse(w, http.StatusCreated, respBridgeGuild{
- Success: true,
- MXID: guild.MXID,
- })
- }
-}
-
-func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request) {
- guildID := mux.Vars(r)["guildID"]
- user := r.Context().Value("user").(*User)
- if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
- jsonResponse(w, http.StatusForbidden, Error{
- Error: "Only bridge admins can unbridge guilds",
- ErrCode: mautrix.MForbidden.ErrCode,
- })
- } else if guild := user.bridge.GetGuildByID(guildID, false); guild == nil {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "Guild not found",
- ErrCode: mautrix.MNotFound.ErrCode,
- })
- } else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "That guild is not bridged",
- ErrCode: ErrCodeGuildNotBridged,
- })
- } else if err := user.unbridgeGuild(guildID); err != nil {
- p.log.Errorfln("Error unbridging %s: %v", guildID, err)
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Internal error while trying to unbridge guild",
- ErrCode: ErrCodeGuildUnbridgeFailed,
- })
- } else {
- w.WriteHeader(http.StatusNoContent)
- }
-}
diff --git a/puppet.go b/puppet.go
deleted file mode 100644
index ca6489e..0000000
--- a/puppet.go
+++ /dev/null
@@ -1,386 +0,0 @@
-package main
-
-import (
- "fmt"
- "regexp"
- "strings"
- "sync"
-
- "github.com/bwmarrin/discordgo"
- "github.com/rs/zerolog"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-type Puppet struct {
- *database.Puppet
-
- bridge *DiscordBridge
- log zerolog.Logger
-
- MXID id.UserID
-
- customIntent *appservice.IntentAPI
- customUser *User
-
- syncLock sync.Mutex
-}
-
-var _ bridge.Ghost = (*Puppet)(nil)
-var _ bridge.GhostWithProfile = (*Puppet)(nil)
-
-func (puppet *Puppet) GetMXID() id.UserID {
- return puppet.MXID
-}
-
-var userIDRegex *regexp.Regexp
-
-func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
- return &Puppet{
- Puppet: dbPuppet,
- bridge: br,
- log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(),
-
- MXID: br.FormatPuppetMXID(dbPuppet.ID),
- }
-}
-
-func (br *DiscordBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) {
- if userIDRegex == nil {
- pattern := fmt.Sprintf(
- "^@%s:%s$",
- br.Config.Bridge.FormatUsername("([0-9]+)"),
- br.Config.Homeserver.Domain,
- )
-
- userIDRegex = regexp.MustCompile(pattern)
- }
-
- match := userIDRegex.FindStringSubmatch(string(mxid))
- if len(match) == 2 {
- return match[1], true
- }
-
- return "", false
-}
-
-func (br *DiscordBridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
- discordID, ok := br.ParsePuppetMXID(mxid)
- if !ok {
- return nil
- }
-
- return br.GetPuppetByID(discordID)
-}
-
-func (br *DiscordBridge) GetPuppetByID(id string) *Puppet {
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
-
- puppet, ok := br.puppets[id]
- if !ok {
- dbPuppet := br.DB.Puppet.Get(id)
- if dbPuppet == nil {
- dbPuppet = br.DB.Puppet.New()
- dbPuppet.ID = id
- dbPuppet.Insert()
- }
-
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[puppet.ID] = puppet
- }
-
- return puppet
-}
-
-func (br *DiscordBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
-
- puppet, ok := br.puppetsByCustomMXID[mxid]
- if !ok {
- dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid)
- if dbPuppet == nil {
- return nil
- }
-
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[puppet.ID] = puppet
- br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
- }
-
- return puppet
-}
-
-func (br *DiscordBridge) GetAllPuppetsWithCustomMXID() []*Puppet {
- return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID())
-}
-
-func (br *DiscordBridge) GetAllPuppets() []*Puppet {
- return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll())
-}
-
-func (br *DiscordBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
-
- output := make([]*Puppet, len(dbPuppets))
- for index, dbPuppet := range dbPuppets {
- if dbPuppet == nil {
- continue
- }
-
- puppet, ok := br.puppets[dbPuppet.ID]
- if !ok {
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[dbPuppet.ID] = puppet
-
- if dbPuppet.CustomMXID != "" {
- br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
- }
- }
-
- output[index] = puppet
- }
-
- return output
-}
-
-func (br *DiscordBridge) FormatPuppetMXID(did string) id.UserID {
- return id.NewUserID(
- br.Config.Bridge.FormatUsername(did),
- br.Config.Homeserver.Domain,
- )
-}
-
-func (puppet *Puppet) GetDisplayname() string {
- return puppet.Name
-}
-
-func (puppet *Puppet) GetAvatarURL() id.ContentURI {
- return puppet.AvatarURL
-}
-
-func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
- return puppet.bridge.AS.Intent(puppet.MXID)
-}
-
-func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
- if puppet.customIntent == nil || (portal.Key.Receiver != "" && portal.Key.Receiver != puppet.ID) {
- return puppet.DefaultIntent()
- }
-
- return puppet.customIntent
-}
-
-func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
- if puppet == nil {
- return nil
- }
- return puppet.customIntent
-}
-
-func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
- for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.ID) {
- // Get room create lock to prevent races between receiving contact info and room creation.
- portal.roomCreateLock.Lock()
- meta(portal)
- portal.roomCreateLock.Unlock()
- }
-}
-
-func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
- newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication)
- if puppet.Name == newName && puppet.NameSet {
- return false
- }
- puppet.Name = newName
- puppet.NameSet = false
- err := puppet.DefaultIntent().SetDisplayName(newName)
- if err != nil {
- puppet.log.Warn().Err(err).Msg("Failed to update displayname")
- } else {
- go puppet.updatePortalMeta(func(portal *Portal) {
- if portal.UpdateNameDirect(puppet.Name, false) {
- portal.Update()
- portal.UpdateBridgeInfo()
- }
- })
- puppet.NameSet = true
- }
- return true
-}
-
-func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
- var downloadURL string
- if guildID == "" {
- if strings.HasPrefix(avatarID, "a_") {
- downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
- } else {
- downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
- }
- } else {
- if strings.HasPrefix(avatarID, "a_") {
- downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
- } else {
- downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
- }
- }
- url := br.DMA.AvatarMXC(guildID, userID, avatarID)
- if !url.IsEmpty() {
- return url, downloadURL, nil
- }
- copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
- AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
- })
- if err != nil {
- return id.ContentURI{}, downloadURL, err
- }
- return copied.MXC, downloadURL, nil
-}
-
-func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
- avatarID := info.Avatar
- if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars {
- avatarID = ""
- }
- if puppet.Avatar == avatarID && puppet.AvatarSet {
- return false
- }
- avatarChanged := avatarID != puppet.Avatar
- puppet.Avatar = avatarID
- puppet.AvatarSet = false
- puppet.AvatarURL = id.ContentURI{}
-
- if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
- url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar)
- if err != nil {
- puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
- return true
- }
- puppet.AvatarURL = url
- }
-
- err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
- if err != nil {
- puppet.log.Warn().Err(err).Msg("Failed to update avatar")
- } else {
- go puppet.updatePortalMeta(func(portal *Portal) {
- if portal.UpdateAvatarFromPuppet(puppet) {
- portal.Update()
- portal.UpdateBridgeInfo()
- }
- })
- puppet.AvatarSet = true
- }
- return true
-}
-
-func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) {
- puppet.syncLock.Lock()
- defer puppet.syncLock.Unlock()
-
- if info == nil || len(info.Username) == 0 || len(info.Discriminator) == 0 {
- if puppet.Name != "" || source == nil {
- return
- }
- var err error
- puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet")
- info, err = source.Session.User(puppet.ID)
- if err != nil {
- puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user")
- return
- }
- }
-
- err := puppet.DefaultIntent().EnsureRegistered()
- if err != nil {
- puppet.log.Error().Err(err).Msg("Failed to ensure registered")
- }
-
- changed := false
- if message != nil {
- if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook {
- puppet.log.Debug().
- Str("message_id", message.ID).
- Str("webhook_id", message.WebhookID).
- Msg("Found webhook ID in message, marking ghost as a webhook")
- puppet.IsWebhook = true
- changed = true
- }
- if message.ApplicationID != "" && !puppet.IsApplication {
- puppet.log.Debug().
- Str("message_id", message.ID).
- Str("application_id", message.ApplicationID).
- Msg("Found application ID in message, marking ghost as an application")
- puppet.IsApplication = true
- puppet.IsWebhook = false
- changed = true
- }
- }
- changed = puppet.UpdateContactInfo(info) || changed
- changed = puppet.UpdateName(info) || changed
- changed = puppet.UpdateAvatar(info) || changed
- if changed {
- puppet.Update()
- }
-}
-
-func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool {
- changed := false
- if puppet.Username != info.Username {
- puppet.Username = info.Username
- changed = true
- }
- if puppet.GlobalName != info.GlobalName {
- puppet.GlobalName = info.GlobalName
- changed = true
- }
- if puppet.Discriminator != info.Discriminator {
- puppet.Discriminator = info.Discriminator
- changed = true
- }
- if puppet.IsBot != info.Bot {
- puppet.IsBot = info.Bot
- changed = true
- }
- if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet {
- puppet.ContactInfoSet = false
- puppet.ResendContactInfo()
- return true
- }
- return false
-}
-
-func (puppet *Puppet) ResendContactInfo() {
- if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
- return
- }
- discordUsername := puppet.Username
- if puppet.Discriminator != "0" {
- discordUsername += "#" + puppet.Discriminator
- }
- contactInfo := map[string]any{
- "com.beeper.bridge.identifiers": []string{
- fmt.Sprintf("discord:%s", discordUsername),
- },
- "com.beeper.bridge.remote_id": puppet.ID,
- "com.beeper.bridge.service": puppet.bridge.BeeperServiceName,
- "com.beeper.bridge.network": puppet.bridge.BeeperNetworkName,
- "com.beeper.bridge.is_network_bot": puppet.IsBot,
- }
- if puppet.IsWebhook {
- contactInfo["com.beeper.bridge.identifiers"] = []string{}
- }
- err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
- if err != nil {
- puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
- } else {
- puppet.ContactInfoSet = true
- }
-}
diff --git a/thread.go b/thread.go
deleted file mode 100644
index 6e6aa7b..0000000
--- a/thread.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package main
-
-import (
- "context"
- "sync"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/rs/zerolog"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-type Thread struct {
- *database.Thread
- Parent *Portal
-
- creationNoticeLock sync.Mutex
- initialBackfillAttempted bool
-}
-
-func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread {
- br.threadsLock.Lock()
- defer br.threadsLock.Unlock()
- thread, ok := br.threadsByID[id]
- if !ok {
- return br.loadThread(br.DB.Thread.GetByDiscordID(id), id, root)
- }
- return thread
-}
-
-func (br *DiscordBridge) GetThreadByRootMXID(mxid id.EventID) *Thread {
- br.threadsLock.Lock()
- defer br.threadsLock.Unlock()
- thread, ok := br.threadsByRootMXID[mxid]
- if !ok {
- return br.loadThread(br.DB.Thread.GetByMatrixRootMsg(mxid), "", nil)
- }
- return thread
-}
-
-func (br *DiscordBridge) GetThreadByRootOrCreationNoticeMXID(mxid id.EventID) *Thread {
- br.threadsLock.Lock()
- defer br.threadsLock.Unlock()
- thread, ok := br.threadsByRootMXID[mxid]
- if !ok {
- thread, ok = br.threadsByCreationNoticeMXID[mxid]
- if !ok {
- return br.loadThread(br.DB.Thread.GetByMatrixRootOrCreationNoticeMsg(mxid), "", nil)
- }
- }
- return thread
-}
-
-func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *database.Message) *Thread {
- if dbThread == nil {
- if root == nil {
- return nil
- }
- dbThread = br.DB.Thread.New()
- dbThread.ID = id
- dbThread.RootDiscordID = root.DiscordID
- dbThread.RootMXID = root.MXID
- dbThread.ParentID = root.Channel.ChannelID
- dbThread.Insert()
- }
- thread := &Thread{
- Thread: dbThread,
- }
- thread.Parent = br.GetExistingPortalByID(database.NewPortalKey(thread.ParentID, ""))
- br.threadsByID[thread.ID] = thread
- br.threadsByRootMXID[thread.RootMXID] = thread
- if thread.CreationNoticeMXID != "" {
- br.threadsByCreationNoticeMXID[thread.CreationNoticeMXID] = thread
- }
- return thread
-}
-
-func (br *DiscordBridge) threadFound(ctx context.Context, source *User, rootMessage *database.Message, id string, metadata *discordgo.Channel) {
- thread := br.GetThreadByID(id, rootMessage)
- log := zerolog.Ctx(ctx)
- log.Debug().Msg("Marked message as thread root")
- if thread.CreationNoticeMXID == "" {
- thread.Parent.sendThreadCreationNotice(ctx, thread)
- }
- // TODO member_ids_preview is probably not guaranteed to contain the source user
- if source != nil && metadata != nil && slices.Contains(metadata.MemberIDsPreview, source.DiscordID) && !source.IsInPortal(thread.ID) {
- source.MarkInPortal(database.UserPortal{
- DiscordID: thread.ID,
- Type: database.UserPortalTypeThread,
- Timestamp: time.Now(),
- })
- if metadata.MessageCount > 0 {
- go thread.maybeInitialBackfill(source)
- } else {
- thread.initialBackfillAttempted = true
- }
- }
-}
-
-func (thread *Thread) maybeInitialBackfill(source *User) {
- if thread.initialBackfillAttempted || thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread == 0 {
- return
- }
- thread.Parent.forwardBackfillLock.Lock()
- if thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) != nil {
- thread.Parent.forwardBackfillLock.Unlock()
- return
- }
- thread.Parent.forwardBackfillInitial(source, thread)
-}
-
-func (thread *Thread) RefererOpt() discordgo.RequestOption {
- return discordgo.WithThreadReferer(thread.Parent.GuildID, thread.ParentID, thread.ID)
-}
-
-func (thread *Thread) Join(user *User) {
- if user.IsInPortal(thread.ID) {
- return
- }
- log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
- log.Debug().Msg("Joining thread")
-
- var doBackfill, backfillStarted bool
- if !thread.initialBackfillAttempted && thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread > 0 {
- thread.Parent.forwardBackfillLock.Lock()
- lastMessage := thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID)
- if lastMessage != nil {
- thread.Parent.forwardBackfillLock.Unlock()
- } else {
- doBackfill = true
- defer func() {
- if !backfillStarted {
- thread.Parent.forwardBackfillLock.Unlock()
- }
- }()
- }
- }
-
- var err error
- if user.Session.IsUser {
- err = user.Session.ThreadJoin(thread.ID, discordgo.WithLocationParam(discordgo.ThreadJoinLocationContextMenu), thread.RefererOpt())
- } else {
- err = user.Session.ThreadJoin(thread.ID)
- }
- if err != nil {
- log.Error().Err(err).Msg("Error joining thread")
- } else {
- user.MarkInPortal(database.UserPortal{
- DiscordID: thread.ID,
- Type: database.UserPortalTypeThread,
- Timestamp: time.Now(),
- })
- if doBackfill {
- go thread.Parent.forwardBackfillInitial(user, thread)
- backfillStarted = true
- }
- }
-}
diff --git a/user.go b/user.go
deleted file mode 100644
index 01a7203..0000000
--- a/user.go
+++ /dev/null
@@ -1,1575 +0,0 @@
-package main
-
-import (
- "context"
- "crypto/tls"
- "errors"
- "fmt"
- "math/rand"
- "net/http"
- "net/url"
- "os"
- "runtime/debug"
- "sort"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/gorilla/websocket"
- "github.com/rs/zerolog"
- "go.mau.fi/util/dbutil"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
- "maunium.net/go/mautrix/pushrules"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-var (
- ErrNotConnected = errors.New("not connected")
- ErrNotLoggedIn = errors.New("not logged in")
-)
-
-type User struct {
- *database.User
-
- sync.Mutex
-
- bridge *DiscordBridge
- log zerolog.Logger
-
- PermissionLevel bridgeconfig.PermissionLevel
-
- spaceCreateLock sync.Mutex
- spaceMembershipChecked bool
- dmSpaceMembershipChecked bool
-
- Session *discordgo.Session
-
- BridgeState *bridge.BridgeStateQueue
- bridgeStateLock sync.Mutex
- wasDisconnected bool
- wasLoggedOut bool
-
- markedOpened map[string]time.Time
- markedOpenedLock sync.Mutex
-
- pendingInteractions map[string]*WrappedCommandEvent
- pendingInteractionsLock sync.Mutex
-
- nextDiscordUploadID atomic.Int32
-
- relationships map[string]*discordgo.Relationship
- // relationshipsReady should be protected by relationshipLock and is merely
- // used to cover the brief moment in time where the readyHandler goroutine
- // is being scheduled; during that time, the relationships map is unlocked
- // and "available" but not logically "ready" just yet.
- relationshipsReady bool
- relationshipLock sync.RWMutex
-}
-
-func (user *User) GetRemoteID() string {
- return user.DiscordID
-}
-
-func (user *User) GetRemoteName() string {
- if user.Session != nil && user.Session.State != nil && user.Session.State.User != nil {
- if user.Session.State.User.Discriminator == "0" {
- return fmt.Sprintf("@%s", user.Session.State.User.Username)
- }
- return fmt.Sprintf("%s#%s", user.Session.State.User.Username, user.Session.State.User.Discriminator)
- }
- return user.DiscordID
-}
-
-var discordLog zerolog.Logger
-
-func discordToZeroLevel(level int) zerolog.Level {
- switch level {
- case discordgo.LogError:
- return zerolog.ErrorLevel
- case discordgo.LogWarning:
- return zerolog.WarnLevel
- case discordgo.LogInformational:
- return zerolog.InfoLevel
- case discordgo.LogDebug:
- fallthrough
- default:
- return zerolog.DebugLevel
- }
-}
-
-func init() {
- discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) {
- discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
- }
-}
-
-func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel {
- return user.PermissionLevel
-}
-
-func (user *User) GetManagementRoomID() id.RoomID {
- return user.ManagementRoom
-}
-
-func (user *User) GetMXID() id.UserID {
- return user.MXID
-}
-
-func (user *User) GetCommandState() map[string]interface{} {
- return nil
-}
-
-func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
- p := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if p == nil || p.CustomIntent() == nil {
- return nil
- }
- return p
-}
-
-func (user *User) GetIGhost() bridge.Ghost {
- if user.DiscordID == "" {
- return nil
- }
- p := user.bridge.GetPuppetByID(user.DiscordID)
- if p == nil {
- return nil
- }
- return p
-}
-
-var _ bridge.User = (*User)(nil)
-
-func (br *DiscordBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User {
- if dbUser == nil {
- if mxid == nil {
- return nil
- }
- dbUser = br.DB.User.New()
- dbUser.MXID = *mxid
- dbUser.Insert()
- }
-
- user := br.NewUser(dbUser)
- br.usersByMXID[user.MXID] = user
- if user.DiscordID != "" {
- br.usersByID[user.DiscordID] = user
- }
- if user.ManagementRoom != "" {
- br.managementRoomsLock.Lock()
- br.managementRooms[user.ManagementRoom] = user
- br.managementRoomsLock.Unlock()
- }
- return user
-}
-
-func (br *DiscordBridge) GetUserByMXID(userID id.UserID) *User {
- if userID == br.Bot.UserID || br.IsGhost(userID) {
- return nil
- }
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
-
- user, ok := br.usersByMXID[userID]
- if !ok {
- return br.loadUser(br.DB.User.GetByMXID(userID), &userID)
- }
- return user
-}
-
-func (br *DiscordBridge) GetUserByID(id string) *User {
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
-
- user, ok := br.usersByID[id]
- if !ok {
- return br.loadUser(br.DB.User.GetByID(id), nil)
- }
- return user
-}
-
-func (br *DiscordBridge) GetCachedUserByID(id string) *User {
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
- 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,
- bridge: br,
- log: br.ZLog.With().Str("user_id", string(dbUser.MXID)).Logger(),
-
- markedOpened: make(map[string]time.Time),
- PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID),
-
- pendingInteractions: make(map[string]*WrappedCommandEvent),
-
- relationships: make(map[string]*discordgo.Relationship),
- }
- user.nextDiscordUploadID.Store(rand.Int31n(100))
- user.BridgeState = br.NewBridgeStateQueue(user)
- return user
-}
-
-func (br *DiscordBridge) getAllUsersWithToken() []*User {
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
-
- dbUsers := br.DB.User.GetAllWithToken()
- users := make([]*User, len(dbUsers))
-
- for idx, dbUser := range dbUsers {
- user, ok := br.usersByMXID[dbUser.MXID]
- if !ok {
- user = br.loadUser(dbUser, nil)
- }
- users[idx] = user
- }
- return users
-}
-
-func (br *DiscordBridge) startUsers() {
- br.ZLog.Debug().Msg("Starting users")
-
- usersWithToken := br.getAllUsersWithToken()
- for _, u := range usersWithToken {
- go u.startupTryConnect(0)
- }
- if len(usersWithToken) == 0 {
- br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
- }
-
- br.ZLog.Debug().Msg("Starting custom puppets")
- for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() {
- go func(puppet *Puppet) {
- br.ZLog.Debug().Str("user_id", puppet.CustomMXID.String()).Msg("Starting custom puppet")
-
- if err := puppet.StartCustomMXID(true); err != nil {
- puppet.log.Error().Err(err).Msg("Failed to start custom puppet")
- }
- }(customPuppet)
- }
-}
-
-func (user *User) startupTryConnect(retryCount int) {
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
- err := user.Connect()
- if err != nil {
- user.log.Error().Err(err).Msg("Error connecting on startup")
- closeErr := &websocket.CloseError{}
- if errors.As(err, &closeErr) && closeErr.Code == 4004 {
- user.invalidAuthHandler(nil)
- } else if retryCount < 6 {
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-unknown-websocket-error", Message: err.Error()})
- retryInSeconds := 2 << retryCount
- user.log.Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
- time.Sleep(time.Duration(retryInSeconds) * time.Second)
- user.startupTryConnect(retryCount + 1)
- } else {
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "dc-unknown-websocket-error", Message: err.Error()})
- }
- }
-}
-
-func (user *User) SetManagementRoom(roomID id.RoomID) {
- user.bridge.managementRoomsLock.Lock()
- defer user.bridge.managementRoomsLock.Unlock()
-
- existing, ok := user.bridge.managementRooms[roomID]
- if ok {
- existing.ManagementRoom = ""
- existing.Update()
- }
-
- user.ManagementRoom = roomID
- user.bridge.managementRooms[user.ManagementRoom] = user
- user.Update()
-}
-
-func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.RoomID) id.RoomID {
- if len(*ptr) > 0 {
- return *ptr
- }
- user.spaceCreateLock.Lock()
- defer user.spaceCreateLock.Unlock()
- if len(*ptr) > 0 {
- return *ptr
- }
-
- initialState := []*event.Event{{
- Type: event.StateRoomAvatar,
- Content: event.Content{
- Parsed: &event.RoomAvatarEventContent{
- URL: user.bridge.Config.AppService.Bot.ParsedAvatar,
- },
- },
- }}
-
- if parent != "" {
- parentIDStr := parent.String()
- initialState = append(initialState, &event.Event{
- Type: event.StateSpaceParent,
- StateKey: &parentIDStr,
- Content: event.Content{
- Parsed: &event.SpaceParentEventContent{
- Canonical: true,
- Via: []string{user.bridge.AS.HomeserverDomain},
- },
- },
- })
- }
-
- resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: name,
- Topic: topic,
- InitialState: initialState,
- CreationContent: map[string]interface{}{
- "type": event.RoomTypeSpace,
- },
- PowerLevelOverride: &event.PowerLevelsEventContent{
- Users: map[id.UserID]int{
- user.bridge.Bot.UserID: 9001,
- user.MXID: 50,
- },
- },
- RoomVersion: "11",
- })
-
- if err != nil {
- user.log.Error().Err(err).Msg("Failed to auto-create space room")
- } else {
- *ptr = resp.RoomID
- user.Update()
- user.ensureInvited(nil, *ptr, false, true)
-
- if parent != "" {
- _, err = user.bridge.Bot.SendStateEvent(parent, event.StateSpaceChild, resp.RoomID.String(), &event.SpaceChildEventContent{
- Via: []string{user.bridge.AS.HomeserverDomain},
- Order: " 0000",
- })
- if err != nil {
- user.log.Error().Err(err).
- Str("created_space_id", resp.RoomID.String()).
- Str("parent_space_id", parent.String()).
- Msg("Failed to add created space room to parent space")
- }
- }
- }
- return *ptr
-}
-
-func (user *User) GetSpaceRoom() id.RoomID {
- return user.getSpaceRoom(&user.SpaceRoom, "Discord", "Your Discord bridged chats", "")
-}
-
-func (user *User) GetDMSpaceRoom() id.RoomID {
- return user.getSpaceRoom(&user.DMSpaceRoom, "Direct Messages", "Your Discord direct messages", user.GetSpaceRoom())
-}
-
-func (user *User) ViewingChannel(portal *Portal) bool {
- if portal.GuildID != "" || !user.Session.IsUser {
- return false
- }
- user.markedOpenedLock.Lock()
- defer user.markedOpenedLock.Unlock()
- ts := user.markedOpened[portal.Key.ChannelID]
- // TODO is there an expiry time?
- if ts.IsZero() {
- user.markedOpened[portal.Key.ChannelID] = time.Now()
- err := user.Session.MarkViewing(portal.Key.ChannelID)
- if err != nil {
- user.log.Error().Err(err).
- Str("channel_id", portal.Key.ChannelID).
- Msg("Failed to mark user as viewing channel")
- }
- return true
- }
- return false
-}
-
-func (user *User) mutePortal(intent *appservice.IntentAPI, portal *Portal, unmute bool) {
- if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteChannelsOnCreate {
- return
- }
- var err error
- if unmute {
- user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Unmuting portal")
- err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID))
- } else {
- user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Muting portal")
- err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{
- Actions: []pushrules.PushActionType{pushrules.ActionDontNotify},
- })
- }
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- user.log.Warn().Err(err).
- Str("room_id", portal.MXID.String()).
- Msg("Failed to update push rule through double puppet")
- }
-}
-
-func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
- doublePuppetIntent := portal.bridge.GetPuppetByCustomMXID(user.MXID).CustomIntent()
- if doublePuppetIntent == nil || portal.MXID == "" {
- return
- }
-
- // TODO sync mute status properly
- if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate && justCreated {
- user.mutePortal(doublePuppetIntent, portal, false)
- }
-}
-
-func (user *User) NextDiscordUploadID() string {
- val := user.nextDiscordUploadID.Add(2)
- return strconv.Itoa(int(val))
-}
-
-func (user *User) Login(token string) error {
- user.bridgeStateLock.Lock()
- user.wasLoggedOut = false
- user.bridgeStateLock.Unlock()
- user.DiscordToken = token
- var err error
- const maxRetries = 3
-Loop:
- for i := 0; i < maxRetries; i++ {
- err = user.Connect()
- if err == nil {
- user.Update()
- return nil
- }
- user.log.Error().Err(err).Msg("Error connecting for login")
- closeErr := &websocket.CloseError{}
- errors.As(err, &closeErr)
- switch closeErr.Code {
- case 4004, 4010, 4011, 4012, 4013, 4014:
- break Loop
- case 4000:
- fallthrough
- default:
- if i < maxRetries-1 {
- time.Sleep(time.Duration(i+1) * 2 * time.Second)
- }
- }
- }
- user.DiscordToken = ""
- return err
-}
-
-func (user *User) IsLoggedIn() bool {
- user.Lock()
- defer user.Unlock()
-
- return user.DiscordToken != ""
-}
-
-func (user *User) Logout(isOverwriting bool) {
- user.Lock()
- defer user.Unlock()
-
- if user.DiscordID != "" {
- puppet := user.bridge.GetPuppetByID(user.DiscordID)
- if puppet.CustomMXID != "" {
- err := puppet.SwitchCustomMXID("", "")
- if err != nil {
- user.log.Warn().Err(err).Msg("Failed to disable custom puppet while logging out of Discord")
- }
- }
- }
-
- if user.Session != nil {
- if err := user.Session.Close(); err != nil {
- user.log.Warn().Err(err).Msg("Error closing session")
- }
- }
-
- user.Session = nil
- user.reconstructRelationships(nil)
- user.DiscordToken = ""
- user.ReadStateVersion = 0
- if !isOverwriting {
- user.bridge.usersLock.Lock()
- if user.bridge.usersByID[user.DiscordID] == user {
- delete(user.bridge.usersByID, user.DiscordID)
- }
- user.bridge.usersLock.Unlock()
- }
- user.DiscordID = ""
- user.Update()
- user.log.Info().Msg("User logged out")
-}
-
-func (user *User) reconstructRelationships(relationships []*discordgo.Relationship) {
- user.relationshipLock.Lock()
- defer user.relationshipLock.Unlock()
-
- clear(user.relationships)
-
- if relationships == nil {
- // Relationships are just being cleared out; we don't actually have
- // them yet.
- user.relationshipsReady = false
- } else {
- // We've received the authoritative list of relationships from the
- // gateway.
- for _, relationship := range relationships {
- user.relationships[relationship.ID] = relationship
- }
- user.relationshipsReady = true
- }
-}
-
-func (user *User) Connected() bool {
- user.Lock()
- defer user.Unlock()
-
- return user.Session != nil
-}
-
-const BotIntents = discordgo.IntentGuilds |
- discordgo.IntentGuildMessages |
- discordgo.IntentGuildMessageReactions |
- discordgo.IntentGuildMessageTyping |
- discordgo.IntentGuildBans |
- discordgo.IntentGuildEmojis |
- discordgo.IntentGuildIntegrations |
- discordgo.IntentGuildInvites |
- //discordgo.IntentGuildVoiceStates |
- //discordgo.IntentGuildScheduledEvents |
- discordgo.IntentDirectMessages |
- discordgo.IntentDirectMessageTyping |
- discordgo.IntentDirectMessageTyping |
- // Privileged intents
- discordgo.IntentMessageContent |
- //discordgo.IntentGuildPresences |
- discordgo.IntentGuildMembers
-
-func (user *User) Connect() error {
- user.Lock()
- // Clear our in-memory relationship cache as it might've changed while
- // offline; READY will repopulate it.
- user.reconstructRelationships(nil)
- defer user.Unlock()
-
- if user.DiscordToken == "" {
- return ErrNotLoggedIn
- }
-
- user.log.Debug().Msg("Connecting to discord")
-
- session, err := discordgo.New(user.DiscordToken)
- 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").
- 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...) // 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 {
- err = user.Session.Open()
- if errors.Is(err, discordgo.ErrImmediateDisconnect) {
- user.log.Warn().Err(err).Msg("Retrying initial connection in 5 seconds")
- time.Sleep(5 * time.Second)
- continue
- }
- return err
- }
-}
-
-func (user *User) eventHandlerSync(rawEvt any) {
- go user.eventHandler(rawEvt)
-}
-
-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)
- case *discordgo.Resumed:
- user.resumeHandler(evt)
- case *discordgo.Connect:
- user.connectedHandler(evt)
- case *discordgo.Disconnect:
- user.disconnectedHandler(evt)
- case *discordgo.InvalidAuth:
- user.invalidAuthHandler(evt)
- case *discordgo.GuildCreate:
- user.guildCreateHandler(evt)
- case *discordgo.GuildDelete:
- user.guildDeleteHandler(evt)
- case *discordgo.GuildUpdate:
- user.guildUpdateHandler(evt)
- case *discordgo.GuildRoleCreate:
- user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
- case *discordgo.GuildRoleUpdate:
- user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
- case *discordgo.GuildRoleDelete:
- user.bridge.DB.Role.DeleteByID(evt.GuildID, evt.RoleID)
- case *discordgo.ChannelCreate:
- user.channelCreateHandler(evt)
- case *discordgo.ChannelDelete:
- user.channelDeleteHandler(evt)
- case *discordgo.ChannelUpdate:
- user.channelUpdateHandler(evt)
- case *discordgo.ChannelRecipientAdd:
- user.channelRecipientAdd(evt)
- case *discordgo.ChannelRecipientRemove:
- user.channelRecipientRemove(evt)
- case *discordgo.RelationshipAdd:
- user.relationshipAddHandler(evt)
- case *discordgo.RelationshipRemove:
- user.relationshipRemoveHandler(evt)
- case *discordgo.RelationshipUpdate:
- user.relationshipUpdateHandler(evt)
- case *discordgo.MessageCreate:
- user.pushPortalMessage(evt, "message create", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageDelete:
- user.pushPortalMessage(evt, "message delete", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageDeleteBulk:
- user.pushPortalMessage(evt, "bulk message delete", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageUpdate:
- user.pushPortalMessage(evt, "message update", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageReactionAdd:
- user.pushPortalMessage(evt, "reaction add", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageReactionRemove:
- user.pushPortalMessage(evt, "reaction remove", evt.ChannelID, evt.GuildID)
- case *discordgo.MessageAck:
- user.messageAckHandler(evt)
- case *discordgo.TypingStart:
- user.typingStartHandler(evt)
- case *discordgo.InteractionSuccess:
- user.interactionSuccessHandler(evt)
- case *discordgo.ThreadListSync:
- user.threadListSyncHandler(evt)
- case *discordgo.Event:
- // Ignore
- default:
- user.log.Debug().Type("event_type", evt).Msg("Unhandled event")
- }
-}
-
-func (user *User) Disconnect() error {
- user.Lock()
- defer user.Unlock()
- if user.Session == nil {
- return ErrNotConnected
- }
-
- user.log.Info().Msg("Disconnecting session manually")
- user.reconstructRelationships(nil)
- if err := user.Session.Close(); err != nil {
- return err
- }
- user.Session = nil
- return nil
-}
-
-func (user *User) getGuildBridgingMode(guildID string) database.GuildBridgingMode {
- if guildID == "" {
- return database.GuildBridgeEverything
- }
- guild := user.bridge.GetGuildByID(guildID, false)
- if guild == nil {
- return database.GuildBridgeNothing
- }
- return guild.BridgingMode
-}
-
-type ChannelSlice []*discordgo.Channel
-
-func (s ChannelSlice) Len() int {
- return len(s)
-}
-
-func (s ChannelSlice) Less(i, j int) bool {
- if s[i].Position != 0 || s[j].Position != 0 {
- return s[i].Position < s[j].Position
- }
- return compareMessageIDs(s[i].LastMessageID, s[j].LastMessageID) == 1
-}
-
-func (s ChannelSlice) Swap(i, j int) {
- s[i], s[j] = s[j], s[i]
-}
-
-func (user *User) readyHandler(r *discordgo.Ready) {
- user.log.Debug().Msg("Discord connection ready")
- user.bridgeStateLock.Lock()
- user.wasLoggedOut = false
- user.bridgeStateLock.Unlock()
-
- if user.DiscordID != r.User.ID {
- user.bridge.usersLock.Lock()
- user.DiscordID = r.User.ID
- if previousUser, ok := user.bridge.usersByID[user.DiscordID]; ok && previousUser != user {
- user.log.Warn().
- Str("previous_user_id", previousUser.MXID.String()).
- Msg("Another user is logged in with same Discord ID, logging them out")
- // TODO send notice?
- previousUser.Logout(true)
- }
- user.bridge.usersByID[user.DiscordID] = user
- user.bridge.usersLock.Unlock()
- user.Update()
- }
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling})
- user.tryAutomaticDoublePuppeting()
-
- user.reconstructRelationships(r.Relationships)
-
- updateTS := time.Now()
- portalsInSpace := make(map[string]bool)
- for _, guild := range user.GetPortals() {
- portalsInSpace[guild.DiscordID] = guild.InSpace
- }
- for _, guild := range r.Guilds {
- user.handleGuild(guild, updateTS, portalsInSpace[guild.ID])
- }
- // The private channel list doesn't seem to be sorted by default, so sort it by message IDs (highest=newest first)
- sort.Sort(ChannelSlice(r.PrivateChannels))
- for i, ch := range r.PrivateChannels {
- portal := user.GetPortalByMeta(ch)
- user.handlePrivateChannel(portal, ch, updateTS, i < user.bridge.Config.Bridge.PrivateChannelCreateLimit, portalsInSpace[portal.Key.ChannelID])
- }
- user.PrunePortalList(updateTS)
-
- if r.ReadState != nil && r.ReadState.Version > user.ReadStateVersion {
- // TODO can we figure out which read states are actually new?
- for _, entry := range r.ReadState.Entries {
- user.messageAckHandler(&discordgo.MessageAck{
- MessageID: string(entry.LastMessageID),
- ChannelID: entry.ID,
- })
- }
- user.ReadStateVersion = r.ReadState.Version
- user.Update()
- }
-
- go user.subscribeGuilds(2 * time.Second)
-
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
-}
-
-func (user *User) subscribeGuilds(delay time.Duration) {
- if !user.Session.IsUser {
- return
- }
- for _, guildMeta := range user.Session.State.Guilds {
- guild := user.bridge.GetGuildByID(guildMeta.ID, false)
- if guild != nil && guild.MXID != "" {
- user.log.Debug().Str("guild_id", guild.ID).Msg("Subscribing to guild")
- dat := discordgo.GuildSubscribeData{
- GuildID: guild.ID,
- Typing: true,
- Activities: true,
- Threads: true,
- }
- err := user.Session.SubscribeGuild(dat)
- if err != nil {
- user.log.Warn().Err(err).Str("guild_id", guild.ID).Msg("Failed to subscribe to guild")
- }
- time.Sleep(delay)
- }
- }
-}
-
-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 {
- if portal.MXID == "" {
- return false
- }
- _, err := user.bridge.Bot.SendStateEvent(user.GetDMSpaceRoom(), event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
- Via: []string{user.bridge.AS.HomeserverDomain},
- })
- if err != nil {
- user.log.Error().Err(err).
- Str("room_id", portal.MXID.String()).
- Msg("Failed to add DMM room to user DM space")
- return false
- } else {
- return true
- }
-}
-
-func (user *User) relationshipAddHandler(r *discordgo.RelationshipAdd) {
- user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship added")
- user.relationshipLock.Lock()
- defer user.relationshipLock.Unlock()
- user.relationships[r.ID] = r.Relationship
- user.handleRelationshipChange(r.ID, r.Nickname)
-}
-
-func (user *User) relationshipUpdateHandler(r *discordgo.RelationshipUpdate) {
- user.relationshipLock.Lock()
- defer user.relationshipLock.Unlock()
- user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship update")
- user.relationships[r.ID] = r.Relationship
- user.handleRelationshipChange(r.ID, r.Nickname)
-}
-
-func (user *User) relationshipRemoveHandler(r *discordgo.RelationshipRemove) {
- user.relationshipLock.Lock()
- defer user.relationshipLock.Unlock()
- user.log.Debug().Str("other_user_id", r.ID).Msg("Relationship removed")
- delete(user.relationships, r.ID)
- user.handleRelationshipChange(r.ID, "")
-}
-
-func (user *User) handleRelationshipChange(userID, nickname string) {
- puppet := user.bridge.GetPuppetByID(userID)
- portal := user.FindPrivateChatWith(userID)
- if portal == nil || puppet == nil {
- return
- }
-
- updated := portal.FriendNick == (nickname != "")
- portal.FriendNick = nickname != ""
- if nickname != "" {
- updated = portal.UpdateNameDirect(nickname, true)
- } else if portal.Name != puppet.Name {
- if portal.shouldSetDMRoomMetadata() {
- updated = portal.UpdateNameDirect(puppet.Name, false)
- } else if portal.NameSet {
- _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateRoomName, "", map[string]any{})
- if err != nil {
- portal.log.Warn().Err(err).Msg("Failed to clear room name after friend nickname was removed")
- } else {
- portal.log.Debug().Msg("Cleared room name after friend nickname was removed")
- portal.NameSet = false
- portal.Update()
- updated = true
- }
- }
- }
- if !updated {
- portal.Update()
- }
-}
-
-func (user *User) handlePrivateChannel(portal *Portal, meta *discordgo.Channel, timestamp time.Time, create, isInSpace bool) {
- if create && portal.MXID == "" {
- err := portal.CreateMatrixRoom(user, meta)
- if err != nil {
- user.log.Error().Err(err).
- Str("channel_id", portal.Key.ChannelID).
- Msg("Failed to create portal for private channel in create handler")
- }
- } else {
- portal.UpdateInfo(user, meta)
- portal.ForwardBackfillMissed(user, meta.LastMessageID, nil)
- }
- user.MarkInPortal(database.UserPortal{
- DiscordID: portal.Key.ChannelID,
- Type: database.UserPortalTypeDM,
- Timestamp: timestamp,
- InSpace: isInSpace || user.addPrivateChannelToSpace(portal),
- })
-}
-
-func (user *User) addGuildToSpace(guild *Guild, isInSpace bool, timestamp time.Time) bool {
- if len(guild.MXID) > 0 && !isInSpace {
- _, err := user.bridge.Bot.SendStateEvent(user.GetSpaceRoom(), event.StateSpaceChild, guild.MXID.String(), &event.SpaceChildEventContent{
- Via: []string{user.bridge.AS.HomeserverDomain},
- })
- if err != nil {
- user.log.Error().Err(err).
- Str("guild_space_id", guild.MXID.String()).
- Msg("Failed to add guild space to user space")
- } else {
- isInSpace = true
- }
- }
- user.MarkInPortal(database.UserPortal{
- DiscordID: guild.ID,
- Type: database.UserPortalTypeGuild,
- Timestamp: timestamp,
- InSpace: isInSpace,
- })
- return isInSpace
-}
-
-func (user *User) discordRoleToDB(guildID string, role *discordgo.Role, dbRole *database.Role, txn dbutil.Execable) bool {
- var changed bool
- if dbRole == nil {
- dbRole = user.bridge.DB.Role.New()
- dbRole.ID = role.ID
- dbRole.GuildID = guildID
- changed = true
- } else {
- changed = dbRole.Name != role.Name ||
- dbRole.Icon != role.Icon ||
- dbRole.Mentionable != role.Mentionable ||
- dbRole.Managed != role.Managed ||
- dbRole.Hoist != role.Hoist ||
- dbRole.Color != role.Color ||
- dbRole.Position != role.Position ||
- dbRole.Permissions != role.Permissions
- }
- dbRole.Role = *role
- if changed {
- dbRole.Upsert(txn)
- }
- return changed
-}
-
-func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
- existingRoles := user.bridge.DB.Role.GetAll(guildID)
- existingRoleMap := make(map[string]*database.Role, len(existingRoles))
- for _, role := range existingRoles {
- existingRoleMap[role.ID] = role
- }
- txn, err := user.bridge.DB.Begin()
- if err != nil {
- user.log.Error().Err(err).Msg("Failed to start transaction for guild role sync")
- panic(err)
- }
- for _, role := range newRoles {
- user.discordRoleToDB(guildID, role, existingRoleMap[role.ID], txn)
- delete(existingRoleMap, role.ID)
- }
- for _, removeRole := range existingRoleMap {
- removeRole.Delete(txn)
- }
- err = txn.Commit()
- if err != nil {
- user.log.Error().Err(err).Msg("Failed to commit guild role sync transaction")
- rollbackErr := txn.Rollback()
- if rollbackErr != nil {
- user.log.Error().Err(rollbackErr).Msg("Failed to rollback errored guild role sync transaction")
- }
- panic(err)
- }
-}
-
-func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSpace bool) {
- guild := user.bridge.GetGuildByID(meta.ID, true)
- guild.UpdateInfo(user, meta)
- if len(meta.Channels) > 0 {
- for _, ch := range meta.Channels {
- if !user.channelIsBridgeable(ch) {
- continue
- }
- portal := user.GetPortalByMeta(ch)
- if guild.BridgingMode >= database.GuildBridgeEverything && portal.MXID == "" {
- err := portal.CreateMatrixRoom(user, ch)
- if err != nil {
- user.log.Error().Err(err).
- Str("guild_id", guild.ID).
- Str("channel_id", ch.ID).
- Msg("Failed to create portal for guild channel in guild handler")
- }
- } else {
- portal.UpdateInfo(user, ch)
- if user.bridge.Config.Bridge.Backfill.MaxGuildMembers < 0 || meta.MemberCount < user.bridge.Config.Bridge.Backfill.MaxGuildMembers {
- portal.ForwardBackfillMissed(user, ch.LastMessageID, nil)
- }
- }
- }
- }
- if len(meta.Roles) > 0 {
- user.handleGuildRoles(meta.ID, meta.Roles)
- }
- user.addGuildToSpace(guild, isInSpace, timestamp)
-}
-
-func (user *User) connectedHandler(_ *discordgo.Connect) {
- user.bridgeStateLock.Lock()
- defer user.bridgeStateLock.Unlock()
- user.log.Debug().Msg("Connected to Discord")
- if user.wasDisconnected {
- user.wasDisconnected = false
- }
-}
-
-func (user *User) disconnectedHandler(_ *discordgo.Disconnect) {
- user.bridgeStateLock.Lock()
- defer user.bridgeStateLock.Unlock()
- if user.wasLoggedOut {
- user.log.Debug().Msg("Disconnected from Discord (not updating bridge state as user was just logged out)")
- return
- }
- user.log.Debug().Msg("Disconnected from Discord")
- user.wasDisconnected = true
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-transient-disconnect", Message: "Temporarily disconnected from Discord, trying to reconnect"})
-}
-
-func (user *User) invalidAuthHandler(_ *discordgo.InvalidAuth) {
- user.bridgeStateLock.Lock()
- defer user.bridgeStateLock.Unlock()
- user.log.Info().Msg("Got logged out from Discord due to invalid token")
- user.wasLoggedOut = true
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-websocket-disconnect-4004", Message: "Discord access token is no longer valid, please log in again"})
- 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).
- Str("name", g.Name).
- Bool("unavailable", g.Unavailable).
- Msg("Got guild create event")
- user.handleGuild(g.Guild, time.Now(), false)
-}
-
-func (user *User) guildDeleteHandler(g *discordgo.GuildDelete) {
- if g.Unavailable {
- user.log.Info().Str("guild_id", g.ID).Msg("Ignoring guild delete event with unavailable flag")
- return
- }
- user.log.Info().Str("guild_id", g.ID).Msg("Got guild delete event")
- user.MarkNotInPortal(g.ID)
- guild := user.bridge.GetGuildByID(g.ID, false)
- if guild == nil || guild.MXID == "" {
- return
- }
- if user.bridge.Config.Bridge.DeleteGuildOnLeave && !user.PortalHasOtherUsers(g.ID) {
- user.log.Debug().Str("guild_id", g.ID).Msg("No other users in guild, cleaning up all portals")
- err := user.unbridgeGuild(g.ID)
- if err != nil {
- user.log.Warn().Err(err).Msg("Failed to unbridge guild that was deleted")
- }
- }
-}
-
-func (user *User) guildUpdateHandler(g *discordgo.GuildUpdate) {
- user.log.Debug().Str("guild_id", g.ID).Msg("Got guild update event")
- user.handleGuild(g.Guild, time.Now(), user.IsInSpace(g.ID))
-}
-
-func (user *User) threadListSyncHandler(t *discordgo.ThreadListSync) {
- for _, meta := range t.Threads {
- log := user.log.With().
- Str("action", "thread list sync").
- Str("guild_id", t.GuildID).
- Str("parent_id", meta.ParentID).
- Str("thread_id", meta.ID).
- Logger()
- ctx := log.WithContext(context.Background())
- thread := user.bridge.GetThreadByID(meta.ID, nil)
- if thread == nil {
- msg := user.bridge.DB.Message.GetByDiscordID(database.NewPortalKey(meta.ParentID, ""), meta.ID)
- if len(msg) == 0 {
- log.Debug().Msg("Found unknown thread in thread list sync and don't have message")
- } else {
- log.Debug().Msg("Found unknown thread in thread list sync for existing message, creating thread")
- user.bridge.threadFound(ctx, user, msg[0], meta.ID, meta)
- }
- } else {
- thread.Parent.ForwardBackfillMissed(user, meta.LastMessageID, thread)
- }
- }
-}
-
-func (user *User) channelCreateHandler(c *discordgo.ChannelCreate) {
- if user.getGuildBridgingMode(c.GuildID) < database.GuildBridgeEverything {
- user.log.Debug().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Ignoring channel create event in unbridged guild")
- return
- }
- user.log.Info().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Got channel create event")
- portal := user.GetPortalByMeta(c.Channel)
- if portal.MXID != "" {
- return
- }
- if c.GuildID == "" {
- user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String()))
- } else if user.channelIsBridgeable(c.Channel) {
- err := portal.CreateMatrixRoom(user, c.Channel)
- if err != nil {
- user.log.Error().Err(err).
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Error creating Matrix room after channel create event")
- }
- } else {
- user.log.Debug().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Got channel create event, but it's not bridgeable, ignoring")
- }
-}
-
-func (user *User) channelDeleteHandler(c *discordgo.ChannelDelete) {
- portal := user.GetExistingPortalByID(c.ID)
- if portal == nil {
- user.log.Debug().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Ignoring channel delete event of unknown channel")
- return
- }
- user.log.Info().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Got channel delete event, cleaning up portal")
- portal.Delete()
- portal.cleanup(!user.bridge.Config.Bridge.DeletePortalOnChannelDelete)
- if c.GuildID == "" {
- user.MarkNotInPortal(portal.Key.ChannelID)
- }
- user.log.Debug().
- Str("guild_id", c.GuildID).Str("channel_id", c.ID).
- Msg("Completed cleaning up channel")
-}
-
-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 if user.channelIsBridgeable(c.Channel) {
- portal.UpdateInfo(user, c.Channel)
- }
-}
-
-func (user *User) channelRecipientAdd(c *discordgo.ChannelRecipientAdd) {
- portal := user.GetExistingPortalByID(c.ChannelID)
- if portal != nil {
- portal.syncParticipant(user, c.User, false)
- }
-}
-
-func (user *User) channelRecipientRemove(c *discordgo.ChannelRecipientRemove) {
- portal := user.GetExistingPortalByID(c.ChannelID)
- if portal != nil {
- portal.syncParticipant(user, c.User, true)
- }
-}
-
-func (user *User) findPortal(channelID string) (*Portal, *Thread) {
- portal := user.GetExistingPortalByID(channelID)
- if portal != nil {
- return portal, nil
- }
- thread := user.bridge.GetThreadByID(channelID, nil)
- if thread != nil && thread.Parent != nil {
- return thread.Parent, thread
- }
- if !user.Session.IsUser {
- channel, _ := user.Session.State.Channel(channelID)
- if channel == nil {
- user.log.Debug().Str("channel_id", channelID).Msg("Fetching info of unknown channel to handle message")
- var err error
- channel, err = user.Session.Channel(channelID)
- if err != nil {
- user.log.Warn().Err(err).Str("channel_id", channelID).Msg("Failed to get info of unknown channel")
- } else {
- user.log.Debug().Str("channel_id", channelID).Msg("Got info for channel to handle message")
- _ = user.Session.State.ChannelAdd(channel)
- }
- }
- if channel != nil && user.channelIsBridgeable(channel) {
- user.log.Debug().Str("channel_id", channelID).Msg("Creating portal and updating info to handle message")
- portal = user.GetPortalByMeta(channel)
- if channel.GuildID == "" {
- user.handlePrivateChannel(portal, channel, time.Now(), false, false)
- } else {
- user.log.Warn().
- Str("channel_id", channel.ID).Str("guild_id", channel.GuildID).
- Msg("Unexpected unknown guild channel")
- }
- return portal, nil
- }
- }
- return nil, nil
-}
-
-func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildID string) {
- if user.getGuildBridgingMode(guildID) <= database.GuildBridgeNothing {
- // If guild bridging mode is nothing, don't even check if the portal exists
- return
- }
-
- portal, thread := user.findPortal(channelID)
- if portal == nil {
- user.log.Debug().
- Str("discord_event", typeName).
- Str("guild_id", guildID).
- Str("channel_id", channelID).
- Msg("Dropping event in unknown channel")
- return
- }
- if mode := user.getGuildBridgingMode(portal.GuildID); mode <= database.GuildBridgeNothing || (portal.MXID == "" && mode <= database.GuildBridgeIfPortalExists) {
- return
- }
-
- wrappedMsg := portalDiscordMessage{
- msg: msg,
- user: user,
- thread: thread,
- }
- select {
- case portal.discordMessages <- wrappedMsg:
- default:
- user.log.Warn().
- Str("discord_event", typeName).
- Str("guild_id", guildID).
- Str("channel_id", channelID).
- Msg("Portal message buffer is full")
- portal.discordMessages <- wrappedMsg
- }
-}
-
-type CustomReadReceipt struct {
- Timestamp int64 `json:"ts,omitempty"`
- DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"`
-}
-
-type CustomReadMarkers struct {
- mautrix.ReqSetReadMarkers
- ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"`
- FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"`
-}
-
-func (user *User) makeReadMarkerContent(eventID id.EventID) *CustomReadMarkers {
- var extra CustomReadReceipt
- extra.DoublePuppetSource = user.bridge.Name
- return &CustomReadMarkers{
- ReqSetReadMarkers: mautrix.ReqSetReadMarkers{
- Read: eventID,
- FullyRead: eventID,
- },
- ReadExtra: extra,
- FullyReadExtra: extra,
- }
-}
-
-func (user *User) messageAckHandler(m *discordgo.MessageAck) {
- portal := user.GetExistingPortalByID(m.ChannelID)
- if portal == nil || portal.MXID == "" {
- return
- }
- dp := user.GetIDoublePuppet()
- if dp == nil {
- return
- }
- msg := user.bridge.DB.Message.GetLastByDiscordID(portal.Key, m.MessageID)
- if msg == nil {
- user.log.Debug().
- Str("channel_id", m.ChannelID).Str("message_id", m.MessageID).
- Msg("Dropping message ack event for unknown message")
- return
- }
- err := dp.CustomIntent().SetReadMarkers(portal.MXID, user.makeReadMarkerContent(msg.MXID))
- if err != nil {
- user.log.Error().Err(err).
- Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
- Msg("Failed to mark event as read")
- } else {
- user.log.Debug().
- Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
- Msg("Marked event as read after Discord message ack")
- if user.ReadStateVersion < m.Version {
- user.ReadStateVersion = m.Version
- // TODO maybe don't update every time?
- user.Update()
- }
- }
-}
-
-func (user *User) typingStartHandler(t *discordgo.TypingStart) {
- if t.UserID == user.DiscordID {
- return
- }
- portal := user.GetExistingPortalByID(t.ChannelID)
- if portal == nil || portal.MXID == "" {
- return
- }
- targetUser := user.bridge.GetCachedUserByID(t.UserID)
- if targetUser != nil {
- return
- }
- portal.handleDiscordTyping(t)
-}
-
-func (user *User) interactionSuccessHandler(s *discordgo.InteractionSuccess) {
- user.pendingInteractionsLock.Lock()
- defer user.pendingInteractionsLock.Unlock()
- ce, ok := user.pendingInteractions[s.Nonce]
- if !ok {
- user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for unknown interaction")
- } else {
- user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for pending interaction")
- ce.React("✅")
- delete(user.pendingInteractions, s.Nonce)
- }
-}
-
-func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect, ignoreCache bool) bool {
- if roomID == "" {
- return false
- }
- if intent == nil {
- intent = user.bridge.Bot
- }
- if !ignoreCache && intent.StateStore.IsInvited(roomID, user.MXID) {
- return true
- }
- ret := false
-
- inviteContent := event.Content{
- Parsed: &event.MemberEventContent{
- Membership: event.MembershipInvite,
- IsDirect: isDirect,
- },
- Raw: map[string]interface{}{},
- }
-
- customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- inviteContent.Raw["fi.mau.will_auto_accept"] = true
- }
-
- _, err := intent.SendStateEvent(roomID, event.StateMember, user.MXID.String(), &inviteContent)
-
- var httpErr mautrix.HTTPError
- if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
- user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin)
- ret = true
- } else if err != nil {
- user.log.Error().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room")
- } else {
- ret = true
- }
-
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
- if err != nil {
- user.log.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room")
- ret = false
- } else {
- ret = true
- }
- }
-
- return ret
-}
-
-func (user *User) getDirectChats() map[id.UserID][]id.RoomID {
- chats := map[id.UserID][]id.RoomID{}
-
- privateChats := user.bridge.DB.Portal.FindPrivateChatsOf(user.DiscordID)
- for _, portal := range privateChats {
- if portal.MXID != "" {
- puppetMXID := user.bridge.FormatPuppetMXID(portal.Key.Receiver)
-
- chats[puppetMXID] = []id.RoomID{portal.MXID}
- }
- }
-
- return chats
-}
-
-func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
- if !user.bridge.Config.Bridge.SyncDirectChatList {
- return
- }
-
- puppet := user.bridge.GetPuppetByMXID(user.MXID)
- if puppet == nil {
- return
- }
-
- intent := puppet.CustomIntent()
- if intent == nil {
- return
- }
-
- method := http.MethodPatch
- if chats == nil {
- chats = user.getDirectChats()
- method = http.MethodPut
- }
-
- user.log.Debug().Msg("Updating m.direct list on homeserver")
-
- var err error
- if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux {
- urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
- _, err = intent.MakeFullRequest(mautrix.FullRequest{
- Method: method,
- URL: urlPath,
- Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}},
- RequestJSON: chats,
- })
- } else {
- existingChats := map[id.UserID][]id.RoomID{}
-
- err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
- if err != nil {
- user.log.Warn().Err(err).Msg("Failed to get m.direct event to update it")
- return
- }
-
- for userID, rooms := range existingChats {
- if _, ok := user.bridge.ParsePuppetMXID(userID); !ok {
- // This is not a ghost user, include it in the new list
- chats[userID] = rooms
- } else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
- // This is a ghost user, but we're not replacing the whole list, so include it too
- chats[userID] = rooms
- }
- }
-
- err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats)
- }
-
- if err != nil {
- user.log.Warn().Err(err).Msg("Failed to update m.direct event")
- }
-}
-
-func (user *User) bridgeGuild(guildID string, everything bool) error {
- guild := user.bridge.GetGuildByID(guildID, false)
- if guild == nil {
- return errors.New("guild not found")
- }
- meta, _ := user.Session.State.Guild(guildID)
- err := guild.CreateMatrixRoom(user, meta)
- if err != nil {
- return err
- }
- log := user.log.With().Str("guild_id", guild.ID).Logger()
- user.addGuildToSpace(guild, false, time.Now())
- for _, ch := range meta.Channels {
- portal := user.GetPortalByMeta(ch)
- if (everything && user.channelIsBridgeable(ch)) || ch.Type == discordgo.ChannelTypeGuildCategory {
- err = portal.CreateMatrixRoom(user, ch)
- if err != nil {
- log.Error().Err(err).Str("channel_id", ch.ID).
- Msg("Failed to create room for guild channel while bridging guild")
- }
- }
- }
- if everything {
- guild.BridgingMode = database.GuildBridgeEverything
- } else {
- guild.BridgingMode = database.GuildBridgeCreateOnMessage
- }
- guild.Update()
-
- if user.Session.IsUser {
- log.Debug().Msg("Subscribing to guild after bridging")
- err = user.Session.SubscribeGuild(discordgo.GuildSubscribeData{
- GuildID: guild.ID,
- Typing: true,
- Activities: true,
- Threads: true,
- })
- if err != nil {
- log.Warn().Err(err).Msg("Failed to subscribe to guild")
- }
- }
-
- return nil
-}
-
-func (user *User) unbridgeGuild(guildID string) error {
- if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin && user.PortalHasOtherUsers(guildID) {
- return errors.New("only bridge admins can unbridge guilds with other users")
- }
- guild := user.bridge.GetGuildByID(guildID, false)
- if guild == nil {
- return errors.New("guild not found")
- }
- guild.roomCreateLock.Lock()
- defer guild.roomCreateLock.Unlock()
- if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
- return errors.New("that guild is not bridged")
- }
- guild.BridgingMode = database.GuildBridgeNothing
- guild.Update()
- for _, portal := range user.bridge.GetAllPortalsInGuild(guild.ID) {
- portal.cleanup(false)
- portal.RemoveMXID()
- }
- guild.cleanup()
- guild.RemoveMXID()
- return nil
-}