diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 173e36a..b238c9c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,8 +8,8 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.24", "1.25"] - name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }} + go-version: ["1.25", "1.26"] + name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0f49322..0d40ec7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: lock-stale: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 id: lock with: issue-inactive-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f53a95..fa9931a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# v0.7.7 (2026-08-16) + +* Updated Docker image to Alpine 3.24. +* Added option to disable DMs to non-friends. +* Fixed panic if editing a message fails. +* Fixed websocket pings when using bot accounts on Discord. + +# v0.7.6 (2026-02-16) + +* Bumped minimum Go version to 1.25. +* Updated Docker image to Alpine 3.23. +* Added support for following tombstones. +* Added support for disabling link previews in messages sent to Discord using + [MSC4095]. +* Added support for federation thumbnail endpoint when using direct media. +* Disabled using `restricted` join rules by default. + +[MSC4095]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095 + # v0.7.5 (2025-07-16) * Fixed federation key response when using direct media. diff --git a/Dockerfile b/Dockerfile index 4664399..1e1c98a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1-alpine3.22 AS builder +FROM golang:1-alpine3.24 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev @@ -6,7 +6,7 @@ COPY . /build WORKDIR /build RUN go build -o /usr/bin/mautrix-discord -FROM alpine:3.22 +FROM alpine:3.24 ENV UID=1337 \ GID=1337 @@ -17,5 +17,6 @@ COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/Dockerfile.ci b/Dockerfile.ci index 32b9c35..a77bffc 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,4 +1,4 @@ -FROM alpine:3.22 +FROM alpine:3.24 ENV UID=1337 \ GID=1337 @@ -10,5 +10,6 @@ COPY $EXECUTABLE /usr/bin/mautrix-discord COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./docker-run.sh /docker-run.sh VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/config/bridge.go b/config/bridge.go index 2f78ed7..c546aa8 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -56,6 +56,7 @@ type BridgeConfig struct { PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"` EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"` UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"` + ForbidDMingStrangers bool `yaml:"forbid_dming_strangers"` Proxy string `yaml:"proxy"` diff --git a/config/upgrade.go b/config/upgrade.go index 1c9fe56..3d7a9fa 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -63,6 +63,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Bool, "bridge", "prefix_webhook_messages") helper.Copy(up.Bool, "bridge", "enable_webhook_avatars") helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload") + helper.Copy(up.Bool, "bridge", "forbid_dming_strangers") helper.Copy(up.Str|up.Null, "bridge", "proxy") helper.Copy(up.Str, "bridge", "cache_media") helper.Copy(up.Bool, "bridge", "direct_media", "enabled") diff --git a/database/json.go b/database/json.go new file mode 100644 index 0000000..566a6c4 --- /dev/null +++ b/database/json.go @@ -0,0 +1,20 @@ +package database + +import ( + "go.mau.fi/util/dbutil" +) + +// Backported from mautrix/go-util@e5cb5e96d15cb87ffe6e5970c2f90ee47980e715. + +// JSONPtr is a convenience function for wrapping a pointer to a value in the JSON utility, but removing typed nils +// (i.e. preventing nils from turning into the string "null" in the database). +func JSONPtr[T any](val *T) dbutil.JSON { + return dbutil.JSON{Data: UntypedNil(val)} +} + +func UntypedNil[T any](val *T) any { + if val == nil { + return nil + } + return val +} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql index 46fbb73..d794530 100644 --- a/database/upgrades/00-latest-revision.sql +++ b/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v23 (compatible with v19+): Latest revision +-- v0 -> v24 (compatible with v19+): Latest revision CREATE TABLE guild ( dcid TEXT PRIMARY KEY, @@ -92,7 +92,8 @@ CREATE TABLE "user" ( space_room TEXT, dm_space_room TEXT, - read_state_version INTEGER NOT NULL DEFAULT 0 + read_state_version INTEGER NOT NULL DEFAULT 0, + heartbeat_session jsonb ); CREATE TABLE user_portal ( diff --git a/database/upgrades/24-user-heartbeat-session.sql b/database/upgrades/24-user-heartbeat-session.sql new file mode 100644 index 0000000..ccb44f9 --- /dev/null +++ b/database/upgrades/24-user-heartbeat-session.sql @@ -0,0 +1,2 @@ +-- v24 (compatible with v19+): Add persisted heartbeat sessions +ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb; diff --git a/database/user.go b/database/user.go index 763625d..eff661b 100644 --- a/database/user.go +++ b/database/user.go @@ -3,6 +3,7 @@ package database import ( "database/sql" + "github.com/bwmarrin/discordgo" "go.mau.fi/util/dbutil" log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/id" @@ -21,18 +22,18 @@ func (uq *UserQuery) New() *User { } func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE mxid=$1` return uq.New().Scan(uq.db.QueryRow(query, userID)) } func (uq *UserQuery) GetByID(id string) *User { - query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1` + query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE dcid=$1` return uq.New().Scan(uq.db.QueryRow(query, id)) } func (uq *UserQuery) GetAllWithToken() []*User { query := ` - SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version + SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE discord_token IS NOT NULL ` rows, err := uq.db.Query(query) @@ -54,19 +55,20 @@ type User struct { db *Database log log.Logger - MXID id.UserID - DiscordID string - DiscordToken string - ManagementRoom id.RoomID - SpaceRoom id.RoomID - DMSpaceRoom id.RoomID + MXID id.UserID + DiscordID string + DiscordToken string + ManagementRoom id.RoomID + SpaceRoom id.RoomID + DMSpaceRoom id.RoomID + HeartbeatSession *discordgo.HeartbeatSession ReadStateVersion int } func (u *User) Scan(row dbutil.Scannable) *User { var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString - err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion) + err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession}) if err != nil { if err != sql.ErrNoRows { u.log.Errorln("Database scan failed:", err) @@ -83,8 +85,8 @@ func (u *User) Scan(row dbutil.Scannable) *User { } func (u *User) Insert() { - query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion) + query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession)) if err != nil { u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) panic(err) @@ -92,8 +94,8 @@ func (u *User) Insert() { } func (u *User) Update() { - query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7` - _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID) + query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6, heartbeat_session=$7 WHERE mxid=$8` + _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession), u.MXID) if err != nil { u.log.Warnfln("Failed to update %q: %v", u.MXID, err) panic(err) diff --git a/directmedia.go b/directmedia.go index 4499c1a..6b683aa 100644 --- a/directmedia.go +++ b/directmedia.go @@ -154,6 +154,7 @@ func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI { addRoutes("r0") addRoutes("v1") federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) + federationRouter.HandleFunc("/v1/media/thumbnail/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet) federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet) mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint) mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod) @@ -556,7 +557,7 @@ func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWri func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := zerolog.Ctx(ctx) - isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/") + isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/") vars := mux.Vars(r) if !isNewFederation && vars["serverName"] != dma.cfg.ServerName { jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ diff --git a/example-config.yaml b/example-config.yaml index ea392bb..0c1ab13 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -130,7 +130,7 @@ bridge: message_error_notices: true # Should the bridge use space-restricted join rules instead of invite-only for guild rooms? # This can avoid unnecessary invite events in guild rooms when members are synced in. - restricted_rooms: true + restricted_rooms: false # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). autojoin_thread_on_open: true @@ -171,6 +171,10 @@ bridge: # like the official client does? The other option is sending the media in the message send request as a form part # (which is always used by bots and webhooks). use_discord_cdn_upload: true + # Should the bridge forbid direct messages from users to other users who they aren't friends with? Discord generally + # considers this to be a "risky" action. Note that the bridge will conservatively reject all outgoing DMs from users + # until it has synced that user's relationships from Discord. + forbid_dming_strangers: true # Proxy for Discord connections proxy: # Should mxc uris copied from Discord be cached? diff --git a/go.mod b/go.mod index 3cfd6aa..68d1bc0 100644 --- a/go.mod +++ b/go.mod @@ -1,45 +1,59 @@ module go.mau.fi/mautrix-discord -go 1.24.0 +go 1.25.0 -toolchain go1.25.0 +toolchain go1.26.6 require ( github.com/bwmarrin/discordgo v0.27.0 - github.com/gabriel-vasile/mimetype v1.4.9 + github.com/gabriel-vasile/mimetype v1.4.15 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 - github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.28 - github.com/rs/zerolog v1.34.0 + github.com/imroc/req/v3 v3.60.0 + github.com/lib/pq v1.12.3 + github.com/mattn/go-sqlite3 v1.14.49 + github.com/refraction-networking/utls v1.8.2 + github.com/rs/zerolog v1.35.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/stretchr/testify v1.10.0 - github.com/yuin/goldmark v1.7.12 + github.com/stretchr/testify v1.11.1 + github.com/yuin/goldmark v1.8.5 go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc - golang.org/x/sync v0.16.0 + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 + golang.org/x/sync v0.22.0 maunium.net/go/maulogger/v2 v2.4.1 maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 ) require ( - github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/icholy/digest v1.2.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/kr/text v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.mau.fi/zeroconfig v0.1.2 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471 + +replace github.com/imroc/req/v3 => github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a diff --git a/go.sum b/go.sum index 51356d2..45e5a74 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,67 @@ 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-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471 h1:yeMnzGxjXjRNbUo5cwP5+XGNJUPHX+yFjvD/PbGvTJc= +github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471/go.mod h1:ATQaN5n/cY4rxHFSsp4UOB1cxf4gdtreFt5NXmlVTmc= +github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a h1:LcaRVi2XyUC7LWnpMHFwDlC1jMs5GyLDSerZoB9aH4I= +github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a/go.mod h1:oQlr0iamhVs/GYPzSWYNaFZFgsHzIYj6ibQrdPwwy2A= +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.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= 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/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= 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-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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/icholy/digest v1.2.0 h1:oTbG4IsNOmidJ+421ehG7Ty93yt1yotq13kFMG569yw= +github.com/icholy/digest v1.2.0/go.mod h1:1P1+LzUv48ybX7bu8tVpZ2QWdd+xRuePNuGawHjwRUE= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +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.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.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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/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/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= 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= @@ -44,31 +72,38 @@ 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= +github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik= +github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/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= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 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= diff --git a/http.go b/http.go new file mode 100644 index 0000000..c38d27d --- /dev/null +++ b/http.go @@ -0,0 +1,109 @@ +// 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 main + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "strings" + + "github.com/imroc/req/v3" + utls "github.com/refraction-networking/utls" +) + +func compileTransport(onlyAdvertiseHTTP1InALPN bool, proxy *url.URL) http.RoundTripper { + reqClient := req.C().ImpersonateChrome() + if proxy != nil { + reqClient.SetProxy(http.ProxyURL(proxy)) + } else { + reqClient.SetProxy(nil) + } + if onlyAdvertiseHTTP1InALPN { + forceHTTP1ChromeFingerprint(reqClient) + reqClient.EnableForceHTTP1() + } + return reqClient.Transport +} + +// forceHTTP1ChromeFingerprint overrides the req client's TLS handshake so the +// uTLS ClientHello keeps Chrome's full fingerprint but advertises _only_ +// http/1.1 in ALPN. +func forceHTTP1ChromeFingerprint(c *req.Client) { + // (This is adapted from uTLS's SetTLSFingerprint.) + c.SetTLSHandshake(func(ctx context.Context, addr string, plainConn net.Conn) (net.Conn, *tls.ConnectionState, error) { + hostname := addr + if i := strings.LastIndex(addr, ":"); i != -1 { + hostname = addr[:i] + } + + // NOTE: The ClientHelloID here _must_ match what req's + // ImpersonateChrome uses. + spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120) + if err != nil { + return nil, nil, fmt.Errorf("failed to build Chrome uTLS spec: %w", err) + } + + // The actual changes we're making here: + exts := spec.Extensions[:0] + for _, ext := range spec.Extensions { + switch e := ext.(type) { + // Drop the ALPS (application_settings) extension. Modern Chrome + // will stop offering h2 there when ALPN omits it. Match that + // behavior. + case *utls.ApplicationSettingsExtension: + continue + + // Patch the ALPN extension to exclusively offer http/1.1. + case *utls.ALPNExtension: + e.AlpnProtocols = []string{"http/1.1"} + } + exts = append(exts, ext) + } + spec.Extensions = exts + + tlsConfig := c.GetTLSClientConfig() + uconn := utls.UClient(plainConn, &utls.Config{ + ServerName: hostname, + NextProtos: []string{"http/1.1"}, + RootCAs: tlsConfig.RootCAs, + InsecureSkipVerify: tlsConfig.InsecureSkipVerify, + KeyLogWriter: tlsConfig.KeyLogWriter, + }, utls.HelloCustom) + if err := uconn.ApplyPreset(&spec); err != nil { + return nil, nil, fmt.Errorf("failed to apply Chrome uTLS spec: %w", err) + } + if err := uconn.HandshakeContext(ctx); err != nil { + return nil, nil, err + } + + cs := uconn.ConnectionState() + return uconn, &tls.ConnectionState{ + Version: cs.Version, + HandshakeComplete: cs.HandshakeComplete, + DidResume: cs.DidResume, + CipherSuite: cs.CipherSuite, + NegotiatedProtocol: cs.NegotiatedProtocol, + ServerName: cs.ServerName, + PeerCertificates: cs.PeerCertificates, + VerifiedChains: cs.VerifiedChains, + }, nil + }) +} diff --git a/main.go b/main.go index 5b6f635..2792443 100644 --- a/main.go +++ b/main.go @@ -187,7 +187,7 @@ func main() { Name: "mautrix-discord", URL: "https://github.com/mautrix/discord", Description: "A Matrix-Discord puppeting bridge.", - Version: "0.7.5", + Version: "0.7.7", ProtocolName: "Discord", BeeperServiceName: "discordgo", BeeperNetworkName: "discord", diff --git a/portal.go b/portal.go index 3a5db83..db26a0e 100644 --- a/portal.go +++ b/portal.go @@ -1226,6 +1226,8 @@ var ( 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") ) @@ -1241,6 +1243,10 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev 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): @@ -1522,11 +1528,6 @@ func (portal *Portal) RefererOptIfUser(sess *discordgo.Session, threadID string) } func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return - } - content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok { go portal.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Ignoring") @@ -1540,6 +1541,33 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { 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 { @@ -1559,7 +1587,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { }) } go portal.sendMessageMetrics(evt, err, "Failed to edit") - if msg.EditedTimestamp != nil { + if msg != nil && msg.EditedTimestamp != nil { edits.UpdateEditTimestamp(*msg.EditedTimestamp) } } else { @@ -1647,16 +1675,21 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { if portal.bridge.Config.Bridge.UseDiscordCDNUpload && !isWebhookSend && sess.IsUser { att := &discordgo.MessageAttachment{ - ID: "0", - Filename: filename, - Description: description, + ID: "0", + Filename: filename, + Description: description, + OriginalContentType: content.Info.MimeType, } sendReq.Attachments = []*discordgo.MessageAttachment{att} + isClip := false prep, err := sender.Session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ Files: []*discordgo.FilePrepare{{ Size: len(data), Name: att.Filename, ID: sender.NextDiscordUploadID(), + + IsClip: &isClip, + OriginalContentType: att.OriginalContentType, }}, }, portal.RefererOpt(threadID)) if err != nil { @@ -1918,12 +1951,13 @@ func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { } func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") return - } else if !sender.IsLoggedIn() { - //go portal.sendMessageMetrics(evt, errReactionUserNotLoggedIn, "Ignoring") - return } reaction := evt.Content.AsReaction() @@ -2101,9 +2135,15 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess } func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.DiscordID != portal.Key.Receiver { - go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") - return + if portal.IsPrivateChat() { + if !sender.IsLoggedIn() { + go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring") + return + } + if sender.DiscordID != portal.Key.Receiver { + go portal.sendMessageMetrics(evt, errUserNotReceiver, "Ignoring") + return + } } sess := sender.Session @@ -2523,6 +2563,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord 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 @@ -2530,6 +2571,7 @@ func (portal *Portal) UpdateInfo(source *User, meta *discordgo.Channel) *discord portal.FriendNick = false changed = portal.UpdateNameDirect(puppet.Name, false) || changed } + source.relationshipLock.RUnlock() } if portal.MXID != "" { portal.syncParticipants(source, meta.Recipients) diff --git a/user.go b/user.go index f209b33..8c0d6ec 100644 --- a/user.go +++ b/user.go @@ -68,6 +68,12 @@ type User struct { 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 { @@ -497,6 +503,7 @@ func (user *User) Logout(isOverwriting bool) { } user.Session = nil + user.reconstructRelationships(nil) user.DiscordToken = "" user.ReadStateVersion = 0 if !isOverwriting { @@ -511,6 +518,26 @@ func (user *User) Logout(isOverwriting bool) { 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() @@ -538,6 +565,9 @@ const BotIntents = discordgo.IntentGuilds | 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 == "" { @@ -550,18 +580,37 @@ func (user *User) Connect() error { if err != nil { return err } + + if user.HeartbeatSession == nil || user.HeartbeatSession.IsExpired() { + user.log.Debug().Msg("Creating new heartbeat session") + sess := discordgo.NewHeartbeatSession() + user.HeartbeatSession = &sess + } + user.HeartbeatSession.BumpLastUsed() + user.Update() + // make discordgo use our session instead of the one it creates automatically + session.HeartbeatSession = *user.HeartbeatSession + + var proxyURL *url.URL if user.bridge.Config.Bridge.Proxy != "" { - u, _ := url.Parse(user.bridge.Config.Bridge.Proxy) + proxyURL, _ = 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), + Proxy: http.ProxyURL(proxyURL), TLSClientConfig: tlsConf, ForceAttemptHTTP2: true, } - session.Dialer.Proxy = http.ProxyURL(u) - session.Dialer.TLSClientConfig = tlsConf + session.GatewayHTTPClient.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: tlsConf, + ForceAttemptHTTP2: true, + } + } + if session.IsUser { + session.Client.Transport = compileTransport(false, proxyURL) + session.GatewayHTTPClient.Transport = compileTransport(true, proxyURL) } // TODO move to config if os.Getenv("DISCORD_DEBUG") == "1" { @@ -569,7 +618,10 @@ func (user *User) Connect() error { } else { session.LogLevel = discordgo.LogInformational } - userDiscordLog := user.log.With().Str("component", "discordgo").Logger() + userDiscordLog := user.log.With(). + Str("component", "discordgo"). + Str("heartbeat_session", session.HeartbeatSession.ID.String()). + Logger() session.Logger = func(msgL, caller int, format string, a ...interface{}) { userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf } @@ -686,6 +738,7 @@ func (user *User) Disconnect() error { } user.log.Info().Msg("Disconnecting session manually") + user.reconstructRelationships(nil) if err := user.Session.Close(); err != nil { return err } @@ -744,9 +797,7 @@ func (user *User) readyHandler(r *discordgo.Ready) { user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling}) user.tryAutomaticDoublePuppeting() - for _, relationship := range r.Relationships { - user.relationships[relationship.ID] = relationship - } + user.reconstructRelationships(r.Relationships) updateTS := time.Now() portalsInSpace := make(map[string]bool) @@ -807,6 +858,7 @@ func (user *User) subscribeGuilds(delay time.Duration) { func (user *User) resumeHandler(_ *discordgo.Resumed) { user.log.Debug().Msg("Discord connection resumed") user.subscribeGuilds(0 * time.Second) + user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } func (user *User) addPrivateChannelToSpace(portal *Portal) bool { @@ -828,17 +880,23 @@ func (user *User) addPrivateChannelToSpace(portal *Portal) bool { 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, "") @@ -1007,7 +1065,6 @@ func (user *User) connectedHandler(_ *discordgo.Connect) { user.log.Debug().Msg("Connected to Discord") if user.wasDisconnected { user.wasDisconnected = false - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } }