Compare commits

..

No commits in common. "main" and "v0.22.1" have entirely different histories.

323 changed files with 9187 additions and 20632 deletions

View file

@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
name: Lint (latest)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.26"
go-version: "1.23"
cache: true
- name: Install libolm
@ -24,7 +24,6 @@ jobs:
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit
@ -35,14 +34,14 @@ jobs:
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm)
go-version: ["1.22", "1.23"]
name: Build (${{ matrix.go-version == '1.23' && 'latest' || 'old' }}, libolm)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
@ -61,28 +60,28 @@ jobs:
- name: Test
run: go test -json -v ./... 2>&1 | gotestfmt
- name: Test (jsonv2)
env:
GOEXPERIMENT: jsonv2
run: go test -json -v ./... 2>&1 | gotestfmt
build-goolm:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, goolm)
go-version: ["1.22", "1.23"]
name: Build (${{ matrix.go-version == '1.23' && 'latest' || 'old' }}, goolm)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Set up gotestfmt
uses: GoTestTools/gotestfmt-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build
run: |
rm -rf crypto/libolm

View file

@ -17,7 +17,7 @@ jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@v5
id: lock
with:
issue-inactive-days: 90

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@ -9,7 +9,7 @@ repos:
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4
rev: v1.0.0-rc.1
hooks:
- id: go-imports-repo
args:
@ -18,7 +18,8 @@ repos:
- "-w"
- id: go-vet-repo-mod
- id: go-mod-tidy
- id: go-staticcheck-repo-mod
# TODO enable this
#- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
@ -26,4 +27,3 @@ repos:
- id: prevent-literal-http-methods
- id: zerolog-ban-global-log
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View file

@ -1,420 +1,3 @@
## v0.26.3 (2026-02-16)
* Bumped minimum Go version to 1.25.
* *(client)* Added fields for sending [MSC4354] sticky events.
* *(bridgev2)* Added automatic message request accepting when sending message.
* *(mediaproxy)* Added support for federation thumbnail endpoint.
* *(crypto/ssss)* Improved support for recovery keys with slightly broken
metadata.
* *(crypto)* Changed key import to call session received callback even for
sessions that already exist in the database.
* *(appservice)* Fixed building websocket URL accidentally using file path
separators instead of always `/`.
* *(crypto)* Fixed key exports not including the `sender_claimed_keys` field.
* *(client)* Fixed incorrect context usage in async uploads.
* *(crypto)* Fixed panic when passing invalid input to megolm message index
parser used for debugging.
* *(bridgev2/provisioning)* Fixed completed or failed logins not being cleaned
up properly.
[MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354
## v0.26.2 (2026-01-16)
* *(bridgev2)* Added chunked portal deletion to avoid database locks when
deleting large portals.
* *(crypto,bridgev2)* Added option to encrypt reaction and reply metadata
as per [MSC4392].
* *(bridgev2/login)* Added `default_value` for user input fields.
* *(bridgev2)* Added interfaces to let the Matrix connector provide suggested
HTTP client settings and to reset active connections of the network connector.
* *(bridgev2)* Added interface to let network connectors get the provisioning
API HTTP router and add new endpoints.
* *(event)* Added blurhash field to Beeper link preview objects.
* *(event)* Added [MSC4391] support for bot commands.
* *(event)* Dropped [MSC4332] support for bot commands.
* *(client)* Changed media download methods to return an error if the provided
MXC URI is empty.
* *(client)* Stabilized support for [MSC4323].
* *(bridgev2/matrix)* Fixed `GetEvent` panicking when trying to decrypt events.
* *(bridgev2)* Fixed some deadlocks when room creation happens in parallel with
a portal re-ID call.
[MSC4391]: https://github.com/matrix-org/matrix-spec-proposals/pull/4391
[MSC4392]: https://github.com/matrix-org/matrix-spec-proposals/pull/4392
## v0.26.1 (2025-12-16)
* **Breaking change *(mediaproxy)*** Changed `GetMediaResponseFile` to return
the mime type from the callback rather than in the return get media return
value. The callback can now also redirect the caller to a different file.
* *(federation)* Added join/knock/leave functions
(thanks to [@nexy7574] in [#422]).
* *(federation/eventauth)* Fixed various incorrect checks.
* *(client)* Added backoff for retrying media uploads to external URLs
(with MSC3870).
* *(bridgev2/config)* Added support for overriding config fields using
environment variables.
* *(bridgev2/commands)* Added command to mute chat on remote network.
* *(bridgev2)* Added interface for network connectors to redirect to a different
user ID when handling an invite from Matrix.
* *(bridgev2)* Added interface for signaling message request status of portals.
* *(bridgev2)* Changed portal creation to not backfill unless `CanBackfill` flag
is set in chat info.
* *(bridgev2)* Changed Matrix reaction handling to only delete old reaction if
bridging the new one is successful.
* *(bridgev2/mxmain)* Improved error message when trying to run bridge with
pre-megabridge database when no database migration exists.
* *(bridgev2)* Improved reliability of database migration when enabling split
portals.
* *(bridgev2)* Improved detection of orphaned DM rooms when starting new chats.
* *(bridgev2)* Stopped sending redundant invites when joining ghosts to public
portal rooms.
* *(bridgev2)* Stopped hardcoding room versions in favor of checking
server capabilities to determine appropriate `/createRoom` parameters.
[#422]: https://github.com/mautrix/go/pull/422
## v0.26.0 (2025-11-16)
* *(client,appservice)* Deprecated `SendMassagedStateEvent` as `SendStateEvent`
has been able to do the same for a while now.
* *(client,federation)* Added size limits for responses to make it safer to send
requests to untrusted servers.
* *(client)* Added wrapper for `/admin/whois` client API
(thanks to [@nexy7574] in [#411]).
* *(synapseadmin)* Added `force_purge` option to DeleteRoom
(thanks to [@nexy7574] in [#420]).
* *(statestore)* Added saving join rules for rooms.
* *(bridgev2)* Added optional automatic rollback of room state if bridging the
change to the remote network fails.
* *(bridgev2)* Added management room notices if transient disconnect state
doesn't resolve within 3 minutes.
* *(bridgev2)* Added interface to signal that certain participants couldn't be
invited when creating a group.
* *(bridgev2)* Added `select` type for user input fields in login.
* *(bridgev2)* Added interface to let network connector customize personal
filtering space.
* *(bridgev2/matrix)* Added checks to avoid sending error messages in reply to
other bots.
* *(bridgev2/matrix)* Switched to using [MSC4169] to send redactions whenever
possible.
* *(bridgev2/publicmedia)* Added support for custom path prefixes, file names,
and encrypted files.
* *(bridgev2/commands)* Added command to resync a single portal.
* *(bridgev2/commands)* Added create group command.
* *(bridgev2/config)* Added option to limit maximum number of logins.
* *(bridgev2)* Changed ghost joining to skip unnecessary invite if portal room
is public.
* *(bridgev2/disappear)* Changed read receipt handling to only start
disappearing timers for messages up to the read message (note: may not work in
all cases if the read receipt points at an unknown event).
* *(event/reply)* Changed plaintext reply fallback removal to only happen when
an HTML reply fallback is removed successfully.
* *(bridgev2/matrix)* Fixed unnecessary sleep after registering bot on first run.
* *(crypto/goolm)* Fixed panic when processing certain malformed Olm messages.
* *(federation)* Fixed HTTP method for sending transactions
(thanks to [@nexy7574] in [#426]).
* *(federation)* Fixed response body being closed even when using `DontReadBody`
parameter.
* *(federation)* Fixed validating auth for requests with query params.
* *(federation/eventauth)* Fixed typo causing restricted joins to not work.
[MSC4169]: https://github.com/matrix-org/matrix-spec-proposals/pull/4169
[#411]: github.com/mautrix/go/pull/411
[#420]: github.com/mautrix/go/pull/420
[#426]: github.com/mautrix/go/pull/426
## v0.25.2 (2025-10-16)
* **Breaking change *(id)*** Split `UserID.ParseAndValidate` into
`ParseAndValidateRelaxed` and `ParseAndValidateStrict`. Strict is the old
behavior, but most users likely want the relaxed version, as there are real
users whose user IDs aren't valid under the strict rules.
* *(crypto)* Added helper methods for generating and verifying with recovery
keys.
* *(bridgev2/matrix)* Added config option to automatically generate a recovery
key for the bridge bot and self-sign the bridge's device.
* *(bridgev2/matrix)* Added initial support for using appservice/MSC3202 mode
for encryption with standard servers like Synapse.
* *(bridgev2)* Added optional support for implicit read receipts.
* *(bridgev2)* Added interface for deleting chats on remote network.
* *(bridgev2)* Added local enforcement of media duration and size limits.
* *(bridgev2)* Extended event duration logging to log any event taking too long.
* *(bridgev2)* Improved validation in group creation provisioning API.
* *(event)* Added event type constant for poll end events.
* *(client)* Added wrapper for searching user directory.
* *(client)* Improved support for managing [MSC4140] delayed events.
* *(crypto/helper)* Changed default sync handling to not block on waiting for
decryption keys. On initial sync, keys won't be requested at all by default.
* *(crypto)* Fixed olm unwedging not working (regressed in v0.25.1).
* *(bridgev2)* Fixed various bugs with migrating to split portals.
* *(event)* Fixed poll start events having incorrect null `m.relates_to`.
* *(client)* Fixed `RespUserProfile` losing standard fields when re-marshaling.
* *(federation)* Fixed various bugs in event auth.
## v0.25.1 (2025-09-16)
* *(client)* Fixed HTTP method of delete devices API call
(thanks to [@fmseals] in [#393]).
* *(client)* Added wrappers for [MSC4323]: User suspension & locking endpoints
(thanks to [@nexy7574] in [#407]).
* *(client)* Stabilized support for extensible profiles.
* *(client)* Stabilized support for `state_after` in sync.
* *(client)* Removed deprecated MSC2716 requests.
* *(crypto)* Added fallback to ensure `m.relates_to` is always copied even if
the content struct doesn't implement `Relatable`.
* *(crypto)* Changed olm unwedging to ignore newly created sessions if they
haven't been used successfully in either direction.
* *(federation)* Added utilities for generating, parsing, validating and
authorizing PDUs.
* Note: the new PDU code depends on `GOEXPERIMENT=jsonv2`
* *(event)* Added `is_animated` flag from [MSC4230] to file info.
* *(event)* Added types for [MSC4332]: In-room bot commands.
* *(event)* Added missing poll end event type for [MSC3381].
* *(appservice)* Fixed URLs not being escaped properly when using unix socket
for homeserver connections.
* *(format)* Added more helpers for forming markdown links.
* *(event,bridgev2)* Added support for Beeper's disappearing message state event.
* *(bridgev2)* Redesigned group creation interface and added support in commands
and provisioning API.
* *(bridgev2)* Added GetEvent to Matrix interface to allow network connectors to
get an old event. The method is best effort only, as some configurations don't
allow fetching old events.
* *(bridgev2)* Added shared logic for provisioning that can be reused by the
API, commands and other sources.
* *(bridgev2)* Fixed mentions and URL previews not being copied over when
caption and media are merged.
* *(bridgev2)* Removed config option to change provisioning API prefix, which
had already broken in the previous release.
[@fmseals]: https://github.com/fmseals
[#393]: https://github.com/mautrix/go/pull/393
[#407]: https://github.com/mautrix/go/pull/407
[MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381
[MSC4230]: https://github.com/matrix-org/matrix-spec-proposals/pull/4230
[MSC4323]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
[MSC4332]: https://github.com/matrix-org/matrix-spec-proposals/pull/4332
## v0.25.0 (2025-08-16)
* Bumped minimum Go version to 1.24.
* **Breaking change *(appservice,bridgev2,federation)*** Replaced gorilla/mux
with standard library ServeMux.
* *(client,bridgev2)* Added support for creator power in room v12.
* *(client)* Added option to not set `User-Agent` header for improved Wasm
compatibility.
* *(bridgev2)* Added support for following tombstones.
* *(bridgev2)* Added interface for getting arbitrary state event from Matrix.
* *(bridgev2)* Added batching to disappearing message queue to ensure it doesn't
use too many resources even if there are a large number of messages.
* *(bridgev2/commands)* Added support for canceling QR login with `cancel`
command.
* *(client)* Added option to override HTTP client used for .well-known
resolution.
* *(crypto/backup)* Added method for encrypting key backup session without
private keys.
* *(event->id)* Moved room version type and constants to id package.
* *(bridgev2)* Bots in DM portals will now be added to the functional members
state event to hide them from the room name calculation.
* *(bridgev2)* Changed message delete handling to ignore "delete for me" events
if there are multiple Matrix users in the room.
* *(format/htmlparser)* Changed text processing to collapse multiple spaces into
one when outside `pre`/`code` tags.
* *(format/htmlparser)* Removed link suffix in plaintext output when link text
is only missing protocol part of href.
* e.g. `<a href="https://example.com">example.com</a>` will turn into
`example.com` rather than `example.com (https://example.com)`
* *(appservice)* Switched appservice websockets from gorilla/websocket to
coder/websocket.
* *(bridgev2/matrix)* Fixed encryption key sharing not ignoring ghosts properly.
* *(crypto/attachments)* Fixed hash check when decrypting file streams.
* *(crypto)* Removed unnecessary `AlreadyShared` error in `ShareGroupSession`.
The function will now act as if it was successful instead.
## v0.24.2 (2025-07-16)
* *(bridgev2)* Added support for return values from portal event handlers. Note
that the return value will always be "queued" unless the event buffer is
disabled.
* *(bridgev2)* Added support for [MSC4144] per-message profile passthrough in
relay mode.
* *(bridgev2)* Added option to auto-reconnect logins after a certain period if
they hit an `UNKNOWN_ERROR` state.
* *(bridgev2)* Added analytics for event handler panics.
* *(bridgev2)* Changed new room creation to hardcode room v11 to avoid v12 rooms
being created before proper support for them can be added.
* *(bridgev2)* Changed queuing events to block instead of dropping events if the
buffer is full.
* *(bridgev2)* Fixed assumption that replies to unknown messages are cross-room.
* *(id)* Fixed server name validation not including ports correctly
(thanks to [@krombel] in [#392]).
* *(federation)* Fixed base64 algorithm in signature generation.
* *(event)* Fixed [MSC4144] fallbacks not being removed from edits.
[@krombel]: https://github.com/krombel
[#392]: https://github.com/mautrix/go/pull/392
## v0.24.1 (2025-06-16)
* *(commands)* Added framework for using reactions as buttons that execute
command handlers.
* *(client)* Added wrapper for `/relations` endpoints.
* *(client)* Added support for stable version of room summary endpoint.
* *(client)* Fixed parsing URL preview responses where width/height are strings.
* *(federation)* Fixed bugs in server auth.
* *(id)* Added utilities for validating server names.
* *(event)* Fixed incorrect empty `entity` field when sending hashed moderation
policy events.
* *(event)* Added [MSC4293] redact events field to member events.
* *(event)* Added support for fallbacks in [MSC4144] per-message profiles.
* *(format)* Added `MarkdownLink` and `MarkdownMention` utility functions for
generating properly escaped markdown.
* *(synapseadmin)* Added support for synchronous (v1) room delete endpoint.
* *(synapseadmin)* Changed `Client` struct to not embed the `mautrix.Client`.
This is a breaking change if you were relying on accessing non-admin functions
from the admin client.
* *(bridgev2/provisioning)* Fixed `/display_and_wait` not passing through errors
from the network connector properly.
* *(bridgev2/crypto)* Fixed encryption not working if the user's ID had the same
prefix as the bridge ghosts (e.g. `@whatsappbridgeuser:example.com` with a
`@whatsapp_` prefix).
* *(bridgev2)* Fixed portals not being saved after creating a DM portal from a
Matrix DM invite.
* *(bridgev2)* Added config option to determine whether cross-room replies
should be bridged.
* *(appservice)* Fixed `EnsureRegistered` not being called when sending a custom
member event for the controlled user.
[MSC4293]: https://github.com/matrix-org/matrix-spec-proposals/pull/4293
## v0.24.0 (2025-05-16)
* *(commands)* Added generic framework for implementing bot commands.
* *(client)* Added support for specifying maximum number of HTTP retries using
a context value instead of having to call `MakeFullRequest` manually.
* *(client,federation)* Added methods for fetching room directories.
* *(federation)* Added support for server side of request authentication.
* *(synapseadmin)* Added wrapper for the account suspension endpoint.
* *(format)* Added method for safely wrapping a string in markdown inline code.
* *(crypto)* Added method to import key backup without persisting to database,
to allow the client more control over the process.
* *(bridgev2)* Added viewing chat interface to signal when the user is viewing
a given chat.
* *(bridgev2)* Added option to pass through transaction ID from client when
sending messages to remote network.
* *(crypto)* Fixed unnecessary error log when decrypting dummy events used for
unwedging Olm sessions.
* *(crypto)* Fixed `forwarding_curve25519_key_chain` not being set consistently
when backing up keys.
* *(event)* Fixed marshaling legacy VoIP events with no version field.
* *(bridgev2)* Fixed disappearing message references not being deleted when the
portal is deleted.
* *(bridgev2)* Fixed read receipt bridging not ignoring fake message entries
and causing unnecessary error logs.
## v0.23.3 (2025-04-16)
* *(commands)* Added generic command processing framework for bots.
* *(client)* Added `allowed_room_ids` field to room summary responses
(thanks to [@nexy7574] in [#367]).
* *(bridgev2)* Added support for custom timeouts on outgoing messages which have
to wait for a remote echo.
* *(bridgev2)* Added automatic typing stop event if the ghost user had sent a
typing event before a message.
* *(bridgev2)* The saved management room is now cleared if the user leaves the
room, allowing the next DM to be automatically marked as a management room.
* *(bridge)* Removed deprecated fallback package for bridge statuses.
The status package is now only available under bridgev2.
[#367]: https://github.com/mautrix/go/pull/367
## v0.23.2 (2025-03-16)
* **Breaking change *(bridge)*** Removed legacy bridge module.
* **Breaking change *(event)*** Changed `m.federate` field in room create event
content to a pointer to allow detecting omitted values.
* *(bridgev2/commands)* Added `set-management-room` command to set a new
management room.
* *(bridgev2/portal)* Changed edit bridging to ignore remote edits if the
original sender on Matrix can't be puppeted.
* *(bridgv2)* Added config option to disable bridging `m.notice` messages.
* *(appservice/http)* Switched access token validation to use constant time
comparisons.
* *(event)* Added support for [MSC3765] rich text topics.
* *(event)* Added fields to policy list event contents for [MSC4204] and
[MSC4205].
* *(client)* Added method for getting the content of a redacted event using
[MSC2815].
* *(client)* Added methods for sending and updating [MSC4140] delayed events.
* *(client)* Added support for [MSC4222] in sync payloads.
* *(crypto/cryptohelper)* Switched to using `sqlite3-fk-wal` instead of plain
`sqlite3` by default.
* *(crypto/encryptolm)* Added generic method for encrypting to-device events.
* *(crypto/ssss)* Fixed panic if server-side key metadata is corrupted.
* *(crypto/sqlstore)* Fixed error when marking over 32 thousand device lists
as outdated on SQLite.
[MSC2815]: https://github.com/matrix-org/matrix-spec-proposals/pull/2815
[MSC3765]: https://github.com/matrix-org/matrix-spec-proposals/pull/3765
[MSC4140]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
[MSC4204]: https://github.com/matrix-org/matrix-spec-proposals/pull/4204
[MSC4205]: https://github.com/matrix-org/matrix-spec-proposals/pull/4205
[MSC4222]: https://github.com/matrix-org/matrix-spec-proposals/pull/4222
## v0.23.1 (2025-02-16)
* *(client)* Added `FullStateEvent` method to get a state event including
metadata (using the `?format=event` query parameter).
* *(client)* Added wrapper method for [MSC4194]'s redact endpoint.
* *(pushrules)* Fixed content rules not considering word boundaries and being
case-sensitive.
* *(crypto)* Fixed bugs that would cause key exports to fail for no reason.
* *(crypto)* Deprecated `ResolveTrust` in favor of `ResolveTrustContext`.
* *(crypto)* Stopped accepting secret shares from unverified devices.
* **Breaking change *(crypto)*** Changed `GetAndVerifyLatestKeyBackupVersion`
to take an optional private key parameter. The method will now trust the
public key if it matches the provided private key even if there are no valid
signatures.
* **Breaking change *(crypto)*** Added context parameter to `IsDeviceTrusted`.
[MSC4194]: https://github.com/matrix-org/matrix-spec-proposals/pull/4194
## v0.23.0 (2025-01-16)
* **Breaking change *(client)*** Changed `JoinRoom` parameters to allow multiple
`via`s.
* **Breaking change *(bridgev2)*** Updated capability system.
* The return type of `NetworkAPI.GetCapabilities` is now different.
* Media type capabilities are enforced automatically by bridgev2.
* Capabilities are now sent to Matrix rooms using the
`com.beeper.room_features` state event.
* *(client)* Added `GetRoomSummary` to implement [MSC3266].
* *(client)* Added support for arbitrary profile fields to implement [MSC4133]
(thanks to [@nexy7574] in [#337]).
* *(crypto)* Started storing olm message hashes to prevent decryption errors
if messages are repeated (e.g. if the app crashes right after decrypting).
* *(crypto)* Improved olm session unwedging to check when the last session was
created instead of only relying on an in-memory map.
* *(crypto/verificationhelper)* Fixed emoji verification not doing cross-signing
properly after a successful verification.
* *(bridgev2/config)* Moved MSC4190 flag from `appservice` to `encryption`.
* *(bridgev2/space)* Fixed failing to add rooms to spaces if the room create
call was made with a temporary context.
* *(bridgev2/commands)* Changed `help` command to hide commands which require
interfaces that aren't implemented by the network connector.
* *(bridgev2/matrixinterface)* Moved deterministic room ID generation to Matrix
connector.
* *(bridgev2)* Fixed service member state event not being set correctly when
creating a DM by inviting a ghost user.
* *(bridgev2)* Fixed `RemoteReactionSync` events replacing all reactions every
time instead of only changed ones.
[MSC3266]: https://github.com/matrix-org/matrix-spec-proposals/pull/3266
[MSC4133]: https://github.com/matrix-org/matrix-spec-proposals/pull/4133
[@nexy7574]: https://github.com/nexy7574
[#337]: https://github.com/mautrix/go/pull/337
## v0.22.1 (2024-12-16)
* *(crypto)* Added automatic cleanup when there are too many olm sessions with
@ -437,7 +20,6 @@
[MSC4156]: https://github.com/matrix-org/matrix-spec-proposals/pull/4156
[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
[#288]: https://github.com/mautrix/go/pull/288
[@onestacked]: https://github.com/onestacked
## v0.22.0 (2024-11-16)

View file

@ -1,9 +1,8 @@
# mautrix-go
[![GoDoc](https://pkg.go.dev/badge/maunium.net/go/mautrix)](https://pkg.go.dev/maunium.net/go/mautrix)
A Golang Matrix framework. Used by [gomuks](https://gomuks.app),
[go-neb](https://github.com/matrix-org/go-neb),
[mautrix-whatsapp](https://github.com/mautrix/whatsapp)
A Golang Matrix framework. Used by [gomuks](https://matrix.org/docs/projects/client/gomuks),
[go-neb](https://github.com/matrix-org/go-neb), [mautrix-whatsapp](https://github.com/mautrix/whatsapp)
and others.
Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net)
@ -14,10 +13,9 @@ The original project is licensed under [Apache 2.0](https://github.com/matrix-or
In addition to the basic client API features the original project has, this framework also has:
* Appservice support (Intent API like mautrix-python, room state storage, etc)
* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc)
* End-to-end encryption support (incl. interactive SAS verification)
* High-level module for building puppeting bridges
* Partial federation module (making requests, PDU processing and event authorization)
* A media proxy server which can be used to expose anything as a Matrix media repo
* High-level module for building chat clients
* Wrapper functions for the Synapse admin API
* Structs for parsing event content
* Helpers for parsing and generating Matrix HTML

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -19,7 +19,8 @@ import (
"syscall"
"time"
"github.com/coder/websocket"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"golang.org/x/net/publicsuffix"
"gopkg.in/yaml.v3"
@ -42,7 +43,7 @@ func Create() *AppService {
intents: make(map[id.UserID]*IntentAPI),
HTTPClient: &http.Client{Timeout: 180 * time.Second, Jar: jar},
StateStore: mautrix.NewMemoryStateStore().(StateStore),
Router: http.NewServeMux(),
Router: mux.NewRouter(),
UserAgent: mautrix.DefaultUserAgent,
txnIDC: NewTransactionIDCache(128),
Live: true,
@ -60,12 +61,12 @@ func Create() *AppService {
DefaultHTTPRetries: 4,
}
as.Router.HandleFunc("PUT /_matrix/app/v1/transactions/{txnID}", as.PutTransaction)
as.Router.HandleFunc("GET /_matrix/app/v1/rooms/{roomAlias}", as.GetRoom)
as.Router.HandleFunc("GET /_matrix/app/v1/users/{userID}", as.GetUser)
as.Router.HandleFunc("POST /_matrix/app/v1/ping", as.PostPing)
as.Router.HandleFunc("GET /_matrix/mau/live", as.GetLive)
as.Router.HandleFunc("GET /_matrix/mau/ready", as.GetReady)
as.Router.HandleFunc("/_matrix/app/v1/transactions/{txnID}", as.PutTransaction).Methods(http.MethodPut)
as.Router.HandleFunc("/_matrix/app/v1/rooms/{roomAlias}", as.GetRoom).Methods(http.MethodGet)
as.Router.HandleFunc("/_matrix/app/v1/users/{userID}", as.GetUser).Methods(http.MethodGet)
as.Router.HandleFunc("/_matrix/app/v1/ping", as.PostPing).Methods(http.MethodPost)
as.Router.HandleFunc("/_matrix/mau/live", as.GetLive).Methods(http.MethodGet)
as.Router.HandleFunc("/_matrix/mau/ready", as.GetReady).Methods(http.MethodGet)
return as
}
@ -113,13 +114,13 @@ var _ StateStore = (*mautrix.MemoryStateStore)(nil)
// QueryHandler handles room alias and user ID queries from the homeserver.
type QueryHandler interface {
QueryAlias(alias id.RoomAlias) bool
QueryAlias(alias string) bool
QueryUser(userID id.UserID) bool
}
type QueryHandlerStub struct{}
func (qh *QueryHandlerStub) QueryAlias(alias id.RoomAlias) bool {
func (qh *QueryHandlerStub) QueryAlias(alias string) bool {
return false
}
@ -127,7 +128,7 @@ func (qh *QueryHandlerStub) QueryUser(userID id.UserID) bool {
return false
}
type WebsocketHandler func(WebsocketCommand) (ok bool, data any)
type WebsocketHandler func(WebsocketCommand) (ok bool, data interface{})
type StateStore interface {
mautrix.StateStore
@ -159,7 +160,7 @@ type AppService struct {
QueryHandler QueryHandler
StateStore StateStore
Router *http.ServeMux
Router *mux.Router
UserAgent string
server *http.Server
HTTPClient *http.Client
@ -178,6 +179,7 @@ type AppService struct {
intentsLock sync.RWMutex
ws *websocket.Conn
wsWriteLock sync.Mutex
StopWebsocket func(error)
websocketHandlers map[string]WebsocketHandler
websocketHandlersLock sync.RWMutex
@ -334,7 +336,7 @@ func (as *AppService) SetHomeserverURL(homeserverURL string) error {
} else if as.hsURLForClient.Scheme == "" {
as.hsURLForClient.Scheme = "https"
}
as.hsURLForClient.RawPath = as.hsURLForClient.EscapedPath()
as.hsURLForClient.RawPath = parsedURL.EscapedPath()
jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
as.HTTPClient = &http.Client{Timeout: 180 * time.Second, Jar: jar}
@ -360,7 +362,7 @@ func (as *AppService) NewMautrixClient(userID id.UserID) *mautrix.Client {
AccessToken: as.Registration.AppToken,
UserAgent: as.UserAgent,
StateStore: as.StateStore,
Log: as.Log.With().Stringer("as_user_id", userID).Logger(),
Log: as.Log.With().Str("as_user_id", userID.String()).Logger(),
Client: as.HTTPClient,
DefaultHTTPRetries: as.DefaultHTTPRetries,
SpecVersions: as.SpecVersions,

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -17,9 +17,8 @@ import (
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/exstrings"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@ -79,9 +78,17 @@ func (as *AppService) Stop() {
func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) (isValid bool) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
mautrix.MMissingToken.WithMessage("Missing access token").Write(w)
} else if !exstrings.ConstantTimeEqual(authHeader[len("Bearer "):], as.Registration.ServerToken) {
mautrix.MUnknownToken.WithMessage("Invalid access token").Write(w)
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Missing access token",
}.Write(w)
} else if authHeader[len("Bearer "):] != as.Registration.ServerToken {
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Incorrect access token",
}.Write(w)
} else {
isValid = true
}
@ -94,15 +101,24 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
return
}
txnID := r.PathValue("txnID")
vars := mux.Vars(r)
txnID := vars["txnID"]
if len(txnID) == 0 {
mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w)
Error{
ErrorCode: ErrNoTransactionID,
HTTPStatus: http.StatusBadRequest,
Message: "Missing transaction ID",
}.Write(w)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil || len(body) == 0 {
mautrix.MNotJSON.WithMessage("Failed to read response body").Write(w)
Error{
ErrorCode: ErrNotJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Missing request body",
}.Write(w)
return
}
log := as.Log.With().Str("transaction_id", txnID).Logger()
@ -111,7 +127,7 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
ctx = log.WithContext(ctx)
if as.txnIDC.IsProcessed(txnID) {
// Duplicate transaction ID: no-op
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
WriteBlankOK(w)
log.Debug().Msg("Ignoring duplicate transaction")
return
}
@ -120,10 +136,14 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
err = json.Unmarshal(body, &txn)
if err != nil {
log.Error().Err(err).Msg("Failed to parse transaction content")
mautrix.MBadJSON.WithMessage("Failed to parse transaction content").Write(w)
Error{
ErrorCode: ErrBadJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Failed to parse body JSON",
}.Write(w)
} else {
as.handleTransaction(ctx, txnID, &txn)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
WriteBlankOK(w)
}
}
@ -201,7 +221,7 @@ func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, def
}
err := evt.Content.ParseRaw(evt.Type)
if errors.Is(err, event.ErrUnsupportedContentType) {
log.Debug().Stringer("event_id", evt.ID).Msg("Not parsing content of unsupported event")
log.Debug().Str("event_id", evt.ID.String()).Msg("Not parsing content of unsupported event")
} else if err != nil {
log.Warn().Err(err).
Str("event_id", evt.ID.String()).
@ -238,12 +258,16 @@ func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) {
return
}
roomAlias := id.RoomAlias(r.PathValue("roomAlias"))
vars := mux.Vars(r)
roomAlias := vars["roomAlias"]
ok := as.QueryHandler.QueryAlias(roomAlias)
if ok {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
WriteBlankOK(w)
} else {
mautrix.MNotFound.WithMessage("Alias not found").Write(w)
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
}
}
@ -253,12 +277,16 @@ func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) {
return
}
userID := id.UserID(r.PathValue("userID"))
vars := mux.Vars(r)
userID := id.UserID(vars["userID"])
ok := as.QueryHandler.QueryUser(userID)
if ok {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
WriteBlankOK(w)
} else {
mautrix.MNotFound.WithMessage("User not found").Write(w)
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
}
}
@ -268,7 +296,11 @@ func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) {
}
body, err := io.ReadAll(r.Body)
if err != nil || len(body) == 0 || !json.Valid(body) {
mautrix.MNotJSON.WithMessage("Invalid or missing request body").Write(w)
Error{
ErrorCode: ErrNotJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Missing request body",
}.Write(w)
return
}
@ -276,21 +308,27 @@ func (as *AppService) PostPing(w http.ResponseWriter, r *http.Request) {
_ = json.Unmarshal(body, &txn)
as.Log.Debug().Str("txn_id", txn.TxnID).Msg("Received ping from homeserver")
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
}
func (as *AppService) GetLive(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
if as.Live {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
w.WriteHeader(http.StatusOK)
} else {
exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
}
w.Write([]byte("{}"))
}
func (as *AppService) GetReady(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
if as.Ready {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
w.WriteHeader(http.StatusOK)
} else {
exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
}
w.Write([]byte("{}"))
}

View file

@ -51,7 +51,7 @@ func (as *AppService) NewIntentAPI(localpart string) *IntentAPI {
}
func (intent *IntentAPI) Register(ctx context.Context) error {
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister{
Username: intent.Localpart,
Type: mautrix.AuthTypeAppservice,
InhibitLogin: true,
@ -86,7 +86,6 @@ func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error {
type EnsureJoinedParams struct {
IgnoreCache bool
BotOverride *mautrix.Client
Via []string
}
func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error {
@ -100,17 +99,11 @@ func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, ext
return nil
}
err := intent.EnsureRegistered(ctx)
if err != nil {
if err := intent.EnsureRegistered(ctx); err != nil {
return fmt.Errorf("failed to ensure joined: %w", err)
}
var resp *mautrix.RespJoinRoom
if len(params.Via) > 0 {
resp, err = intent.JoinRoom(ctx, roomID.String(), &mautrix.ReqJoinRoom{Via: params.Via})
} else {
resp, err = intent.JoinRoomByID(ctx, roomID)
}
resp, err := intent.JoinRoomByID(ctx, roomID)
if err != nil {
bot := intent.bot
if params.BotOverride != nil {
@ -149,16 +142,12 @@ func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, ext
return nil
}
func (intent *IntentAPI) IsDoublePuppet() bool {
return intent.IsCustomPuppet && intent.as.DoublePuppetValue != ""
}
func (intent *IntentAPI) AddDoublePuppetValue(into any) any {
return intent.AddDoublePuppetValueWithTS(into, 0)
}
func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any {
if !intent.IsDoublePuppet() {
if !intent.IsCustomPuppet || intent.as.DoublePuppetValue == "" {
return into
}
// Only use ts deduplication feature with appservice double puppeting
@ -214,45 +203,38 @@ func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any {
}
}
func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) {
func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON)
}
func (intent *IntentAPI) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
if !intent.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) {
return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support")
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.BeeperSendEphemeralEvent(ctx, roomID, eventType, contentJSON, extra...)
}
// Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead
func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
}
func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) {
func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
if eventType != event.StateMember || stateKey != string(intent.UserID) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
} else if err := intent.EnsureRegistered(ctx); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON)
}
// Deprecated: use SendStateEvent with mautrix.ReqSendEvent.Timestamp instead
func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts)
return intent.Client.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ts)
}
func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error {
@ -311,7 +293,7 @@ func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID i
func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipJoin, "", extraContent...)
return &mautrix.RespJoinRoom{RoomID: roomID}, err
return &mautrix.RespJoinRoom{}, err
}
return intent.Client.JoinRoomByID(ctx, roomID)
}
@ -380,24 +362,6 @@ func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id
return member
}
func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error {
if pl.CreateEvent != nil {
return nil
}
var err error
pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to get create event from cache: %w", err)
} else if pl.CreateEvent != nil {
return nil
}
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
if err != nil {
return fmt.Errorf("failed to get create event from server: %w", err)
}
return nil
}
func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) {
pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID)
if err != nil {
@ -407,12 +371,6 @@ func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl
if pl == nil {
pl = &event.PowerLevelsEventContent{}
err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl)
if err != nil {
return
}
}
if pl.CreateEvent == nil {
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
}
return
}
@ -427,7 +385,8 @@ func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, us
return nil, err
}
if pl.EnsureUserLevelAs(intent.UserID, userID, level) {
if pl.GetUserLevel(userID) != level {
pl.SetUserLevel(userID, level)
return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl)
}
return nil, nil
@ -516,7 +475,7 @@ func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentU
// No need to update
return nil
}
if !avatarURL.IsEmpty() && !intent.SpecVersions.Supports(mautrix.BeeperFeatureHungry) {
if !avatarURL.IsEmpty() {
// Some homeservers require the avatar to be downloaded before setting it
resp, _ := intent.Download(ctx, avatarURL)
if resp != nil {

View file

@ -1,68 +0,0 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package appservice
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
)
func (intent *IntentAPI) EnsureAppserviceConnection(ctx context.Context) {
var pingResp *mautrix.RespAppservicePing
var txnID string
var retryCount int
var err error
const maxRetries = 6
for {
txnID = intent.TxnID()
pingResp, err = intent.AppservicePing(ctx, intent.as.Registration.ID, txnID)
if err == nil {
break
}
var httpErr mautrix.HTTPError
var pingErrBody string
if errors.As(err, &httpErr) && httpErr.RespError != nil {
if val, ok := httpErr.RespError.ExtraData["body"].(string); ok {
pingErrBody = strings.TrimSpace(val)
}
}
outOfRetries := retryCount >= maxRetries
level := zerolog.ErrorLevel
if outOfRetries {
level = zerolog.FatalLevel
}
evt := zerolog.Ctx(ctx).WithLevel(level).Err(err).Str("txn_id", txnID)
if pingErrBody != "" {
bodyBytes := []byte(pingErrBody)
if json.Valid(bodyBytes) {
evt.RawJSON("body", bodyBytes)
} else {
evt.Str("body", pingErrBody)
}
}
if outOfRetries {
evt.Msg("Homeserver -> appservice connection is not working")
zerolog.Ctx(ctx).Info().Msg("See https://docs.mau.fi/faq/as-ping for more info")
os.Exit(13)
}
evt.Msg("Homeserver -> appservice connection is not working, retrying in 5 seconds...")
time.Sleep(5 * time.Second)
retryCount++
}
zerolog.Ctx(ctx).Debug().
Str("txn_id", txnID).
Int64("duration_ms", pingResp.DurationMS).
Msg("Homeserver -> appservice connection works")
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -7,7 +7,9 @@
package appservice
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/rs/zerolog"
@ -101,3 +103,50 @@ func (txn *Transaction) ContentString() string {
// EventListener is a function that receives events.
type EventListener func(evt *event.Event)
// WriteBlankOK writes a blank OK message as a reply to a HTTP request.
func WriteBlankOK(w http.ResponseWriter) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{}"))
}
// Respond responds to a HTTP request with a JSON object.
func Respond(w http.ResponseWriter, data interface{}) error {
w.Header().Add("Content-Type", "application/json")
dataStr, err := json.Marshal(data)
if err != nil {
return err
}
_, err = w.Write(dataStr)
return err
}
// Error represents a Matrix protocol error.
type Error struct {
HTTPStatus int `json:"-"`
ErrorCode ErrorCode `json:"errcode"`
Message string `json:"error"`
}
func (err Error) Write(w http.ResponseWriter) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(err.HTTPStatus)
_ = Respond(w, &err)
}
// ErrorCode is the machine-readable code in an Error.
type ErrorCode string
// Native ErrorCodes
const (
ErrUnknownToken ErrorCode = "M_UNKNOWN_TOKEN"
ErrBadJSON ErrorCode = "M_BAD_JSON"
ErrNotJSON ErrorCode = "M_NOT_JSON"
ErrUnknown ErrorCode = "M_UNKNOWN"
)
// Custom ErrorCodes
const (
ErrNoTransactionID ErrorCode = "NET.MAUNIUM.NO_TRANSACTION_ID"
)

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -11,26 +11,26 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"maunium.net/go/mautrix"
)
type WebsocketRequest struct {
ReqID int `json:"id,omitempty"`
Command string `json:"command"`
Data any `json:"data"`
ReqID int `json:"id,omitempty"`
Command string `json:"command"`
Data interface{} `json:"data"`
Deadline time.Duration `json:"-"`
}
type WebsocketCommand struct {
@ -41,7 +41,7 @@ type WebsocketCommand struct {
Ctx context.Context `json:"-"`
}
func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest {
func (wsc *WebsocketCommand) MakeResponse(ok bool, data interface{}) *WebsocketRequest {
if wsc.ReqID == 0 || wsc.Command == "response" || wsc.Command == "error" {
return nil
}
@ -56,7 +56,7 @@ func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest {
var prefixMessage string
for unwrappedErr != nil {
errorData, jsonErr = json.Marshal(unwrappedErr)
if len(errorData) > 2 && jsonErr == nil {
if errorData != nil && len(errorData) > 2 && jsonErr == nil {
prefixMessage = strings.Replace(err.Error(), unwrappedErr.Error(), "", 1)
prefixMessage = strings.TrimRight(prefixMessage, ": ")
break
@ -98,8 +98,8 @@ type WebsocketMessage struct {
}
const (
WebsocketCloseConnReplaced websocket.StatusCode = 4001
WebsocketCloseTxnNotAcknowledged websocket.StatusCode = 4002
WebsocketCloseConnReplaced = 4001
WebsocketCloseTxnNotAcknowledged = 4002
)
type MeowWebsocketCloseCode string
@ -133,7 +133,7 @@ func (mwcc MeowWebsocketCloseCode) String() string {
}
type CloseCommand struct {
Code websocket.StatusCode `json:"-"`
Code int `json:"-"`
Command string `json:"command"`
Status MeowWebsocketCloseCode `json:"status"`
}
@ -143,15 +143,15 @@ func (cc CloseCommand) Error() string {
}
func parseCloseError(err error) error {
var closeError websocket.CloseError
closeError := &websocket.CloseError{}
if !errors.As(err, &closeError) {
return err
}
var closeCommand CloseCommand
closeCommand.Code = closeError.Code
closeCommand.Command = "disconnect"
if len(closeError.Reason) > 0 {
jsonErr := json.Unmarshal([]byte(closeError.Reason), &closeCommand)
if len(closeError.Text) > 0 {
jsonErr := json.Unmarshal([]byte(closeError.Text), &closeCommand)
if jsonErr != nil {
return err
}
@ -159,7 +159,7 @@ func parseCloseError(err error) error {
if len(closeCommand.Status) == 0 {
if closeCommand.Code == WebsocketCloseConnReplaced {
closeCommand.Status = MeowConnectionReplaced
} else if closeCommand.Code == websocket.StatusServiceRestart {
} else if closeCommand.Code == websocket.CloseServiceRestart {
closeCommand.Status = MeowServerShuttingDown
}
}
@ -170,23 +170,20 @@ func (as *AppService) HasWebsocket() bool {
return as.ws != nil
}
func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) error {
func (as *AppService) SendWebsocket(cmd *WebsocketRequest) error {
ws := as.ws
if cmd == nil {
return nil
} else if ws == nil {
return ErrWebsocketNotConnected
}
wr, err := ws.Writer(ctx, websocket.MessageText)
if err != nil {
return err
as.wsWriteLock.Lock()
defer as.wsWriteLock.Unlock()
if cmd.Deadline == 0 {
cmd.Deadline = 3 * time.Minute
}
err = json.NewEncoder(wr).Encode(cmd)
if err != nil {
_ = wr.Close()
return err
}
return wr.Close()
_ = ws.SetWriteDeadline(time.Now().Add(cmd.Deadline))
return ws.WriteJSON(cmd)
}
func (as *AppService) clearWebsocketResponseWaiters() {
@ -223,12 +220,12 @@ func (er *ErrorResponse) Error() string {
return fmt.Sprintf("%s: %s", er.Code, er.Message)
}
func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response any) error {
func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response interface{}) error {
cmd.ReqID = int(atomic.AddInt32(&as.websocketRequestID, 1))
respChan := make(chan *WebsocketCommand, 1)
as.addWebsocketResponseWaiter(cmd.ReqID, respChan)
defer as.removeWebsocketResponseWaiter(cmd.ReqID, respChan)
err := as.SendWebsocket(ctx, cmd)
err := as.SendWebsocket(cmd)
if err != nil {
return err
}
@ -257,7 +254,7 @@ func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketReques
}
}
func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, any) {
func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, interface{}) {
zerolog.Ctx(cmd.Ctx).Warn().Msg("No handler for websocket command")
return false, fmt.Errorf("unknown request type")
}
@ -281,28 +278,14 @@ func (as *AppService) defaultHandleWebsocketTransaction(ctx context.Context, msg
return true, &WebsocketTransactionResponse{TxnID: msg.TxnID}
}
func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error), ws *websocket.Conn) {
func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) {
defer stopFunc(ErrWebsocketUnknownError)
ctx := context.Background()
for {
msgType, reader, err := ws.Reader(ctx)
if err != nil {
as.Log.Debug().Err(err).Msg("Error getting reader from websocket")
stopFunc(parseCloseError(err))
return
} else if msgType != websocket.MessageText {
as.Log.Debug().Msg("Ignoring non-text message from websocket")
continue
}
data, err := io.ReadAll(reader)
if err != nil {
as.Log.Debug().Err(err).Msg("Error reading data from websocket")
stopFunc(parseCloseError(err))
return
}
var msg WebsocketMessage
err = json.Unmarshal(data, &msg)
err := ws.ReadJSON(&msg)
if err != nil {
as.Log.Debug().Err(err).Msg("Error parsing JSON received from websocket")
as.Log.Debug().Err(err).Msg("Error reading from websocket")
stopFunc(parseCloseError(err))
return
}
@ -313,11 +296,11 @@ func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error)
with = with.Str("transaction_id", msg.TxnID)
}
log := with.Logger()
ctx := log.WithContext(ctx)
ctx = log.WithContext(ctx)
if msg.Command == "" || msg.Command == "transaction" {
ok, resp := as.WebsocketTransactionHandler(ctx, msg)
go func() {
err := as.SendWebsocket(ctx, msg.MakeResponse(ok, resp))
err := as.SendWebsocket(msg.MakeResponse(ok, resp))
if err != nil {
log.Warn().Err(err).Msg("Failed to send response to websocket transaction")
} else {
@ -349,7 +332,7 @@ func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error)
}
go func() {
okResp, data := handler(msg.WebsocketCommand)
err := as.SendWebsocket(ctx, msg.MakeResponse(okResp, data))
err := as.SendWebsocket(msg.MakeResponse(okResp, data))
if err != nil {
log.Error().Err(err).Msg("Failed to send response to websocket command")
} else if okResp {
@ -362,7 +345,7 @@ func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error)
}
}
func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConnect func()) error {
func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error {
var parsed *url.URL
if baseURL != "" {
var err error
@ -374,29 +357,26 @@ func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConn
copiedURL := *as.hsURLForClient
parsed = &copiedURL
}
parsed.Path = path.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync")
parsed.Path = filepath.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync")
if parsed.Scheme == "http" {
parsed.Scheme = "ws"
} else if parsed.Scheme == "https" {
parsed.Scheme = "wss"
}
ws, resp, err := websocket.Dial(ctx, parsed.String(), &websocket.DialOptions{
HTTPClient: as.HTTPClient,
HTTPHeader: http.Header{
"Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)},
"User-Agent": []string{as.BotClient().UserAgent},
ws, resp, err := websocket.DefaultDialer.Dial(parsed.String(), http.Header{
"Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)},
"User-Agent": []string{as.BotClient().UserAgent},
"X-Mautrix-Process-ID": []string{as.ProcessID},
"X-Mautrix-Websocket-Version": []string{"3"},
},
"X-Mautrix-Process-ID": []string{as.ProcessID},
"X-Mautrix-Websocket-Version": []string{"3"},
})
if resp != nil && resp.StatusCode >= 400 {
var errResp mautrix.RespError
var errResp Error
err = json.NewDecoder(resp.Body).Decode(&errResp)
if err != nil {
return fmt.Errorf("websocket request returned HTTP %d with non-JSON body", resp.StatusCode)
} else {
return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrCode, resp.StatusCode, errResp.Err)
return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrorCode, resp.StatusCode, errResp.Message)
}
} else if err != nil {
return fmt.Errorf("failed to open websocket: %w", err)
@ -419,13 +399,12 @@ func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConn
}
})
}
ws.SetReadLimit(50 * 1024 * 1024)
as.ws = ws
as.StopWebsocket = stopFunc
as.PrepareWebsocket()
as.Log.Debug().Msg("Appservice transaction websocket opened")
go as.consumeWebsocket(ctx, stopFunc, ws)
go as.consumeWebsocket(stopFunc, ws)
var onConnectDone atomic.Bool
if onConnect != nil {
@ -447,7 +426,12 @@ func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConn
as.ws = nil
}
err = ws.Close(websocket.StatusGoingAway, "")
_ = ws.SetWriteDeadline(time.Now().Add(3 * time.Second))
err = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, ""))
if err != nil && !errors.Is(err, websocket.ErrCloseSent) {
as.Log.Warn().Err(err).Msg("Error writing close message to websocket")
}
err = ws.Close()
if err != nil {
as.Log.Warn().Err(err).Msg("Error closing websocket")
}

936
bridge/bridge.go Normal file
View file

@ -0,0 +1,936 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridge
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/lib/pq"
"github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil"
_ "go.mau.fi/util/dbutil/litestream"
"go.mau.fi/util/exzerolog"
"gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"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/sqlstatestore"
)
var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String()
var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool()
var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String()
var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool()
var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool()
var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool()
var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool()
var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool()
var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool()
var wantHelp, _ = flag.MakeHelpFlag()
var _ appservice.StateStore = (*sqlstatestore.SQLStateStore)(nil)
type Portal interface {
IsEncrypted() bool
IsPrivateChat() bool
MarkEncrypted()
MainIntent() *appservice.IntentAPI
ReceiveMatrixEvent(user User, evt *event.Event)
UpdateBridgeInfo(ctx context.Context)
}
type MembershipHandlingPortal interface {
Portal
HandleMatrixLeave(sender User, evt *event.Event)
HandleMatrixKick(sender User, ghost Ghost, evt *event.Event)
HandleMatrixInvite(sender User, ghost Ghost, evt *event.Event)
}
type ReadReceiptHandlingPortal interface {
Portal
HandleMatrixReadReceipt(sender User, eventID id.EventID, receipt event.ReadReceipt)
}
type TypingPortal interface {
Portal
HandleMatrixTyping(userIDs []id.UserID)
}
type MetaHandlingPortal interface {
Portal
HandleMatrixMeta(sender User, evt *event.Event)
}
type DisappearingPortal interface {
Portal
ScheduleDisappearing()
}
type PowerLevelHandlingPortal interface {
Portal
HandleMatrixPowerLevels(sender User, evt *event.Event)
}
type JoinRuleHandlingPortal interface {
Portal
HandleMatrixJoinRule(sender User, evt *event.Event)
}
type BanHandlingPortal interface {
Portal
HandleMatrixBan(sender User, ghost Ghost, evt *event.Event)
HandleMatrixUnban(sender User, ghost Ghost, evt *event.Event)
}
type KnockHandlingPortal interface {
Portal
HandleMatrixKnock(sender User, evt *event.Event)
HandleMatrixRetractKnock(sender User, evt *event.Event)
HandleMatrixAcceptKnock(sender User, ghost Ghost, evt *event.Event)
HandleMatrixRejectKnock(sender User, ghost Ghost, evt *event.Event)
}
type InviteHandlingPortal interface {
Portal
HandleMatrixAcceptInvite(sender User, evt *event.Event)
HandleMatrixRejectInvite(sender User, evt *event.Event)
HandleMatrixRetractInvite(sender User, ghost Ghost, evt *event.Event)
}
type User interface {
GetPermissionLevel() bridgeconfig.PermissionLevel
IsLoggedIn() bool
GetManagementRoomID() id.RoomID
SetManagementRoom(id.RoomID)
GetMXID() id.UserID
GetIDoublePuppet() DoublePuppet
GetIGhost() Ghost
}
type DoublePuppet interface {
CustomIntent() *appservice.IntentAPI
SwitchCustomMXID(accessToken string, userID id.UserID) error
ClearCustomMXID()
}
type Ghost interface {
DoublePuppet
DefaultIntent() *appservice.IntentAPI
GetMXID() id.UserID
}
type GhostWithProfile interface {
Ghost
GetDisplayname() string
GetAvatarURL() id.ContentURI
}
type ChildOverride interface {
GetExampleConfig() string
GetConfigPtr() interface{}
Init()
Start()
Stop()
GetIPortal(id.RoomID) Portal
GetAllIPortals() []Portal
GetIUser(id id.UserID, create bool) User
IsGhost(id.UserID) bool
GetIGhost(id.UserID) Ghost
CreatePrivatePortal(id.RoomID, User, Ghost)
}
type ConfigValidatingBridge interface {
ChildOverride
ValidateConfig() error
}
type FlagHandlingBridge interface {
ChildOverride
HandleFlags() bool
}
type PreInitableBridge interface {
ChildOverride
PreInit()
}
type WebsocketStartingBridge interface {
ChildOverride
OnWebsocketConnect()
}
type CSFeatureRequirer interface {
CheckFeatures(versions *mautrix.RespVersions) (string, bool)
}
type Bridge struct {
Name string
URL string
Description string
Version string
ProtocolName string
BeeperServiceName string
BeeperNetworkName string
AdditionalShortFlags string
AdditionalLongFlags string
VersionDesc string
LinkifiedVersion string
BuildTime string
commit string
baseVersion string
PublicHSAddress *url.URL
DoublePuppet *doublePuppetUtil
AS *appservice.AppService
EventProcessor *appservice.EventProcessor
CommandProcessor CommandProcessor
MatrixHandler *MatrixHandler
Bot *appservice.IntentAPI
Config bridgeconfig.BaseConfig
ConfigPath string
RegistrationPath string
SaveConfig bool
ConfigUpgrader configupgrade.BaseUpgrader
DB *dbutil.Database
StateStore *sqlstatestore.SQLStateStore
Crypto Crypto
CryptoPickleKey string
ZLog *zerolog.Logger
MediaConfig mautrix.RespMediaConfig
SpecVersions mautrix.RespVersions
Child ChildOverride
manualStop chan int
Stopping bool
latestState *status.BridgeState
Websocket bool
wsStopPinger chan struct{}
wsStarted chan struct{}
wsStopped chan struct{}
wsShortCircuitReconnectBackoff chan struct{}
wsStartupWait *sync.WaitGroup
}
type Crypto interface {
HandleMemberEvent(context.Context, *event.Event)
Decrypt(context.Context, *event.Event) (*event.Event, error)
Encrypt(context.Context, id.RoomID, event.Type, *event.Content) error
WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
ResetSession(context.Context, id.RoomID)
Init(ctx context.Context) error
Start()
Stop()
Reset(ctx context.Context, startAfterReset bool)
Client() *mautrix.Client
ShareKeys(context.Context) error
}
func (br *Bridge) GenerateRegistration() {
if !br.SaveConfig {
// We need to save the generated as_token and hs_token in the config
_, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration")
os.Exit(5)
} else if br.Config.Homeserver.Domain == "example.com" {
_, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set")
os.Exit(20)
}
reg := br.Config.GenerateRegistration()
err := reg.Save(br.RegistrationPath)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err)
os.Exit(21)
}
updateTokens := func(helper configupgrade.Helper) {
helper.Set(configupgrade.Str, reg.AppToken, "appservice", "as_token")
helper.Set(configupgrade.Str, reg.ServerToken, "appservice", "hs_token")
}
_, _, err = configupgrade.Do(br.ConfigPath, true, br.ConfigUpgrader, configupgrade.SimpleUpgrader(updateTokens))
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err)
os.Exit(22)
}
fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.")
os.Exit(0)
}
func (br *Bridge) InitVersion(tag, commit, buildTime string) {
br.baseVersion = br.Version
if len(tag) > 0 && tag[0] == 'v' {
tag = tag[1:]
}
if tag != br.Version {
suffix := ""
if !strings.HasSuffix(br.Version, "+dev") {
suffix = "+dev"
}
if len(commit) > 8 {
br.Version = fmt.Sprintf("%s%s.%s", br.Version, suffix, commit[:8])
} else {
br.Version = fmt.Sprintf("%s%s.unknown", br.Version, suffix)
}
}
br.LinkifiedVersion = fmt.Sprintf("v%s", br.Version)
if tag == br.Version {
br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, tag)
} else if len(commit) > 8 {
br.LinkifiedVersion = strings.Replace(br.LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], br.URL, commit), 1)
}
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.Version, mautrix.DefaultUserAgent)
br.VersionDesc = fmt.Sprintf("%s %s (%s with %s)", br.Name, br.Version, buildTime, runtime.Version())
br.commit = commit
br.BuildTime = buildTime
}
var MinSpecVersion = mautrix.SpecV14
func (br *Bridge) logInitialRequestError(err error, defaultMessage string) {
if errors.Is(err, mautrix.MUnknownToken) {
br.ZLog.WithLevel(zerolog.FatalLevel).Msg("The as_token was not accepted. Is the registration file installed in your homeserver correctly?")
br.ZLog.Info().Msg("See https://docs.mau.fi/faq/as-token for more info")
} else if errors.Is(err, mautrix.MExclusive) {
br.ZLog.WithLevel(zerolog.FatalLevel).Msg("The as_token was accepted, but the /register request was not. Are the homeserver domain, bot username and username template in the config correct, and do they match the values in the registration?")
br.ZLog.Info().Msg("See https://docs.mau.fi/faq/as-register for more info")
} else {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg(defaultMessage)
}
}
func (br *Bridge) ensureConnection(ctx context.Context) {
for {
versions, err := br.Bot.Versions(ctx)
if err != nil {
if errors.Is(err, mautrix.MForbidden) {
br.ZLog.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying")
err = br.Bot.EnsureRegistered(ctx)
if err != nil {
br.logInitialRequestError(err, "Failed to register after /versions failed with M_FORBIDDEN")
os.Exit(16)
}
} else {
br.ZLog.Err(err).Msg("Failed to connect to homeserver, retrying in 10 seconds...")
time.Sleep(10 * time.Second)
}
} else {
br.SpecVersions = *versions
*br.AS.SpecVersions = *versions
break
}
}
unsupportedServerLogLevel := zerolog.FatalLevel
if *ignoreUnsupportedServer {
unsupportedServerLogLevel = zerolog.ErrorLevel
}
if br.Config.Homeserver.Software == bridgeconfig.SoftwareHungry && !br.SpecVersions.Supports(mautrix.BeeperFeatureHungry) {
br.ZLog.WithLevel(zerolog.FatalLevel).Msg("The config claims the homeserver is hungryserv, but the /versions response didn't confirm it")
os.Exit(18)
} else if !br.SpecVersions.ContainsGreaterOrEqual(MinSpecVersion) {
br.ZLog.WithLevel(unsupportedServerLogLevel).
Stringer("server_supports", br.SpecVersions.GetLatest()).
Stringer("bridge_requires", MinSpecVersion).
Msg("The homeserver is outdated (supported spec versions are below minimum required by bridge)")
if !*ignoreUnsupportedServer {
os.Exit(18)
}
} else if fr, ok := br.Child.(CSFeatureRequirer); ok {
if msg, hasFeatures := fr.CheckFeatures(&br.SpecVersions); !hasFeatures {
br.ZLog.WithLevel(unsupportedServerLogLevel).Msg(msg)
if !*ignoreUnsupportedServer {
os.Exit(18)
}
}
}
resp, err := br.Bot.Whoami(ctx)
if err != nil {
br.logInitialRequestError(err, "/whoami request failed with unknown error")
os.Exit(16)
} else if resp.UserID != br.Bot.UserID {
br.ZLog.WithLevel(zerolog.FatalLevel).
Stringer("got_user_id", resp.UserID).
Stringer("expected_user_id", br.Bot.UserID).
Msg("Unexpected user ID in whoami call")
os.Exit(17)
}
if br.Websocket {
br.ZLog.Debug().Msg("Websocket mode: no need to check status of homeserver -> bridge connection")
return
} else if !br.SpecVersions.Supports(mautrix.FeatureAppservicePing) {
br.ZLog.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection")
return
}
var pingResp *mautrix.RespAppservicePing
var txnID string
var retryCount int
const maxRetries = 6
for {
txnID = br.Bot.TxnID()
pingResp, err = br.Bot.AppservicePing(ctx, br.Config.AppService.ID, txnID)
if err == nil {
break
}
var httpErr mautrix.HTTPError
var pingErrBody string
if errors.As(err, &httpErr) && httpErr.RespError != nil {
if val, ok := httpErr.RespError.ExtraData["body"].(string); ok {
pingErrBody = strings.TrimSpace(val)
}
}
outOfRetries := retryCount >= maxRetries
level := zerolog.ErrorLevel
if outOfRetries {
level = zerolog.FatalLevel
}
evt := br.ZLog.WithLevel(level).Err(err).Str("txn_id", txnID)
if pingErrBody != "" {
bodyBytes := []byte(pingErrBody)
if json.Valid(bodyBytes) {
evt.RawJSON("body", bodyBytes)
} else {
evt.Str("body", pingErrBody)
}
}
if outOfRetries {
evt.Msg("Homeserver -> bridge connection is not working")
br.ZLog.Info().Msg("See https://docs.mau.fi/faq/as-ping for more info")
os.Exit(13)
}
evt.Msg("Homeserver -> bridge connection is not working, retrying in 5 seconds...")
time.Sleep(5 * time.Second)
retryCount++
}
br.ZLog.Debug().
Str("txn_id", txnID).
Int64("duration_ms", pingResp.DurationMS).
Msg("Homeserver -> bridge connection works")
}
func (br *Bridge) fetchMediaConfig(ctx context.Context) {
cfg, err := br.Bot.GetMediaConfig(ctx)
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to fetch media config")
} else {
if cfg.UploadSize == 0 {
cfg.UploadSize = 50 * 1024 * 1024
}
br.MediaConfig = *cfg
}
}
func (br *Bridge) UpdateBotProfile(ctx context.Context) {
br.ZLog.Debug().Msg("Updating bot profile")
botConfig := &br.Config.AppService.Bot
var err error
var mxc id.ContentURI
if botConfig.Avatar == "remove" {
err = br.Bot.SetAvatarURL(ctx, mxc)
} else if !botConfig.ParsedAvatar.IsEmpty() {
err = br.Bot.SetAvatarURL(ctx, botConfig.ParsedAvatar)
}
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to update bot avatar")
}
if botConfig.Displayname == "remove" {
err = br.Bot.SetDisplayName(ctx, "")
} else if len(botConfig.Displayname) > 0 {
err = br.Bot.SetDisplayName(ctx, botConfig.Displayname)
}
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to update bot displayname")
}
if br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) && br.BeeperNetworkName != "" {
br.ZLog.Debug().Msg("Setting contact info on the appservice bot")
br.Bot.BeeperUpdateProfile(ctx, map[string]any{
"com.beeper.bridge.service": br.BeeperServiceName,
"com.beeper.bridge.network": br.BeeperNetworkName,
"com.beeper.bridge.is_bridge_bot": true,
})
}
}
func (br *Bridge) loadConfig() {
configData, upgraded, err := configupgrade.Do(br.ConfigPath, br.SaveConfig, br.ConfigUpgrader)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Error updating config:", err)
if configData == nil {
os.Exit(10)
}
}
target := br.Child.GetConfigPtr()
if !upgraded {
// Fallback: if config upgrading failed, load example config for base values
err = yaml.Unmarshal([]byte(br.Child.GetExampleConfig()), &target)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to unmarshal example config:", err)
os.Exit(10)
}
}
err = yaml.Unmarshal(configData, target)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err)
os.Exit(10)
}
}
func (br *Bridge) validateConfig() error {
switch {
case br.Config.Homeserver.Address == "https://matrix.example.com":
return errors.New("homeserver.address not configured")
case br.Config.Homeserver.Domain == "example.com":
return errors.New("homeserver.domain not configured")
case !bridgeconfig.AllowedHomeserverSoftware[br.Config.Homeserver.Software]:
return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)")
case br.Config.AppService.ASToken == "This value is generated when generating the registration":
return errors.New("appservice.as_token not configured. Did you forget to generate the registration? ")
case br.Config.AppService.HSToken == "This value is generated when generating the registration":
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration? ")
case br.Config.AppService.Database.URI == "postgres://user:password@host/database?sslmode=disable":
return errors.New("appservice.database not configured")
default:
err := br.Config.Bridge.Validate()
if err != nil {
return err
}
validator, ok := br.Child.(ConfigValidatingBridge)
if ok {
return validator.ValidateConfig()
}
return nil
}
}
func (br *Bridge) getProfile(userID id.UserID, roomID id.RoomID) *event.MemberEventContent {
ghost := br.Child.GetIGhost(userID)
if ghost == nil {
return nil
}
profilefulGhost, ok := ghost.(GhostWithProfile)
if ok {
return &event.MemberEventContent{
Displayname: profilefulGhost.GetDisplayname(),
AvatarURL: profilefulGhost.GetAvatarURL().CUString(),
}
}
return nil
}
func (br *Bridge) init() {
pib, ok := br.Child.(PreInitableBridge)
if ok {
pib.PreInit()
}
var err error
br.MediaConfig.UploadSize = 50 * 1024 * 1024
br.ZLog, err = br.Config.Logging.Compile()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err)
os.Exit(12)
}
exzerolog.SetupDefaults(br.ZLog)
br.DoublePuppet = &doublePuppetUtil{br: br, log: br.ZLog.With().Str("component", "double puppet").Logger()}
err = br.validateConfig()
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
br.ZLog.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info")
os.Exit(11)
}
br.ZLog.Info().
Str("name", br.Name).
Str("version", br.Version).
Str("built_at", br.BuildTime).
Str("go_version", runtime.Version()).
Msg("Initializing bridge")
br.ZLog.Debug().Msg("Initializing database connection")
dbConfig := br.Config.AppService.Database
if (dbConfig.Type == "sqlite3-fk-wal" || dbConfig.Type == "litestream") && dbConfig.MaxOpenConns != 1 && !strings.Contains(dbConfig.URI, "_txlock=immediate") {
var fixedExampleURI string
if !strings.HasPrefix(dbConfig.URI, "file:") {
fixedExampleURI = fmt.Sprintf("file:%s?_txlock=immediate", dbConfig.URI)
} else if !strings.ContainsRune(dbConfig.URI, '?') {
fixedExampleURI = fmt.Sprintf("%s?_txlock=immediate", dbConfig.URI)
} else {
fixedExampleURI = fmt.Sprintf("%s&_txlock=immediate", dbConfig.URI)
}
br.ZLog.Warn().
Str("fixed_uri_example", fixedExampleURI).
Msg("Using SQLite without _txlock=immediate is not recommended")
}
br.DB, err = dbutil.NewFromConfig(br.Name, dbConfig, dbutil.ZeroLogger(br.ZLog.With().Str("db_section", "main").Logger()))
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection")
if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt {
os.Exit(18)
}
os.Exit(14)
}
br.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase
br.DB.IgnoreForeignTables = *ignoreForeignTables
br.ZLog.Debug().Msg("Initializing state store")
br.StateStore = sqlstatestore.NewSQLStateStore(br.DB, dbutil.ZeroLogger(br.ZLog.With().Str("db_section", "matrix_state").Logger()), true)
br.AS, err = appservice.CreateFull(appservice.CreateOpts{
Registration: br.Config.AppService.GetRegistration(),
HomeserverDomain: br.Config.Homeserver.Domain,
HomeserverURL: br.Config.Homeserver.Address,
HostConfig: appservice.HostConfig{
Hostname: br.Config.AppService.Hostname,
Port: br.Config.AppService.Port,
},
StateStore: br.StateStore,
})
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).
Msg("Failed to initialize appservice")
os.Exit(15)
}
br.AS.Log = *br.ZLog
br.AS.DoublePuppetValue = br.Name
br.AS.GetProfile = br.getProfile
br.Bot = br.AS.BotIntent()
br.ZLog.Debug().Msg("Initializing Matrix event processor")
br.EventProcessor = appservice.NewEventProcessor(br.AS)
if !br.Config.AppService.AsyncTransactions {
br.EventProcessor.ExecMode = appservice.Sync
}
br.ZLog.Debug().Msg("Initializing Matrix event handler")
br.MatrixHandler = NewMatrixHandler(br)
br.Crypto = NewCryptoHelper(br)
hsURL := br.Config.Homeserver.Address
if br.Config.Homeserver.PublicAddress != "" {
hsURL = br.Config.Homeserver.PublicAddress
}
br.PublicHSAddress, err = url.Parse(hsURL)
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).
Str("input", hsURL).
Msg("Failed to parse public homeserver URL")
os.Exit(15)
}
br.Child.Init()
}
type zerologPQError pq.Error
func (zpe *zerologPQError) MarshalZerologObject(evt *zerolog.Event) {
maybeStr := func(field, value string) {
if value != "" {
evt.Str(field, value)
}
}
maybeStr("severity", zpe.Severity)
if name := zpe.Code.Name(); name != "" {
evt.Str("code", name)
} else if zpe.Code != "" {
evt.Str("code", string(zpe.Code))
}
//maybeStr("message", zpe.Message)
maybeStr("detail", zpe.Detail)
maybeStr("hint", zpe.Hint)
maybeStr("position", zpe.Position)
maybeStr("internal_position", zpe.InternalPosition)
maybeStr("internal_query", zpe.InternalQuery)
maybeStr("where", zpe.Where)
maybeStr("schema", zpe.Schema)
maybeStr("table", zpe.Table)
maybeStr("column", zpe.Column)
maybeStr("data_type_name", zpe.DataTypeName)
maybeStr("constraint", zpe.Constraint)
maybeStr("file", zpe.File)
maybeStr("line", zpe.Line)
maybeStr("routine", zpe.Routine)
}
func (br *Bridge) LogDBUpgradeErrorAndExit(name string, err error) {
logEvt := br.ZLog.WithLevel(zerolog.FatalLevel).
Err(err).
Str("db_section", name)
var errWithLine *dbutil.PQErrorWithLine
if errors.As(err, &errWithLine) {
logEvt.Str("sql_line", errWithLine.Line)
}
var pqe *pq.Error
if errors.As(err, &pqe) {
logEvt.Object("pq_error", (*zerologPQError)(pqe))
}
logEvt.Msg("Failed to initialize database")
if sqlError := (&sqlite3.Error{}); errors.As(err, sqlError) && sqlError.Code == sqlite3.ErrCorrupt {
os.Exit(18)
} else if errors.Is(err, dbutil.ErrForeignTables) {
br.ZLog.Info().Msg("See https://docs.mau.fi/faq/foreign-tables for more info")
} else if errors.Is(err, dbutil.ErrNotOwned) {
br.ZLog.Info().Msg("Sharing the same database with different programs is not supported")
} else if errors.Is(err, dbutil.ErrUnsupportedDatabaseVersion) {
br.ZLog.Info().Msg("Downgrading the bridge is not supported")
}
os.Exit(15)
}
func (br *Bridge) WaitWebsocketConnected() {
if br.wsStartupWait != nil {
br.wsStartupWait.Wait()
}
}
func (br *Bridge) start() {
br.ZLog.Debug().Msg("Running database upgrades")
err := br.DB.Upgrade(br.ZLog.With().Str("db_section", "main").Logger().WithContext(context.TODO()))
if err != nil {
br.LogDBUpgradeErrorAndExit("main", err)
} else if err = br.StateStore.Upgrade(br.ZLog.With().Str("db_section", "matrix_state").Logger().WithContext(context.TODO())); err != nil {
br.LogDBUpgradeErrorAndExit("matrix_state", err)
}
if br.Config.Homeserver.Websocket || len(br.Config.Homeserver.WSProxy) > 0 {
br.Websocket = true
br.ZLog.Debug().Msg("Starting application service websocket")
var wg sync.WaitGroup
wg.Add(1)
br.wsStartupWait = &wg
br.wsShortCircuitReconnectBackoff = make(chan struct{})
go br.startWebsocket(&wg)
} else if br.AS.Host.IsConfigured() {
br.ZLog.Debug().Msg("Starting application service HTTP server")
go br.AS.Start()
} else {
br.ZLog.WithLevel(zerolog.FatalLevel).Msg("Neither appservice HTTP listener nor websocket is enabled")
os.Exit(23)
}
br.ZLog.Debug().Msg("Checking connection to homeserver")
ctx := br.ZLog.WithContext(context.Background())
br.ensureConnection(ctx)
go br.fetchMediaConfig(ctx)
if br.Crypto != nil {
err = br.Crypto.Init(ctx)
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Error initializing end-to-bridge encryption")
os.Exit(19)
}
}
br.ZLog.Debug().Msg("Starting event processor")
br.EventProcessor.Start(ctx)
go br.UpdateBotProfile(ctx)
if br.Crypto != nil {
go br.Crypto.Start()
}
br.Child.Start()
br.WaitWebsocketConnected()
br.AS.Ready = true
if br.Config.Bridge.GetResendBridgeInfo() {
go br.ResendBridgeInfo()
}
if br.Websocket && br.Config.Homeserver.WSPingInterval > 0 {
br.wsStopPinger = make(chan struct{}, 1)
go br.websocketServerPinger()
}
}
func (br *Bridge) ResendBridgeInfo() {
if !br.SaveConfig {
br.ZLog.Warn().Msg("Not setting resend_bridge_info to false in config due to --no-update flag")
} else {
_, _, err := configupgrade.Do(br.ConfigPath, true, br.ConfigUpgrader, configupgrade.SimpleUpgrader(func(helper configupgrade.Helper) {
helper.Set(configupgrade.Bool, "false", "bridge", "resend_bridge_info")
}))
if err != nil {
br.ZLog.Err(err).Msg("Failed to save config after setting resend_bridge_info to false")
}
}
br.ZLog.Info().Msg("Re-sending bridge info state event to all portals")
for _, portal := range br.Child.GetAllIPortals() {
portal.UpdateBridgeInfo(context.TODO())
}
br.ZLog.Info().Msg("Finished re-sending bridge info state events")
}
func sendStopSignal(ch chan struct{}) {
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
}
func (br *Bridge) stop() {
br.Stopping = true
if br.Crypto != nil {
br.Crypto.Stop()
}
waitForWS := false
if br.AS.StopWebsocket != nil {
br.ZLog.Debug().Msg("Stopping application service websocket")
br.AS.StopWebsocket(appservice.ErrWebsocketManualStop)
waitForWS = true
}
br.AS.Stop()
sendStopSignal(br.wsStopPinger)
sendStopSignal(br.wsShortCircuitReconnectBackoff)
br.EventProcessor.Stop()
br.Child.Stop()
err := br.DB.Close()
if err != nil {
br.ZLog.Warn().Err(err).Msg("Error closing database")
}
if waitForWS {
select {
case <-br.wsStopped:
case <-time.After(4 * time.Second):
br.ZLog.Warn().Msg("Timed out waiting for websocket to close")
}
}
}
func (br *Bridge) ManualStop(exitCode int) {
if br.manualStop != nil {
br.manualStop <- exitCode
} else {
os.Exit(exitCode)
}
}
type VersionJSONOutput struct {
Name string
URL string
Version string
IsRelease bool
Commit string
FormattedVersion string
BuildTime string
OS string
Arch string
Mautrix struct {
Version string
Commit string
}
}
func (br *Bridge) Main() {
flag.SetHelpTitles(
fmt.Sprintf("%s - %s", br.Name, br.Description),
fmt.Sprintf("%s [-hgvn%s] [-c <path>] [-r <path>]%s", br.Name, br.AdditionalShortFlags, br.AdditionalLongFlags))
err := flag.Parse()
br.ConfigPath = *configPath
br.RegistrationPath = *registrationPath
br.SaveConfig = !*dontSaveConfig
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
} else if *wantHelp {
flag.PrintHelp()
os.Exit(0)
} else if *version {
fmt.Println(br.VersionDesc)
return
} else if *versionJSON {
output := VersionJSONOutput{
URL: br.URL,
Name: br.Name,
Version: br.baseVersion,
IsRelease: br.Version == br.baseVersion,
Commit: br.commit,
FormattedVersion: br.Version,
BuildTime: br.BuildTime,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
output.Mautrix.Commit = mautrix.Commit
output.Mautrix.Version = mautrix.Version
_ = json.NewEncoder(os.Stdout).Encode(output)
return
} else if flagHandler, ok := br.Child.(FlagHandlingBridge); ok && flagHandler.HandleFlags() {
return
}
br.loadConfig()
if *generateRegistration {
br.GenerateRegistration()
return
}
br.manualStop = make(chan int, 1)
br.init()
br.ZLog.Info().Msg("Bridge initialization complete, starting...")
br.start()
br.ZLog.Info().Msg("Bridge started!")
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
var exitCode int
select {
case <-c:
br.ZLog.Info().Msg("Interrupt received, stopping...")
case exitCode = <-br.manualStop:
br.ZLog.Info().Int("exit_code", exitCode).Msg("Manual stop requested")
}
br.stop()
br.ZLog.Info().Msg("Bridge stopped.")
os.Exit(exitCode)
}

View file

@ -0,0 +1,337 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridgeconfig
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/rs/zerolog"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/random"
"go.mau.fi/zeroconfig"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
type HomeserverSoftware string
const (
SoftwareStandard HomeserverSoftware = "standard"
SoftwareAsmux HomeserverSoftware = "asmux"
SoftwareHungry HomeserverSoftware = "hungry"
)
var AllowedHomeserverSoftware = map[HomeserverSoftware]bool{
SoftwareStandard: true,
SoftwareAsmux: true,
SoftwareHungry: true,
}
type HomeserverConfig struct {
Address string `yaml:"address"`
Domain string `yaml:"domain"`
AsyncMedia bool `yaml:"async_media"`
PublicAddress string `yaml:"public_address,omitempty"`
Software HomeserverSoftware `yaml:"software"`
StatusEndpoint string `yaml:"status_endpoint"`
MessageSendCheckpointEndpoint string `yaml:"message_send_checkpoint_endpoint"`
Websocket bool `yaml:"websocket"`
WSProxy string `yaml:"websocket_proxy"`
WSPingInterval int `yaml:"ping_interval_seconds"`
}
type AppserviceConfig struct {
Address string `yaml:"address"`
Hostname string `yaml:"hostname"`
Port uint16 `yaml:"port"`
Database dbutil.Config `yaml:"database"`
ID string `yaml:"id"`
Bot BotUserConfig `yaml:"bot"`
ASToken string `yaml:"as_token"`
HSToken string `yaml:"hs_token"`
EphemeralEvents bool `yaml:"ephemeral_events"`
AsyncTransactions bool `yaml:"async_transactions"`
}
func (config *BaseConfig) MakeUserIDRegex(matcher string) *regexp.Regexp {
usernamePlaceholder := strings.ToLower(random.String(16))
usernameTemplate := fmt.Sprintf("@%s:%s",
config.Bridge.FormatUsername(usernamePlaceholder),
config.Homeserver.Domain)
usernameTemplate = regexp.QuoteMeta(usernameTemplate)
usernameTemplate = strings.Replace(usernameTemplate, usernamePlaceholder, matcher, 1)
usernameTemplate = fmt.Sprintf("^%s$", usernameTemplate)
return regexp.MustCompile(usernameTemplate)
}
// GenerateRegistration generates a registration file for the homeserver.
func (config *BaseConfig) GenerateRegistration() *appservice.Registration {
registration := appservice.CreateRegistration()
config.AppService.HSToken = registration.ServerToken
config.AppService.ASToken = registration.AppToken
config.AppService.copyToRegistration(registration)
registration.SenderLocalpart = random.String(32)
botRegex := regexp.MustCompile(fmt.Sprintf("^@%s:%s$",
regexp.QuoteMeta(config.AppService.Bot.Username),
regexp.QuoteMeta(config.Homeserver.Domain)))
registration.Namespaces.UserIDs.Register(botRegex, true)
registration.Namespaces.UserIDs.Register(config.MakeUserIDRegex(".*"), true)
return registration
}
func (config *BaseConfig) MakeAppService() *appservice.AppService {
as := appservice.Create()
as.HomeserverDomain = config.Homeserver.Domain
_ = as.SetHomeserverURL(config.Homeserver.Address)
as.Host.Hostname = config.AppService.Hostname
as.Host.Port = config.AppService.Port
as.Registration = config.AppService.GetRegistration()
return as
}
// GetRegistration copies the data from the bridge config into an *appservice.Registration struct.
// This can't be used with the homeserver, see GenerateRegistration for generating files for the homeserver.
func (asc *AppserviceConfig) GetRegistration() *appservice.Registration {
reg := &appservice.Registration{}
asc.copyToRegistration(reg)
reg.SenderLocalpart = asc.Bot.Username
reg.ServerToken = asc.HSToken
reg.AppToken = asc.ASToken
return reg
}
func (asc *AppserviceConfig) copyToRegistration(registration *appservice.Registration) {
registration.ID = asc.ID
registration.URL = asc.Address
falseVal := false
registration.RateLimited = &falseVal
registration.EphemeralEvents = asc.EphemeralEvents
registration.SoruEphemeralEvents = asc.EphemeralEvents
}
type BotUserConfig struct {
Username string `yaml:"username"`
Displayname string `yaml:"displayname"`
Avatar string `yaml:"avatar"`
ParsedAvatar id.ContentURI `yaml:"-"`
}
type serializableBUC BotUserConfig
func (buc *BotUserConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
var sbuc serializableBUC
err := unmarshal(&sbuc)
if err != nil {
return err
}
*buc = (BotUserConfig)(sbuc)
if buc.Avatar != "" && buc.Avatar != "remove" {
buc.ParsedAvatar, err = id.ParseContentURI(buc.Avatar)
if err != nil {
return fmt.Errorf("%w in bot avatar", err)
}
}
return nil
}
type BridgeConfig interface {
FormatUsername(username string) string
GetEncryptionConfig() EncryptionConfig
GetCommandPrefix() string
GetManagementRoomTexts() ManagementRoomTexts
GetDoublePuppetConfig() DoublePuppetConfig
GetResendBridgeInfo() bool
EnableMessageStatusEvents() bool
EnableMessageErrorNotices() bool
Validate() error
}
type DoublePuppetConfig struct {
ServerMap map[string]string `yaml:"double_puppet_server_map"`
AllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
SharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
}
type EncryptionConfig struct {
Allow bool `yaml:"allow"`
Default bool `yaml:"default"`
Require bool `yaml:"require"`
Appservice bool `yaml:"appservice"`
PlaintextMentions bool `yaml:"plaintext_mentions"`
DeleteKeys struct {
DeleteOutboundOnAck bool `yaml:"delete_outbound_on_ack"`
DontStoreOutbound bool `yaml:"dont_store_outbound"`
RatchetOnDecrypt bool `yaml:"ratchet_on_decrypt"`
DeleteFullyUsedOnDecrypt bool `yaml:"delete_fully_used_on_decrypt"`
DeletePrevOnNewSession bool `yaml:"delete_prev_on_new_session"`
DeleteOnDeviceDelete bool `yaml:"delete_on_device_delete"`
PeriodicallyDeleteExpired bool `yaml:"periodically_delete_expired"`
DeleteOutdatedInbound bool `yaml:"delete_outdated_inbound"`
} `yaml:"delete_keys"`
VerificationLevels struct {
Receive id.TrustState `yaml:"receive"`
Send id.TrustState `yaml:"send"`
Share id.TrustState `yaml:"share"`
} `yaml:"verification_levels"`
AllowKeySharing bool `yaml:"allow_key_sharing"`
Rotation struct {
EnableCustom bool `yaml:"enable_custom"`
Milliseconds int64 `yaml:"milliseconds"`
Messages int `yaml:"messages"`
DisableDeviceChangeKeyRotation bool `yaml:"disable_device_change_key_rotation"`
} `yaml:"rotation"`
}
type ManagementRoomTexts struct {
Welcome string `yaml:"welcome"`
WelcomeConnected string `yaml:"welcome_connected"`
WelcomeUnconnected string `yaml:"welcome_unconnected"`
AdditionalHelp string `yaml:"additional_help"`
}
type BaseConfig struct {
Homeserver HomeserverConfig `yaml:"homeserver"`
AppService AppserviceConfig `yaml:"appservice"`
Bridge BridgeConfig `yaml:"-"`
Logging zeroconfig.Config `yaml:"logging"`
}
func doUpgrade(helper up.Helper) {
helper.Copy(up.Str, "homeserver", "address")
helper.Copy(up.Str, "homeserver", "domain")
if legacyAsmuxFlag, ok := helper.Get(up.Bool, "homeserver", "asmux"); ok && legacyAsmuxFlag == "true" {
helper.Set(up.Str, string(SoftwareAsmux), "homeserver", "software")
} else {
helper.Copy(up.Str, "homeserver", "software")
}
helper.Copy(up.Str|up.Null, "homeserver", "status_endpoint")
helper.Copy(up.Str|up.Null, "homeserver", "message_send_checkpoint_endpoint")
helper.Copy(up.Bool, "homeserver", "async_media")
helper.Copy(up.Str|up.Null, "homeserver", "websocket_proxy")
helper.Copy(up.Bool, "homeserver", "websocket")
helper.Copy(up.Int, "homeserver", "ping_interval_seconds")
helper.Copy(up.Str|up.Null, "appservice", "address")
helper.Copy(up.Str|up.Null, "appservice", "hostname")
helper.Copy(up.Int|up.Null, "appservice", "port")
if dbType, ok := helper.Get(up.Str, "appservice", "database", "type"); ok && dbType == "sqlite3" {
helper.Set(up.Str, "sqlite3-fk-wal", "appservice", "database", "type")
} else {
helper.Copy(up.Str, "appservice", "database", "type")
}
helper.Copy(up.Str, "appservice", "database", "uri")
helper.Copy(up.Int, "appservice", "database", "max_open_conns")
helper.Copy(up.Int, "appservice", "database", "max_idle_conns")
helper.Copy(up.Str|up.Null, "appservice", "database", "max_conn_idle_time")
helper.Copy(up.Str|up.Null, "appservice", "database", "max_conn_lifetime")
helper.Copy(up.Str, "appservice", "id")
helper.Copy(up.Str, "appservice", "bot", "username")
helper.Copy(up.Str, "appservice", "bot", "displayname")
helper.Copy(up.Str, "appservice", "bot", "avatar")
helper.Copy(up.Bool, "appservice", "ephemeral_events")
helper.Copy(up.Bool, "appservice", "async_transactions")
helper.Copy(up.Str, "appservice", "as_token")
helper.Copy(up.Str, "appservice", "hs_token")
if helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "print_level") != nil || helper.GetNode("logging", "file_name_format") != nil) {
_, _ = fmt.Fprintln(os.Stderr, "Migrating legacy log config")
migrateLegacyLogConfig(helper)
} else if helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "handlers") != nil) {
_, _ = fmt.Fprintln(os.Stderr, "Migrating Python log config is not currently supported")
// TODO implement?
//migratePythonLogConfig(helper)
} else {
helper.Copy(up.Map, "logging")
}
}
type legacyLogConfig struct {
Directory string `yaml:"directory"`
FileNameFormat string `yaml:"file_name_format"`
FileDateFormat string `yaml:"file_date_format"`
FileMode uint32 `yaml:"file_mode"`
TimestampFormat string `yaml:"timestamp_format"`
RawPrintLevel string `yaml:"print_level"`
JSONStdout bool `yaml:"print_json"`
JSONFile bool `yaml:"file_json"`
}
func migrateLegacyLogConfig(helper up.Helper) {
var llc legacyLogConfig
var newConfig zeroconfig.Config
err := helper.GetBaseNode("logging").Decode(&newConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Base config is corrupted: failed to decode example log config:", err)
return
} else if len(newConfig.Writers) != 2 || newConfig.Writers[0].Type != "stdout" || newConfig.Writers[1].Type != "file" {
_, _ = fmt.Fprintln(os.Stderr, "Base log config is not in expected format")
return
}
err = helper.GetNode("logging").Decode(&llc)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to decode legacy log config:", err)
return
}
if llc.RawPrintLevel != "" {
level, err := zerolog.ParseLevel(llc.RawPrintLevel)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse minimum stdout log level:", err)
} else {
newConfig.Writers[0].MinLevel = &level
}
}
if llc.Directory != "" && llc.FileNameFormat != "" {
if llc.FileNameFormat == "{{.Date}}-{{.Index}}.log" {
llc.FileNameFormat = "bridge.log"
} else {
llc.FileNameFormat = strings.ReplaceAll(llc.FileNameFormat, "{{.Date}}", "")
llc.FileNameFormat = strings.ReplaceAll(llc.FileNameFormat, "{{.Index}}", "")
}
newConfig.Writers[1].Filename = filepath.Join(llc.Directory, llc.FileNameFormat)
} else if llc.FileNameFormat == "" {
newConfig.Writers = newConfig.Writers[0:1]
}
if llc.JSONStdout {
newConfig.Writers[0].TimeFormat = ""
newConfig.Writers[0].Format = "json"
} else if llc.TimestampFormat != "" {
newConfig.Writers[0].TimeFormat = llc.TimestampFormat
}
var updatedConfig yaml.Node
err = updatedConfig.Encode(&newConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to encode migrated log config:", err)
return
}
*helper.GetBaseNode("logging").Node = updatedConfig
}
// Upgrader is a config upgrader that copies the default fields in the homeserver, appservice and logging blocks.
var Upgrader = up.SimpleUpgrader(doUpgrade)

View file

@ -0,0 +1,71 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridgeconfig
import (
"strconv"
"strings"
"maunium.net/go/mautrix/id"
)
type PermissionConfig map[string]PermissionLevel
type PermissionLevel int
const (
PermissionLevelBlock PermissionLevel = 0
PermissionLevelRelay PermissionLevel = 5
PermissionLevelUser PermissionLevel = 10
PermissionLevelAdmin PermissionLevel = 100
)
var namesToLevels = map[string]PermissionLevel{
"block": PermissionLevelBlock,
"relay": PermissionLevelRelay,
"user": PermissionLevelUser,
"admin": PermissionLevelAdmin,
}
func RegisterPermissionLevel(name string, level PermissionLevel) {
namesToLevels[name] = level
}
func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
rawPC := make(map[string]string)
err := unmarshal(&rawPC)
if err != nil {
return err
}
if *pc == nil {
*pc = make(map[string]PermissionLevel)
}
for key, value := range rawPC {
level, ok := namesToLevels[strings.ToLower(value)]
if ok {
(*pc)[key] = level
} else if val, err := strconv.Atoi(value); err == nil {
(*pc)[key] = PermissionLevel(val)
} else {
(*pc)[key] = PermissionLevelBlock
}
}
return nil
}
func (pc PermissionConfig) Get(userID id.UserID) PermissionLevel {
if level, ok := pc[string(userID)]; ok {
return level
} else if level, ok = pc[userID.Homeserver()]; len(userID.Homeserver()) > 0 && ok {
return level
} else if level, ok = pc["*"]; ok {
return level
} else {
return PermissionLevelBlock
}
}

156
bridge/bridgestate.go Normal file
View file

@ -0,0 +1,156 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridge
import (
"context"
"runtime/debug"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/status"
)
func (br *Bridge) SendBridgeState(ctx context.Context, state *status.BridgeState) error {
if br.Websocket {
// FIXME this doesn't account for multiple users
br.latestState = state
return br.AS.SendWebsocket(&appservice.WebsocketRequest{
Command: "bridge_status",
Data: state,
})
} else if br.Config.Homeserver.StatusEndpoint != "" {
return state.SendHTTP(ctx, br.Config.Homeserver.StatusEndpoint, br.Config.AppService.ASToken)
} else {
return nil
}
}
func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
if len(br.Config.Homeserver.StatusEndpoint) == 0 && !br.Websocket {
return
}
for {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := br.SendBridgeState(ctx, &state); err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to update global bridge state")
cancel()
time.Sleep(5 * time.Second)
continue
} else {
br.ZLog.Debug().Interface("bridge_state", state).Msg("Sent new global bridge state")
cancel()
break
}
}
}
type BridgeStateQueue struct {
prev *status.BridgeState
ch chan status.BridgeState
bridge *Bridge
user status.BridgeStateFiller
}
func (br *Bridge) NewBridgeStateQueue(user status.BridgeStateFiller) *BridgeStateQueue {
if len(br.Config.Homeserver.StatusEndpoint) == 0 && !br.Websocket {
return nil
}
bsq := &BridgeStateQueue{
ch: make(chan status.BridgeState, 10),
bridge: br,
user: user,
}
go bsq.loop()
return bsq
}
func (bsq *BridgeStateQueue) loop() {
defer func() {
err := recover()
if err != nil {
bsq.bridge.ZLog.Error().
Str(zerolog.ErrorStackFieldName, string(debug.Stack())).
Interface(zerolog.ErrorFieldName, err).
Msg("Panic in bridge state loop")
}
}()
for state := range bsq.ch {
bsq.immediateSendBridgeState(state)
}
}
func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) {
retryIn := 2
for {
if bsq.prev != nil && bsq.prev.ShouldDeduplicate(&state) {
bsq.bridge.ZLog.Debug().
Str("state_event", string(state.StateEvent)).
Msg("Not sending bridge state as it's a duplicate")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := bsq.bridge.SendBridgeState(ctx, &state)
cancel()
if err != nil {
bsq.bridge.ZLog.Warn().Err(err).
Int("retry_in_seconds", retryIn).
Msg("Failed to update bridge state")
time.Sleep(time.Duration(retryIn) * time.Second)
retryIn *= 2
if retryIn > 64 {
retryIn = 64
}
} else {
bsq.prev = &state
bsq.bridge.ZLog.Debug().
Interface("bridge_state", state).
Msg("Sent new bridge state")
return
}
}
}
func (bsq *BridgeStateQueue) Send(state status.BridgeState) {
if bsq == nil {
return
}
state = state.Fill(bsq.user)
if len(bsq.ch) >= 8 {
bsq.bridge.ZLog.Warn().Msg("Bridge state queue is nearly full, discarding an item")
select {
case <-bsq.ch:
default:
}
}
select {
case bsq.ch <- state:
default:
bsq.bridge.ZLog.Error().Msg("Bridge state queue is full, dropped new state")
}
}
func (bsq *BridgeStateQueue) GetPrev() status.BridgeState {
if bsq != nil && bsq.prev != nil {
return *bsq.prev
}
return status.BridgeState{}
}
func (bsq *BridgeStateQueue) SetPrev(prev status.BridgeState) {
if bsq != nil {
bsq.prev = &prev
}
}

77
bridge/commands/admin.go Normal file
View file

@ -0,0 +1,77 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"strconv"
"maunium.net/go/mautrix/id"
)
var CommandDiscardMegolmSession = &FullHandler{
Func: func(ce *Event) {
if ce.Bridge.Crypto == nil {
ce.Reply("This bridge instance doesn't have end-to-bridge encryption enabled")
} else {
ce.Bridge.Crypto.ResetSession(ce.Ctx, ce.RoomID)
ce.Reply("Successfully reset Megolm session in this room. New decryption keys will be shared the next time a message is sent from the remote network.")
}
},
Name: "discard-megolm-session",
Aliases: []string{"discard-session"},
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Discard the Megolm session in the room",
},
RequiresAdmin: true,
}
func fnSetPowerLevel(ce *Event) {
var level int
var userID id.UserID
var err error
if len(ce.Args) == 1 {
level, err = strconv.Atoi(ce.Args[0])
if err != nil {
ce.Reply("Invalid power level \"%s\"", ce.Args[0])
return
}
userID = ce.User.GetMXID()
} else if len(ce.Args) == 2 {
userID = id.UserID(ce.Args[0])
_, _, err := userID.Parse()
if err != nil {
ce.Reply("Invalid user ID \"%s\"", ce.Args[0])
return
}
level, err = strconv.Atoi(ce.Args[1])
if err != nil {
ce.Reply("Invalid power level \"%s\"", ce.Args[1])
return
}
} else {
ce.Reply("**Usage:** `set-pl [user] <level>`")
return
}
_, err = ce.Portal.MainIntent().SetPowerLevel(ce.Ctx, ce.RoomID, userID, level)
if err != nil {
ce.Reply("Failed to set power levels: %v", err)
}
}
var CommandSetPowerLevel = &FullHandler{
Func: fnSetPowerLevel,
Name: "set-pl",
Aliases: []string{"set-power-level"},
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Change the power level in a portal room.",
Args: "[_user ID_] <_power level_>",
},
RequiresAdmin: true,
RequiresPortal: true,
}

View file

@ -0,0 +1,83 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
var CommandLoginMatrix = &FullHandler{
Func: fnLoginMatrix,
Name: "login-matrix",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Enable double puppeting.",
Args: "<_access token_>",
},
RequiresLogin: true,
}
func fnLoginMatrix(ce *Event) {
if len(ce.Args) == 0 {
ce.Reply("**Usage:** `login-matrix <access token>`")
return
}
puppet := ce.User.GetIDoublePuppet()
if puppet == nil {
puppet = ce.User.GetIGhost()
if puppet == nil {
ce.Reply("Didn't get a ghost :(")
return
}
}
err := puppet.SwitchCustomMXID(ce.Args[0], ce.User.GetMXID())
if err != nil {
ce.Reply("Failed to enable double puppeting: %v", err)
} else {
ce.Reply("Successfully switched puppet")
}
}
var CommandPingMatrix = &FullHandler{
Func: fnPingMatrix,
Name: "ping-matrix",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Ping the Matrix server with the double puppet.",
},
RequiresLogin: true,
}
func fnPingMatrix(ce *Event) {
puppet := ce.User.GetIDoublePuppet()
if puppet == nil || puppet.CustomIntent() == nil {
ce.Reply("You are not logged in with your Matrix account.")
return
}
resp, err := puppet.CustomIntent().Whoami(ce.Ctx)
if err != nil {
ce.Reply("Failed to validate Matrix login: %v", err)
} else {
ce.Reply("Confirmed valid access token for %s / %s", resp.UserID, resp.DeviceID)
}
}
var CommandLogoutMatrix = &FullHandler{
Func: fnLogoutMatrix,
Name: "logout-matrix",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Disable double puppeting.",
},
RequiresLogin: true,
}
func fnLogoutMatrix(ce *Event) {
puppet := ce.User.GetIDoublePuppet()
if puppet == nil || puppet.CustomIntent() == nil {
ce.Reply("You don't have double puppeting enabled.")
return
}
puppet.ClearCustomMXID()
ce.Reply("Successfully disabled double puppeting.")
}

95
bridge/commands/event.go Normal file
View file

@ -0,0 +1,95 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
// Event stores all data which might be used to handle commands
type Event struct {
Bot *appservice.IntentAPI
Bridge *bridge.Bridge
Portal bridge.Portal
Processor *Processor
Handler MinimalHandler
RoomID id.RoomID
EventID id.EventID
User bridge.User
Command string
Args []string
RawArgs string
ReplyTo id.EventID
Ctx context.Context
ZLog *zerolog.Logger
}
// MainIntent returns the intent to use when replying to the command.
//
// It prefers the bridge bot, but falls back to the other user in DMs if the bridge bot is not present.
func (ce *Event) MainIntent() *appservice.IntentAPI {
intent := ce.Bot
if ce.Portal != nil && ce.Portal.IsPrivateChat() && !ce.Portal.IsEncrypted() {
intent = ce.Portal.MainIntent()
}
return intent
}
// Reply sends a reply to command as notice, with optional string formatting and automatic $cmdprefix replacement.
func (ce *Event) Reply(msg string, args ...interface{}) {
msg = strings.ReplaceAll(msg, "$cmdprefix ", ce.Bridge.Config.Bridge.GetCommandPrefix()+" ")
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
ce.ReplyAdvanced(msg, true, false)
}
// ReplyAdvanced sends a reply to command as notice. It allows using HTML and disabling markdown,
// but doesn't have built-in string formatting.
func (ce *Event) ReplyAdvanced(msg string, allowMarkdown, allowHTML bool) {
content := format.RenderMarkdown(msg, allowMarkdown, allowHTML)
content.MsgType = event.MsgNotice
_, err := ce.MainIntent().SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, content)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to reply to command")
}
}
// React sends a reaction to the command.
func (ce *Event) React(key string) {
_, err := ce.MainIntent().SendReaction(ce.Ctx, ce.RoomID, ce.EventID, key)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to react to command")
}
}
// Redact redacts the command.
func (ce *Event) Redact(req ...mautrix.ReqRedact) {
_, err := ce.MainIntent().RedactEvent(ce.Ctx, ce.RoomID, ce.EventID, req...)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to redact command")
}
}
// MarkRead marks the command event as read.
func (ce *Event) MarkRead() {
err := ce.MainIntent().SendReceipt(ce.Ctx, ce.RoomID, ce.EventID, event.ReceiptTypeRead, nil)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to mark command as read")
}
}

100
bridge/commands/handler.go Normal file
View file

@ -0,0 +1,100 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
)
type MinimalHandler interface {
Run(*Event)
}
type MinimalHandlerFunc func(*Event)
func (mhf MinimalHandlerFunc) Run(ce *Event) {
mhf(ce)
}
type CommandState struct {
Next MinimalHandler
Action string
Meta interface{}
}
type CommandingUser interface {
bridge.User
GetCommandState() *CommandState
SetCommandState(*CommandState)
}
type Handler interface {
MinimalHandler
GetName() string
}
type AliasedHandler interface {
Handler
GetAliases() []string
}
type FullHandler struct {
Func func(*Event)
Name string
Aliases []string
Help HelpMeta
RequiresAdmin bool
RequiresPortal bool
RequiresLogin bool
RequiresEventLevel event.Type
}
func (fh *FullHandler) GetHelp() HelpMeta {
fh.Help.Command = fh.Name
return fh.Help
}
func (fh *FullHandler) GetName() string {
return fh.Name
}
func (fh *FullHandler) GetAliases() []string {
return fh.Aliases
}
func (fh *FullHandler) ShowInHelp(ce *Event) bool {
return !fh.RequiresAdmin || ce.User.GetPermissionLevel() >= bridgeconfig.PermissionLevelAdmin
}
func (fh *FullHandler) userHasRoomPermission(ce *Event) bool {
levels, err := ce.MainIntent().PowerLevels(ce.Ctx, ce.RoomID)
if err != nil {
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
ce.Reply("Failed to get room power levels to see if you're allowed to use that command")
return false
}
return levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(fh.RequiresEventLevel)
}
func (fh *FullHandler) Run(ce *Event) {
if fh.RequiresAdmin && ce.User.GetPermissionLevel() < bridgeconfig.PermissionLevelAdmin {
ce.Reply("That command is limited to bridge administrators.")
} else if fh.RequiresEventLevel.Type != "" && ce.User.GetPermissionLevel() < bridgeconfig.PermissionLevelAdmin && !fh.userHasRoomPermission(ce) {
ce.Reply("That command requires room admin rights.")
} else if fh.RequiresPortal && ce.Portal == nil {
ce.Reply("That command can only be ran in portal rooms.")
} else if fh.RequiresLogin && !ce.User.IsLoggedIn() {
ce.Reply("That command requires you to be logged in.")
} else {
fh.Func(ce)
}
}

129
bridge/commands/help.go Normal file
View file

@ -0,0 +1,129 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"fmt"
"sort"
"strings"
)
type HelpfulHandler interface {
Handler
GetHelp() HelpMeta
ShowInHelp(*Event) bool
}
type HelpSection struct {
Name string
Order int
}
var (
// Deprecated: this should be used as a placeholder that needs to be fixed
HelpSectionUnclassified = HelpSection{"Unclassified", -1}
HelpSectionGeneral = HelpSection{"General", 0}
HelpSectionAuth = HelpSection{"Authentication", 10}
HelpSectionAdmin = HelpSection{"Administration", 50}
)
type HelpMeta struct {
Command string
Section HelpSection
Description string
Args string
}
func (hm *HelpMeta) String() string {
if len(hm.Args) == 0 {
return fmt.Sprintf("**%s** - %s", hm.Command, hm.Description)
}
return fmt.Sprintf("**%s** %s - %s", hm.Command, hm.Args, hm.Description)
}
type helpSectionList []HelpSection
func (h helpSectionList) Len() int {
return len(h)
}
func (h helpSectionList) Less(i, j int) bool {
return h[i].Order < h[j].Order
}
func (h helpSectionList) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
type helpMetaList []HelpMeta
func (h helpMetaList) Len() int {
return len(h)
}
func (h helpMetaList) Less(i, j int) bool {
return h[i].Command < h[j].Command
}
func (h helpMetaList) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
var _ sort.Interface = (helpSectionList)(nil)
var _ sort.Interface = (helpMetaList)(nil)
func FormatHelp(ce *Event) string {
sections := make(map[HelpSection]helpMetaList)
for _, handler := range ce.Processor.handlers {
helpfulHandler, ok := handler.(HelpfulHandler)
if !ok || !helpfulHandler.ShowInHelp(ce) {
continue
}
help := helpfulHandler.GetHelp()
if help.Description == "" {
continue
}
sections[help.Section] = append(sections[help.Section], help)
}
sortedSections := make(helpSectionList, 0, len(sections))
for section := range sections {
sortedSections = append(sortedSections, section)
}
sort.Sort(sortedSections)
var output strings.Builder
output.Grow(10240)
var prefixMsg string
if ce.RoomID == ce.User.GetManagementRoomID() {
prefixMsg = "This is your management room: prefixing commands with `%s` is not required."
} else if ce.Portal != nil {
prefixMsg = "**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged."
} else {
prefixMsg = "This is not your management room: prefixing commands with `%s` is required."
}
_, _ = fmt.Fprintf(&output, prefixMsg, ce.Bridge.Config.Bridge.GetCommandPrefix())
output.WriteByte('\n')
output.WriteString("Parameters in [square brackets] are optional, while parameters in <angle brackets> are required.")
output.WriteByte('\n')
output.WriteByte('\n')
for _, section := range sortedSections {
output.WriteString("#### ")
output.WriteString(section.Name)
output.WriteByte('\n')
sort.Sort(sections[section])
for _, command := range sections[section] {
output.WriteString(command.String())
output.WriteByte('\n')
}
output.WriteByte('\n')
}
return output.String()
}

56
bridge/commands/meta.go Normal file
View file

@ -0,0 +1,56 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
var CommandHelp = &FullHandler{
Func: func(ce *Event) {
ce.Reply(FormatHelp(ce))
},
Name: "help",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Show this help message.",
},
}
var CommandVersion = &FullHandler{
Func: func(ce *Event) {
ce.Reply("[%s](%s) %s (%s)", ce.Bridge.Name, ce.Bridge.URL, ce.Bridge.LinkifiedVersion, ce.Bridge.BuildTime)
},
Name: "version",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Get the bridge version.",
},
}
var CommandCancel = &FullHandler{
Func: func(ce *Event) {
commandingUser, ok := ce.User.(CommandingUser)
if !ok {
ce.Reply("This bridge does not implement cancelable commands")
return
}
state := commandingUser.GetCommandState()
if state != nil {
action := state.Action
if action == "" {
action = "Unknown action"
}
commandingUser.SetCommandState(nil)
ce.Reply("%s cancelled.", action)
} else {
ce.Reply("No ongoing command.")
}
},
Name: "cancel",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Cancel an ongoing action.",
},
}

View file

@ -0,0 +1,122 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"context"
"runtime/debug"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
)
type Processor struct {
bridge *bridge.Bridge
log *zerolog.Logger
handlers map[string]Handler
aliases map[string]string
}
// NewProcessor creates a Processor
func NewProcessor(bridge *bridge.Bridge) *Processor {
proc := &Processor{
bridge: bridge,
log: bridge.ZLog,
handlers: make(map[string]Handler),
aliases: make(map[string]string),
}
proc.AddHandlers(
CommandHelp, CommandVersion, CommandCancel,
CommandLoginMatrix, CommandLogoutMatrix, CommandPingMatrix,
CommandDiscardMegolmSession, CommandSetPowerLevel)
return proc
}
func (proc *Processor) AddHandlers(handlers ...Handler) {
for _, handler := range handlers {
proc.AddHandler(handler)
}
}
func (proc *Processor) AddHandler(handler Handler) {
proc.handlers[handler.GetName()] = handler
aliased, ok := handler.(AliasedHandler)
if ok {
for _, alias := range aliased.GetAliases() {
proc.aliases[alias] = handler.GetName()
}
}
}
// Handle handles messages to the bridge
func (proc *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user bridge.User, message string, replyTo id.EventID) {
defer func() {
err := recover()
if err != nil {
zerolog.Ctx(ctx).Error().
Str(zerolog.ErrorStackFieldName, string(debug.Stack())).
Interface(zerolog.ErrorFieldName, err).
Msg("Panic in Matrix command handler")
}
}()
args := strings.Fields(message)
if len(args) == 0 {
args = []string{"unknown-command"}
}
command := strings.ToLower(args[0])
rawArgs := strings.TrimLeft(strings.TrimPrefix(message, command), " ")
log := zerolog.Ctx(ctx).With().Str("mx_command", command).Logger()
ctx = log.WithContext(ctx)
ce := &Event{
Bot: proc.bridge.Bot,
Bridge: proc.bridge,
Portal: proc.bridge.Child.GetIPortal(roomID),
Processor: proc,
RoomID: roomID,
EventID: eventID,
User: user,
Command: command,
Args: args[1:],
RawArgs: rawArgs,
ReplyTo: replyTo,
Ctx: ctx,
ZLog: &log,
}
log.Debug().Msg("Received command")
realCommand, ok := proc.aliases[ce.Command]
if !ok {
realCommand = ce.Command
}
commandingUser, ok := ce.User.(CommandingUser)
var handler MinimalHandler
handler, ok = proc.handlers[realCommand]
if !ok {
var state *CommandState
if commandingUser != nil {
state = commandingUser.GetCommandState()
}
if state != nil && state.Next != nil {
ce.Command = ""
ce.RawArgs = message
ce.Args = args
ce.Handler = state.Next
state.Next.Run(ce)
} else {
ce.Reply("Unknown command, use the `help` command for help.")
}
} else {
ce.Handler = handler
handler.Run(ce)
}
}

511
bridge/crypto.go Normal file
View file

@ -0,0 +1,511 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build cgo && !nocrypto
package bridge
import (
"context"
"errors"
"fmt"
"os"
"runtime/debug"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/sqlstatestore"
)
var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil)
var NoSessionFound = crypto.NoSessionFound
var DuplicateMessageIndex = crypto.DuplicateMessageIndex
var UnknownMessageIndex = olm.UnknownMessageIndex
type CryptoHelper struct {
bridge *Bridge
client *mautrix.Client
mach *crypto.OlmMachine
store *SQLCryptoStore
log *zerolog.Logger
lock sync.RWMutex
syncDone sync.WaitGroup
cancelSync func()
cancelPeriodicDeleteLoop func()
}
func NewCryptoHelper(bridge *Bridge) Crypto {
if !bridge.Config.Bridge.GetEncryptionConfig().Allow {
bridge.ZLog.Debug().Msg("Bridge built with end-to-bridge encryption, but disabled in config")
return nil
}
log := bridge.ZLog.With().Str("component", "crypto").Logger()
return &CryptoHelper{
bridge: bridge,
log: &log,
}
}
func (helper *CryptoHelper) Init(ctx context.Context) error {
if len(helper.bridge.CryptoPickleKey) == 0 {
panic("CryptoPickleKey not set")
}
helper.log.Debug().Msg("Initializing end-to-bridge encryption...")
helper.store = NewSQLCryptoStore(
helper.bridge.DB,
dbutil.ZeroLogger(helper.bridge.ZLog.With().Str("db_section", "crypto").Logger()),
helper.bridge.AS.BotMXID(),
fmt.Sprintf("@%s:%s", helper.bridge.Config.Bridge.FormatUsername("%"), helper.bridge.AS.HomeserverDomain),
helper.bridge.CryptoPickleKey,
)
err := helper.store.DB.Upgrade(ctx)
if err != nil {
helper.bridge.LogDBUpgradeErrorAndExit("crypto", err)
}
var isExistingDevice bool
helper.client, isExistingDevice, err = helper.loginBot(ctx)
if err != nil {
return err
}
helper.log.Debug().
Str("device_id", helper.client.DeviceID.String()).
Msg("Logged in as bridge bot")
stateStore := &cryptoStateStore{helper.bridge}
helper.mach = crypto.NewOlmMachine(helper.client, helper.log, helper.store, stateStore)
helper.mach.AllowKeyShare = helper.allowKeyShare
encryptionConfig := helper.bridge.Config.Bridge.GetEncryptionConfig()
helper.mach.SendKeysMinTrust = encryptionConfig.VerificationLevels.Receive
helper.mach.PlaintextMentions = encryptionConfig.PlaintextMentions
helper.mach.DeleteOutboundKeysOnAck = encryptionConfig.DeleteKeys.DeleteOutboundOnAck
helper.mach.DontStoreOutboundKeys = encryptionConfig.DeleteKeys.DontStoreOutbound
helper.mach.RatchetKeysOnDecrypt = encryptionConfig.DeleteKeys.RatchetOnDecrypt
helper.mach.DeleteFullyUsedKeysOnDecrypt = encryptionConfig.DeleteKeys.DeleteFullyUsedOnDecrypt
helper.mach.DeletePreviousKeysOnReceive = encryptionConfig.DeleteKeys.DeletePrevOnNewSession
helper.mach.DeleteKeysOnDeviceDelete = encryptionConfig.DeleteKeys.DeleteOnDeviceDelete
helper.mach.DisableDeviceChangeKeyRotation = encryptionConfig.Rotation.DisableDeviceChangeKeyRotation
if encryptionConfig.DeleteKeys.PeriodicallyDeleteExpired {
ctx, cancel := context.WithCancel(context.Background())
helper.cancelPeriodicDeleteLoop = cancel
go helper.mach.ExpiredKeyDeleteLoop(ctx)
}
if encryptionConfig.DeleteKeys.DeleteOutdatedInbound {
deleted, err := helper.store.RedactOutdatedGroupSessions(ctx)
if err != nil {
return err
}
if len(deleted) > 0 {
helper.log.Debug().Int("deleted", len(deleted)).Msg("Deleted inbound keys which lacked expiration metadata")
}
}
helper.client.Syncer = &cryptoSyncer{helper.mach}
helper.client.Store = helper.store
err = helper.mach.Load(ctx)
if err != nil {
return err
}
if isExistingDevice {
helper.verifyKeysAreOnServer(ctx)
}
go helper.resyncEncryptionInfo(context.TODO())
return nil
}
func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
log := helper.log.With().Str("action", "resync encryption event").Logger()
rows, err := helper.bridge.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`)
if err != nil {
log.Err(err).Msg("Failed to query rooms for resync")
return
}
roomIDs, err := dbutil.NewRowIter(rows, dbutil.ScanSingleColumn[id.RoomID]).AsList()
if err != nil {
log.Err(err).Msg("Failed to scan rooms for resync")
return
}
if len(roomIDs) > 0 {
log.Debug().Interface("room_ids", roomIDs).Msg("Resyncing rooms")
for _, roomID := range roomIDs {
var evt event.EncryptionEventContent
err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to get encryption event")
_, err = helper.bridge.DB.Exec(ctx, `
UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}'
`, roomID)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to unmark room for resync after failed sync")
}
} else {
maxAge := evt.RotationPeriodMillis
if maxAge <= 0 {
maxAge = (7 * 24 * time.Hour).Milliseconds()
}
maxMessages := evt.RotationPeriodMessages
if maxMessages <= 0 {
maxMessages = 100
}
log.Debug().
Str("room_id", roomID.String()).
Int64("max_age_ms", maxAge).
Int("max_messages", maxMessages).
Interface("content", &evt).
Msg("Resynced encryption event")
_, err = helper.bridge.DB.Exec(ctx, `
UPDATE crypto_megolm_inbound_session
SET max_age=$1, max_messages=$2
WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL
`, maxAge, maxMessages, roomID)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to update megolm session table")
} else {
log.Debug().Str("room_id", roomID.String()).Msg("Updated megolm session table")
}
}
}
}
}
func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
cfg := helper.bridge.Config.Bridge.GetEncryptionConfig()
if !cfg.AllowKeySharing {
return &crypto.KeyShareRejectNoResponse
} else if device.Trust == id.TrustStateBlacklisted {
return &crypto.KeyShareRejectBlacklisted
} else if trustState := helper.mach.ResolveTrust(device); trustState >= cfg.VerificationLevels.Share {
portal := helper.bridge.Child.GetIPortal(info.RoomID)
if portal == nil {
zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: room is not a portal")
return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
}
user := helper.bridge.Child.GetIUser(device.UserID, true)
// FIXME reimplement IsInPortal
if user.GetPermissionLevel() < bridgeconfig.PermissionLevelAdmin /*&& !user.IsInPortal(portal.Key)*/ {
zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: user is not in portal")
return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
}
zerolog.Ctx(ctx).Debug().Msg("Accepting key request")
return nil
} else {
return &crypto.KeyShareRejectUnverified
}
}
func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool, error) {
deviceID, err := helper.store.FindDeviceID(ctx)
if err != nil {
return nil, false, fmt.Errorf("failed to find existing device ID: %w", err)
} else if len(deviceID) > 0 {
helper.log.Debug().Str("device_id", deviceID.String()).Msg("Found existing device ID for bot in database")
}
// Create a new client instance with the default AS settings (including as_token),
// the Login call will then override the access token in the client.
client := helper.bridge.AS.NewMautrixClient(helper.bridge.AS.BotMXID())
flows, err := client.GetLoginFlows(ctx)
if err != nil {
return nil, deviceID != "", fmt.Errorf("failed to get supported login flows: %w", err)
} else if !flows.HasFlow(mautrix.AuthTypeAppservice) {
return nil, deviceID != "", fmt.Errorf("homeserver does not support appservice login")
}
resp, err := client.Login(ctx, &mautrix.ReqLogin{
Type: mautrix.AuthTypeAppservice,
Identifier: mautrix.UserIdentifier{
Type: mautrix.IdentifierTypeUser,
User: string(helper.bridge.AS.BotMXID()),
},
DeviceID: deviceID,
StoreCredentials: true,
InitialDeviceDisplayName: fmt.Sprintf("%s bridge", helper.bridge.ProtocolName),
})
if err != nil {
return nil, deviceID != "", fmt.Errorf("failed to log in as bridge bot: %w", err)
}
helper.store.DeviceID = resp.DeviceID
return client, deviceID != "", nil
}
func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) {
helper.log.Debug().Msg("Making sure keys are still on server")
resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{
DeviceKeys: map[id.UserID]mautrix.DeviceIDList{
helper.client.UserID: {helper.client.DeviceID},
},
})
if err != nil {
helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to query own keys to make sure device still exists")
os.Exit(33)
}
device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID]
if ok && len(device.Keys) > 0 {
return
}
helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto")
helper.Reset(ctx, false)
}
func (helper *CryptoHelper) Start() {
if helper.bridge.Config.Bridge.GetEncryptionConfig().Appservice {
helper.log.Debug().Msg("End-to-bridge encryption is in appservice mode, registering event listeners and not starting syncer")
helper.bridge.AS.Registration.EphemeralEvents = true
helper.mach.AddAppserviceListener(helper.bridge.EventProcessor)
return
}
helper.syncDone.Add(1)
defer helper.syncDone.Done()
helper.log.Debug().Msg("Starting syncer for receiving to-device messages")
var ctx context.Context
ctx, helper.cancelSync = context.WithCancel(context.Background())
err := helper.client.SyncWithContext(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Fatal error syncing")
os.Exit(51)
} else {
helper.log.Info().Msg("Bridge bot to-device syncer stopped without error")
}
}
func (helper *CryptoHelper) Stop() {
helper.log.Debug().Msg("CryptoHelper.Stop() called, stopping bridge bot sync")
helper.client.StopSync()
if helper.cancelSync != nil {
helper.cancelSync()
}
if helper.cancelPeriodicDeleteLoop != nil {
helper.cancelPeriodicDeleteLoop()
}
helper.syncDone.Wait()
}
func (helper *CryptoHelper) clearDatabase(ctx context.Context) {
_, err := helper.store.DB.Exec(ctx, "DELETE FROM crypto_account")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_account table")
}
_, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_olm_session")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_olm_session table")
}
_, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_megolm_outbound_session table")
}
//_, _ = helper.store.DB.Exec("DELETE FROM crypto_device")
//_, _ = helper.store.DB.Exec("DELETE FROM crypto_tracked_user")
//_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_keys")
//_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_signatures")
}
func (helper *CryptoHelper) Reset(ctx context.Context, startAfterReset bool) {
helper.lock.Lock()
defer helper.lock.Unlock()
helper.log.Info().Msg("Resetting end-to-bridge encryption device")
helper.Stop()
helper.log.Debug().Msg("Crypto syncer stopped, clearing database")
helper.clearDatabase(ctx)
helper.log.Debug().Msg("Crypto database cleared, logging out of all sessions")
_, err := helper.client.LogoutAll(ctx)
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to log out all devices")
}
helper.client = nil
helper.store = nil
helper.mach = nil
err = helper.Init(ctx)
if err != nil {
helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Error reinitializing end-to-bridge encryption")
os.Exit(50)
}
helper.log.Info().Msg("End-to-bridge encryption successfully reset")
if startAfterReset {
go helper.Start()
}
}
func (helper *CryptoHelper) Client() *mautrix.Client {
return helper.client
}
func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) {
return helper.mach.DecryptMegolmEvent(ctx, evt)
}
func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content *event.Content) (err error) {
helper.lock.RLock()
defer helper.lock.RUnlock()
var encrypted *event.EncryptedEventContent
encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content)
if err != nil {
if !errors.Is(err, crypto.SessionExpired) && !errors.Is(err, crypto.SessionNotShared) && !errors.Is(err, crypto.NoGroupSession) {
return
}
helper.log.Debug().Err(err).
Str("room_id", roomID.String()).
Msg("Got error while encrypting event for room, sharing group session and trying again...")
var users []id.UserID
users, err = helper.store.GetRoomJoinedOrInvitedMembers(ctx, roomID)
if err != nil {
err = fmt.Errorf("failed to get room member list: %w", err)
} else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil {
err = fmt.Errorf("failed to share group session: %w", err)
} else if encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content); err != nil {
err = fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err)
}
}
if encrypted != nil {
content.Parsed = encrypted
content.Raw = nil
}
return
}
func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
helper.lock.RLock()
defer helper.lock.RUnlock()
return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout)
}
func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
helper.lock.RLock()
defer helper.lock.RUnlock()
if deviceID == "" {
deviceID = "*"
}
err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
if err != nil {
helper.log.Warn().Err(err).
Str("user_id", userID.String()).
Str("device_id", deviceID.String()).
Str("session_id", sessionID.String()).
Str("room_id", roomID.String()).
Msg("Failed to send key request")
} else {
helper.log.Debug().
Str("user_id", userID.String()).
Str("device_id", deviceID.String()).
Str("session_id", sessionID.String()).
Str("room_id", roomID.String()).
Msg("Sent key request")
}
}
func (helper *CryptoHelper) ResetSession(ctx context.Context, roomID id.RoomID) {
helper.lock.RLock()
defer helper.lock.RUnlock()
err := helper.mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
if err != nil {
helper.log.Debug().Err(err).
Str("room_id", roomID.String()).
Msg("Error manually removing outbound group session in room")
}
}
func (helper *CryptoHelper) HandleMemberEvent(ctx context.Context, evt *event.Event) {
helper.lock.RLock()
defer helper.lock.RUnlock()
helper.mach.HandleMemberEvent(ctx, evt)
}
// ShareKeys uploads the given number of one-time-keys to the server.
func (helper *CryptoHelper) ShareKeys(ctx context.Context) error {
return helper.mach.ShareKeys(ctx, -1)
}
type cryptoSyncer struct {
*crypto.OlmMachine
}
func (syncer *cryptoSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
done := make(chan struct{})
go func() {
defer func() {
if err := recover(); err != nil {
syncer.Log.Error().
Str("since", since).
Interface("error", err).
Str("stack", string(debug.Stack())).
Msg("Processing sync response panicked")
}
done <- struct{}{}
}()
syncer.Log.Trace().Str("since", since).Msg("Starting sync response handling")
syncer.ProcessSyncResponse(ctx, resp, since)
syncer.Log.Trace().Str("since", since).Msg("Successfully handled sync response")
}()
select {
case <-done:
case <-time.After(30 * time.Second):
syncer.Log.Warn().Str("since", since).Msg("Handling sync response is taking unusually long")
}
return nil
}
func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
if errors.Is(err, mautrix.MUnknownToken) {
return 0, err
}
syncer.Log.Error().Err(err).Msg("Error /syncing, waiting 10 seconds")
return 10 * time.Second, nil
}
func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
everything := []event.Type{{Type: "*"}}
return &mautrix.Filter{
Presence: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
Room: mautrix.RoomFilter{
IncludeLeave: false,
Ephemeral: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
State: mautrix.FilterPart{NotTypes: everything},
Timeline: mautrix.FilterPart{NotTypes: everything},
},
}
}
type cryptoStateStore struct {
bridge *Bridge
}
var _ crypto.StateStore = (*cryptoStateStore)(nil)
func (c *cryptoStateStore) IsEncrypted(ctx context.Context, id id.RoomID) (bool, error) {
portal := c.bridge.Child.GetIPortal(id)
if portal != nil {
return portal.IsEncrypted(), nil
}
return c.bridge.StateStore.IsEncrypted(ctx, id)
}
func (c *cryptoStateStore) FindSharedRooms(ctx context.Context, id id.UserID) ([]id.RoomID, error) {
return c.bridge.StateStore.FindSharedRooms(ctx, id)
}
func (c *cryptoStateStore) GetEncryptionEvent(ctx context.Context, id id.RoomID) (*event.EncryptionEventContent, error) {
return c.bridge.StateStore.GetEncryptionEvent(ctx, id)
}

63
bridge/cryptostore.go Normal file
View file

@ -0,0 +1,63 @@
// Copyright (c) 2022 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build cgo && !nocrypto
package bridge
import (
"context"
"github.com/lib/pq"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/id"
)
func init() {
crypto.PostgresArrayWrapper = pq.Array
}
type SQLCryptoStore struct {
*crypto.SQLCryptoStore
UserID id.UserID
GhostIDFormat string
}
var _ crypto.Store = (*SQLCryptoStore)(nil)
func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, userID id.UserID, ghostIDFormat, pickleKey string) *SQLCryptoStore {
return &SQLCryptoStore{
SQLCryptoStore: crypto.NewSQLCryptoStore(db, log, "", "", []byte(pickleKey)),
UserID: userID,
GhostIDFormat: ghostIDFormat,
}
}
func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) {
var rows dbutil.Rows
rows, err = store.DB.Query(ctx, `
SELECT user_id FROM mx_user_profile
WHERE room_id=$1
AND (membership='join' OR membership='invite')
AND user_id<>$2
AND user_id NOT LIKE $3
`, roomID, store.UserID, store.GhostIDFormat)
if err != nil {
return
}
for rows.Next() {
var userID id.UserID
err = rows.Scan(&userID)
if err != nil {
return members, err
} else {
members = append(members, userID)
}
}
return
}

173
bridge/doublepuppet.go Normal file
View file

@ -0,0 +1,173 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridge
import (
"context"
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
type doublePuppetUtil struct {
br *Bridge
log zerolog.Logger
}
func (dp *doublePuppetUtil) newClient(ctx context.Context, mxid id.UserID, accessToken string) (*mautrix.Client, error) {
_, homeserver, err := mxid.Parse()
if err != nil {
return nil, err
}
homeserverURL, found := dp.br.Config.Bridge.GetDoublePuppetConfig().ServerMap[homeserver]
if !found {
if homeserver == dp.br.AS.HomeserverDomain {
homeserverURL = ""
} else if dp.br.Config.Bridge.GetDoublePuppetConfig().AllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(ctx, homeserver)
if err != nil {
return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
}
homeserverURL = resp.Homeserver.BaseURL
dp.log.Debug().
Str("homeserver", homeserver).
Str("url", homeserverURL).
Str("user_id", mxid.String()).
Msg("Discovered URL to enable double puppeting for user")
} else {
return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
}
}
return dp.br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL)
}
func (dp *doublePuppetUtil) newIntent(ctx context.Context, mxid id.UserID, accessToken string) (*appservice.IntentAPI, error) {
client, err := dp.newClient(ctx, mxid, accessToken)
if err != nil {
return nil, err
}
ia := dp.br.AS.NewIntentAPI("custom")
ia.Client = client
ia.Localpart, _, _ = mxid.Parse()
ia.UserID = mxid
ia.IsCustomPuppet = true
return ia, nil
}
func (dp *doublePuppetUtil) autoLogin(ctx context.Context, mxid id.UserID, loginSecret string) (string, error) {
dp.log.Debug().Str("user_id", mxid.String()).Msg("Logging into user account with shared secret")
client, err := dp.newClient(ctx, mxid, "")
if err != nil {
return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
}
bridgeName := fmt.Sprintf("%s Bridge", dp.br.ProtocolName)
req := mautrix.ReqLogin{
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
DeviceID: id.DeviceID(bridgeName),
InitialDeviceDisplayName: bridgeName,
}
if loginSecret == "appservice" {
client.AccessToken = dp.br.AS.Registration.AppToken
req.Type = mautrix.AuthTypeAppservice
} else {
loginFlows, err := client.GetLoginFlows(ctx)
if err != nil {
return "", fmt.Errorf("failed to get supported login flows: %w", err)
}
mac := hmac.New(sha512.New, []byte(loginSecret))
mac.Write([]byte(mxid))
token := hex.EncodeToString(mac.Sum(nil))
switch {
case loginFlows.HasFlow(mautrix.AuthTypeDevtureSharedSecret):
req.Type = mautrix.AuthTypeDevtureSharedSecret
req.Token = token
case loginFlows.HasFlow(mautrix.AuthTypePassword):
req.Type = mautrix.AuthTypePassword
req.Password = token
default:
return "", fmt.Errorf("no supported auth types for shared secret auth found")
}
}
resp, err := client.Login(ctx, &req)
if err != nil {
return "", err
}
return resp.AccessToken, nil
}
var (
ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
ErrNoAccessToken = errors.New("no access token provided")
ErrNoMXID = errors.New("no mxid provided")
)
const useConfigASToken = "appservice-config"
const asTokenModePrefix = "as_token:"
func (dp *doublePuppetUtil) Setup(ctx context.Context, mxid id.UserID, savedAccessToken string, reloginOnFail bool) (intent *appservice.IntentAPI, newAccessToken string, err error) {
if len(mxid) == 0 {
err = ErrNoMXID
return
}
_, homeserver, _ := mxid.Parse()
loginSecret, hasSecret := dp.br.Config.Bridge.GetDoublePuppetConfig().SharedSecretMap[homeserver]
// Special case appservice: prefix to not login and use it as an as_token directly.
if hasSecret && strings.HasPrefix(loginSecret, asTokenModePrefix) {
intent, err = dp.newIntent(ctx, mxid, strings.TrimPrefix(loginSecret, asTokenModePrefix))
if err != nil {
return
}
intent.SetAppServiceUserID = true
if savedAccessToken != useConfigASToken {
var resp *mautrix.RespWhoami
resp, err = intent.Whoami(ctx)
if err == nil && resp.UserID != mxid {
err = ErrMismatchingMXID
}
}
return intent, useConfigASToken, err
}
if savedAccessToken == "" || savedAccessToken == useConfigASToken {
if reloginOnFail && hasSecret {
savedAccessToken, err = dp.autoLogin(ctx, mxid, loginSecret)
} else {
err = ErrNoAccessToken
}
if err != nil {
return
}
}
intent, err = dp.newIntent(ctx, mxid, savedAccessToken)
if err != nil {
return
}
var resp *mautrix.RespWhoami
resp, err = intent.Whoami(ctx)
if err != nil {
if reloginOnFail && hasSecret && errors.Is(err, mautrix.MUnknownToken) {
intent.AccessToken, err = dp.autoLogin(ctx, mxid, loginSecret)
if err == nil {
newAccessToken = intent.AccessToken
}
}
} else if resp.UserID != mxid {
err = ErrMismatchingMXID
} else {
newAccessToken = savedAccessToken
}
return
}

755
bridge/matrix.go Normal file
View file

@ -0,0 +1,755 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridge
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type CommandProcessor interface {
Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user User, message string, replyTo id.EventID)
}
type MatrixHandler struct {
bridge *Bridge
as *appservice.AppService
log *zerolog.Logger
TrackEventDuration func(event.Type) func()
}
func noop() {}
func noopTrack(_ event.Type) func() {
return noop
}
func NewMatrixHandler(br *Bridge) *MatrixHandler {
handler := &MatrixHandler{
bridge: br,
as: br.AS,
log: br.ZLog,
TrackEventDuration: noopTrack,
}
for evtType := range status.CheckpointTypes {
br.EventProcessor.On(evtType, handler.sendBridgeCheckpoint)
}
br.EventProcessor.On(event.EventMessage, handler.HandleMessage)
br.EventProcessor.On(event.EventEncrypted, handler.HandleEncrypted)
br.EventProcessor.On(event.EventSticker, handler.HandleMessage)
br.EventProcessor.On(event.EventReaction, handler.HandleReaction)
br.EventProcessor.On(event.EventRedaction, handler.HandleRedaction)
br.EventProcessor.On(event.StateMember, handler.HandleMembership)
br.EventProcessor.On(event.StateRoomName, handler.HandleRoomMetadata)
br.EventProcessor.On(event.StateRoomAvatar, handler.HandleRoomMetadata)
br.EventProcessor.On(event.StateTopic, handler.HandleRoomMetadata)
br.EventProcessor.On(event.StateEncryption, handler.HandleEncryption)
br.EventProcessor.On(event.EphemeralEventReceipt, handler.HandleReceipt)
br.EventProcessor.On(event.EphemeralEventTyping, handler.HandleTyping)
br.EventProcessor.On(event.StatePowerLevels, handler.HandlePowerLevels)
br.EventProcessor.On(event.StateJoinRules, handler.HandleJoinRule)
return handler
}
func (mx *MatrixHandler) sendBridgeCheckpoint(_ context.Context, evt *event.Event) {
if !evt.Mautrix.CheckpointSent {
go mx.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepBridge, 0)
}
}
func (mx *MatrixHandler) HandleEncryption(ctx context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if evt.Content.AsEncryption().Algorithm != id.AlgorithmMegolmV1 {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal != nil && !portal.IsEncrypted() {
mx.log.Debug().
Str("user_id", evt.Sender.String()).
Str("room_id", evt.RoomID.String()).
Msg("Encryption was enabled in room")
portal.MarkEncrypted()
if portal.IsPrivateChat() {
err := mx.as.BotIntent().EnsureJoined(ctx, evt.RoomID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client})
if err != nil {
mx.log.Err(err).
Str("room_id", evt.RoomID.String()).
Msg("Failed to join bot to room after encryption was enabled")
}
}
}
}
func (mx *MatrixHandler) joinAndCheckMembers(ctx context.Context, evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
log := zerolog.Ctx(ctx)
resp, err := intent.JoinRoomByID(ctx, evt.RoomID)
if err != nil {
log.Warn().Err(err).Msg("Failed to join room with invite")
return nil
}
members, err := intent.JoinedMembers(ctx, resp.RoomID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get members in room after accepting invite, leaving room")
_, _ = intent.LeaveRoom(ctx, resp.RoomID)
return nil
}
if len(members.Joined) < 2 {
log.Debug().Msg("Leaving empty room after accepting invite")
_, _ = intent.LeaveRoom(ctx, resp.RoomID)
return nil
}
return members
}
func (mx *MatrixHandler) sendNoticeWithMarkdown(ctx context.Context, roomID id.RoomID, message string) (*mautrix.RespSendEvent, error) {
intent := mx.as.BotIntent()
content := format.RenderMarkdown(message, true, false)
content.MsgType = event.MsgNotice
return intent.SendMessageEvent(ctx, roomID, event.EventMessage, content)
}
func (mx *MatrixHandler) HandleBotInvite(ctx context.Context, evt *event.Event) {
intent := mx.as.BotIntent()
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil {
return
}
members := mx.joinAndCheckMembers(ctx, evt, intent)
if members == nil {
return
}
if user.GetPermissionLevel() < bridgeconfig.PermissionLevelUser {
_, _ = intent.SendNotice(ctx, evt.RoomID, "You are not whitelisted to use this bridge.\n"+
"If you're the owner of this bridge, see the bridge.permissions section in your config file.")
_, _ = intent.LeaveRoom(ctx, evt.RoomID)
return
}
texts := mx.bridge.Config.Bridge.GetManagementRoomTexts()
_, _ = mx.sendNoticeWithMarkdown(ctx, evt.RoomID, texts.Welcome)
if len(members.Joined) == 2 && (len(user.GetManagementRoomID()) == 0 || evt.Content.AsMember().IsDirect) {
user.SetManagementRoom(evt.RoomID)
_, _ = intent.SendNotice(ctx, user.GetManagementRoomID(), "This room has been registered as your bridge management/status room.")
zerolog.Ctx(ctx).Debug().Msg("Registered room as management room with inviter")
}
if evt.RoomID == user.GetManagementRoomID() {
if user.IsLoggedIn() {
_, _ = mx.sendNoticeWithMarkdown(ctx, evt.RoomID, texts.WelcomeConnected)
} else {
_, _ = mx.sendNoticeWithMarkdown(ctx, evt.RoomID, texts.WelcomeUnconnected)
}
additionalHelp := texts.AdditionalHelp
if len(additionalHelp) > 0 {
_, _ = mx.sendNoticeWithMarkdown(ctx, evt.RoomID, additionalHelp)
}
}
}
func (mx *MatrixHandler) HandleGhostInvite(ctx context.Context, evt *event.Event, inviter User, ghost Ghost) {
log := zerolog.Ctx(ctx)
intent := ghost.DefaultIntent()
if inviter.GetPermissionLevel() < bridgeconfig.PermissionLevelUser {
log.Debug().Msg("Rejecting invite: inviter is not whitelisted")
_, err := intent.LeaveRoom(ctx, evt.RoomID, &mautrix.ReqLeave{
Reason: "You're not whitelisted to use this bridge",
})
if err != nil {
log.Error().Err(err).Msg("Failed to reject invite")
}
return
} else if !inviter.IsLoggedIn() {
log.Debug().Msg("Rejecting invite: inviter is not logged in")
_, err := intent.LeaveRoom(ctx, evt.RoomID, &mautrix.ReqLeave{
Reason: "You're not logged into this bridge",
})
if err != nil {
log.Error().Err(err).Msg("Failed to reject invite")
}
return
}
members := mx.joinAndCheckMembers(ctx, evt, intent)
if members == nil {
return
}
var createEvent event.CreateEventContent
if err := intent.StateEvent(ctx, evt.RoomID, event.StateCreate, "", &createEvent); err != nil {
log.Warn().Err(err).Msg("Failed to check m.room.create event in room")
} else if createEvent.Type != "" {
log.Warn().Str("room_type", string(createEvent.Type)).Msg("Non-standard room type, leaving room")
_, err = intent.LeaveRoom(ctx, evt.RoomID, &mautrix.ReqLeave{
Reason: "Unsupported room type",
})
if err != nil {
log.Error().Err(err).Msg("Failed to leave room")
}
return
}
var hasBridgeBot, hasOtherUsers bool
for mxid, _ := range members.Joined {
if mxid == intent.UserID || mxid == inviter.GetMXID() {
continue
} else if mxid == mx.bridge.Bot.UserID {
hasBridgeBot = true
} else {
hasOtherUsers = true
}
}
if !hasBridgeBot && !hasOtherUsers && evt.Content.AsMember().IsDirect {
mx.bridge.Child.CreatePrivatePortal(evt.RoomID, inviter, ghost)
} else if !hasBridgeBot {
log.Debug().Msg("Leaving multi-user room after accepting invite")
_, _ = intent.SendNotice(ctx, evt.RoomID, "Please invite the bridge bot first if you want to bridge to a remote chat.")
_, _ = intent.LeaveRoom(ctx, evt.RoomID)
} else {
_, _ = intent.SendNotice(ctx, evt.RoomID, "This puppet will remain inactive until this room is bridged to a remote chat.")
}
}
func (mx *MatrixHandler) HandleMembership(ctx context.Context, evt *event.Event) {
if evt.Sender == mx.bridge.Bot.UserID || mx.bridge.Child.IsGhost(evt.Sender) {
return
}
defer mx.TrackEventDuration(evt.Type)()
if mx.bridge.Crypto != nil {
mx.bridge.Crypto.HandleMemberEvent(ctx, evt)
}
log := mx.log.With().
Str("sender", evt.Sender.String()).
Str("target", evt.GetStateKey()).
Str("room_id", evt.RoomID.String()).
Logger()
ctx = log.WithContext(ctx)
content := evt.Content.AsMember()
if content.Membership == event.MembershipInvite && id.UserID(evt.GetStateKey()) == mx.as.BotMXID() {
mx.HandleBotInvite(ctx, evt)
return
}
if mx.shouldIgnoreEvent(evt) {
return
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil {
return
}
isSelf := id.UserID(evt.GetStateKey()) == evt.Sender
ghost := mx.bridge.Child.GetIGhost(id.UserID(evt.GetStateKey()))
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil {
if ghost != nil && content.Membership == event.MembershipInvite {
mx.HandleGhostInvite(ctx, evt, user, ghost)
}
return
} else if user.GetPermissionLevel() < bridgeconfig.PermissionLevelUser || !user.IsLoggedIn() {
return
}
bhp, bhpOk := portal.(BanHandlingPortal)
mhp, mhpOk := portal.(MembershipHandlingPortal)
khp, khpOk := portal.(KnockHandlingPortal)
ihp, ihpOk := portal.(InviteHandlingPortal)
if !(mhpOk || bhpOk || khpOk) {
return
}
prevContent := &event.MemberEventContent{Membership: event.MembershipLeave}
if evt.Unsigned.PrevContent != nil {
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent)
}
if ihpOk && prevContent.Membership == event.MembershipInvite && content.Membership != event.MembershipBan {
if content.Membership == event.MembershipJoin {
ihp.HandleMatrixAcceptInvite(user, evt)
}
if content.Membership == event.MembershipLeave {
if isSelf {
ihp.HandleMatrixRejectInvite(user, evt)
} else if ghost != nil {
ihp.HandleMatrixRetractInvite(user, ghost, evt)
}
}
}
if bhpOk && ghost != nil {
if content.Membership == event.MembershipBan {
bhp.HandleMatrixBan(user, ghost, evt)
} else if content.Membership == event.MembershipLeave && prevContent.Membership == event.MembershipBan {
bhp.HandleMatrixUnban(user, ghost, evt)
}
}
if khpOk {
if content.Membership == event.MembershipKnock {
khp.HandleMatrixKnock(user, evt)
} else if prevContent.Membership == event.MembershipKnock {
if content.Membership == event.MembershipInvite && ghost != nil {
khp.HandleMatrixAcceptKnock(user, ghost, evt)
} else if content.Membership == event.MembershipLeave {
if isSelf {
khp.HandleMatrixRetractKnock(user, evt)
} else if ghost != nil {
khp.HandleMatrixRejectKnock(user, ghost, evt)
}
}
}
}
if mhpOk {
if content.Membership == event.MembershipLeave && prevContent.Membership == event.MembershipJoin {
if isSelf {
mhp.HandleMatrixLeave(user, evt)
} else if ghost != nil {
mhp.HandleMatrixKick(user, ghost, evt)
}
} else if content.Membership == event.MembershipInvite && !isSelf && ghost != nil {
mhp.HandleMatrixInvite(user, ghost, evt)
}
}
// TODO kicking/inviting non-ghost users users
}
func (mx *MatrixHandler) HandleRoomMetadata(ctx context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil || portal.IsPrivateChat() {
return
}
metaPortal, ok := portal.(MetaHandlingPortal)
if !ok {
return
}
metaPortal.HandleMatrixMeta(user, evt)
}
func (mx *MatrixHandler) shouldIgnoreEvent(evt *event.Event) bool {
if evt.Sender == mx.bridge.Bot.UserID || mx.bridge.Child.IsGhost(evt.Sender) {
return true
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil || user.GetPermissionLevel() <= 0 {
return true
} else if val, ok := evt.Content.Raw[appservice.DoublePuppetKey]; ok && val == mx.bridge.Name && user.GetIDoublePuppet() != nil {
return true
}
return false
}
const initialSessionWaitTimeout = 3 * time.Second
const extendedSessionWaitTimeout = 22 * time.Second
func (mx *MatrixHandler) sendCryptoStatusError(ctx context.Context, evt *event.Event, editEvent id.EventID, err error, retryCount int, isFinal bool) id.EventID {
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepDecrypted, err, isFinal, retryCount)
if mx.bridge.Config.Bridge.EnableMessageStatusEvents() {
statusEvent := &event.BeeperMessageStatusEventContent{
// TODO: network
RelatesTo: event.RelatesTo{
Type: event.RelReference,
EventID: evt.ID,
},
Status: event.MessageStatusRetriable,
Reason: event.MessageStatusUndecryptable,
Error: err.Error(),
Message: errorToHumanMessage(err),
}
if !isFinal {
statusEvent.Status = event.MessageStatusPending
}
_, sendErr := mx.bridge.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, statusEvent)
if sendErr != nil {
zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to send message status event")
}
}
if mx.bridge.Config.Bridge.EnableMessageErrorNotices() {
update := event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("\u26a0 Your message was not bridged: %v.", err),
}
if errors.Is(err, errNoCrypto) {
update.Body = "🔒 This bridge has not been configured to support encryption"
}
relatable, ok := evt.Content.Parsed.(event.Relatable)
if editEvent != "" {
update.SetEdit(editEvent)
} else if ok && relatable.OptionalGetRelatesTo().GetThreadParent() != "" {
update.GetRelatesTo().SetThread(relatable.OptionalGetRelatesTo().GetThreadParent(), evt.ID)
}
resp, sendErr := mx.bridge.Bot.SendMessageEvent(ctx, evt.RoomID, event.EventMessage, &update)
if sendErr != nil {
zerolog.Ctx(ctx).Error().Err(sendErr).Msg("Failed to send decryption error notice")
} else if resp != nil {
return resp.EventID
}
}
return ""
}
var (
errDeviceNotTrusted = errors.New("your device is not trusted")
errMessageNotEncrypted = errors.New("unencrypted message")
errNoDecryptionKeys = errors.New("the bridge hasn't received the decryption keys")
errNoCrypto = errors.New("this bridge has not been configured to support encryption")
)
func errorToHumanMessage(err error) string {
var withheld *event.RoomKeyWithheldEventContent
switch {
case errors.Is(err, errDeviceNotTrusted), errors.Is(err, errNoDecryptionKeys):
return err.Error()
case errors.Is(err, UnknownMessageIndex):
return "the keys received by the bridge can't decrypt the message"
case errors.Is(err, DuplicateMessageIndex):
return "your client encrypted multiple messages with the same key"
case errors.As(err, &withheld):
if withheld.Code == event.RoomKeyWithheldBeeperRedacted {
return "your client used an outdated encryption session"
}
return "your client refused to share decryption keys with the bridge"
case errors.Is(err, errMessageNotEncrypted):
return "the message is not encrypted"
default:
return "the bridge failed to decrypt the message"
}
}
func deviceUnverifiedErrorWithExplanation(trust id.TrustState) error {
var explanation string
switch trust {
case id.TrustStateBlacklisted:
explanation = "device is blacklisted"
case id.TrustStateUnset:
explanation = "unverified"
case id.TrustStateUnknownDevice:
explanation = "device info not found"
case id.TrustStateForwarded:
explanation = "keys were forwarded from an unknown device"
case id.TrustStateCrossSignedUntrusted:
explanation = "cross-signing keys changed after setting up the bridge"
default:
return errDeviceNotTrusted
}
return fmt.Errorf("%w (%s)", errDeviceNotTrusted, explanation)
}
func copySomeKeys(original, decrypted *event.Event) {
isScheduled, _ := original.Content.Raw["com.beeper.scheduled"].(bool)
_, alreadyExists := decrypted.Content.Raw["com.beeper.scheduled"]
if isScheduled && !alreadyExists {
decrypted.Content.Raw["com.beeper.scheduled"] = true
}
}
func (mx *MatrixHandler) postDecrypt(ctx context.Context, original, decrypted *event.Event, retryCount int, errorEventID id.EventID, duration time.Duration) {
log := zerolog.Ctx(ctx)
minLevel := mx.bridge.Config.Bridge.GetEncryptionConfig().VerificationLevels.Send
if decrypted.Mautrix.TrustState < minLevel {
logEvt := log.Warn().
Str("user_id", decrypted.Sender.String()).
Bool("forwarded_keys", decrypted.Mautrix.ForwardedKeys).
Stringer("device_trust", decrypted.Mautrix.TrustState).
Stringer("min_trust", minLevel)
if decrypted.Mautrix.TrustSource != nil {
dev := decrypted.Mautrix.TrustSource
logEvt.
Str("device_id", dev.DeviceID.String()).
Str("device_signing_key", dev.SigningKey.String())
} else {
logEvt.Str("device_id", "unknown")
}
logEvt.Msg("Dropping event due to insufficient verification level")
err := deviceUnverifiedErrorWithExplanation(decrypted.Mautrix.TrustState)
go mx.sendCryptoStatusError(ctx, decrypted, errorEventID, err, retryCount, true)
return
}
copySomeKeys(original, decrypted)
mx.bridge.SendMessageSuccessCheckpoint(decrypted, status.MsgStepDecrypted, retryCount)
decrypted.Mautrix.CheckpointSent = true
decrypted.Mautrix.DecryptionDuration = duration
decrypted.Mautrix.EventSource |= event.SourceDecrypted
mx.bridge.EventProcessor.Dispatch(ctx, decrypted)
if errorEventID != "" {
_, _ = mx.bridge.Bot.RedactEvent(ctx, decrypted.RoomID, errorEventID)
}
}
func (mx *MatrixHandler) HandleEncrypted(ctx context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
}
content := evt.Content.AsEncrypted()
log := zerolog.Ctx(ctx).With().
Str("event_id", evt.ID.String()).
Str("session_id", content.SessionID.String()).
Logger()
ctx = log.WithContext(ctx)
if mx.bridge.Crypto == nil {
go mx.sendCryptoStatusError(ctx, evt, "", errNoCrypto, 0, true)
return
}
log.Debug().Msg("Decrypting received event")
decryptionStart := time.Now()
decrypted, err := mx.bridge.Crypto.Decrypt(ctx, evt)
decryptionRetryCount := 0
if errors.Is(err, NoSessionFound) {
decryptionRetryCount = 1
log.Debug().
Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())).
Msg("Couldn't find session, waiting for keys to arrive...")
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepDecrypted, err, false, 0)
if mx.bridge.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) {
log.Debug().Msg("Got keys after waiting, trying to decrypt event again")
decrypted, err = mx.bridge.Crypto.Decrypt(ctx, evt)
} else {
go mx.waitLongerForSession(ctx, evt, decryptionStart)
return
}
}
if err != nil {
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepDecrypted, err, true, decryptionRetryCount)
log.Warn().Err(err).Msg("Failed to decrypt event")
go mx.sendCryptoStatusError(ctx, evt, "", err, decryptionRetryCount, true)
return
}
mx.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, "", time.Since(decryptionStart))
}
func (mx *MatrixHandler) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time) {
log := zerolog.Ctx(ctx)
content := evt.Content.AsEncrypted()
log.Debug().
Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).
Msg("Couldn't find session, requesting keys and waiting longer...")
go mx.bridge.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
errorEventID := mx.sendCryptoStatusError(ctx, evt, "", fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), 1, false)
if !mx.bridge.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) {
log.Debug().Msg("Didn't get session, giving up trying to decrypt event")
mx.sendCryptoStatusError(ctx, evt, errorEventID, errNoDecryptionKeys, 2, true)
return
}
log.Debug().Msg("Got keys after waiting longer, trying to decrypt event again")
decrypted, err := mx.bridge.Crypto.Decrypt(ctx, evt)
if err != nil {
log.Error().Err(err).Msg("Failed to decrypt event")
mx.sendCryptoStatusError(ctx, evt, errorEventID, err, 2, true)
return
}
mx.postDecrypt(ctx, evt, decrypted, 2, errorEventID, time.Since(decryptionStart))
}
func (mx *MatrixHandler) HandleMessage(ctx context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
log := zerolog.Ctx(ctx).With().
Str("event_id", evt.ID.String()).
Str("room_id", evt.RoomID.String()).
Str("sender", evt.Sender.String()).
Logger()
ctx = log.WithContext(ctx)
if mx.shouldIgnoreEvent(evt) {
return
} else if !evt.Mautrix.WasEncrypted && mx.bridge.Config.Bridge.GetEncryptionConfig().Require {
log.Warn().Msg("Dropping unencrypted event")
mx.sendCryptoStatusError(ctx, evt, "", errMessageNotEncrypted, 0, true)
return
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil {
return
}
content := evt.Content.AsMessage()
content.RemoveReplyFallback()
if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser && content.MsgType == event.MsgText {
commandPrefix := mx.bridge.Config.Bridge.GetCommandPrefix()
hasCommandPrefix := strings.HasPrefix(content.Body, commandPrefix)
if hasCommandPrefix {
content.Body = strings.TrimLeft(strings.TrimPrefix(content.Body, commandPrefix), " ")
}
if hasCommandPrefix || evt.RoomID == user.GetManagementRoomID() {
go mx.bridge.CommandProcessor.Handle(ctx, evt.RoomID, evt.ID, user, content.Body, content.RelatesTo.GetReplyTo())
go mx.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepCommand, 0)
if mx.bridge.Config.Bridge.EnableMessageStatusEvents() {
statusEvent := &event.BeeperMessageStatusEventContent{
// TODO: network
RelatesTo: event.RelatesTo{
Type: event.RelReference,
EventID: evt.ID,
},
Status: event.MessageStatusSuccess,
}
_, sendErr := mx.bridge.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, statusEvent)
if sendErr != nil {
log.Warn().Err(sendErr).Msg("Failed to send message status event for command")
}
}
return
}
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal != nil {
portal.ReceiveMatrixEvent(user, evt)
} else {
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, fmt.Errorf("unknown room"), true, 0)
}
}
func (mx *MatrixHandler) HandleReaction(_ context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil || user.GetPermissionLevel() < bridgeconfig.PermissionLevelUser || !user.IsLoggedIn() {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal != nil {
portal.ReceiveMatrixEvent(user, evt)
} else {
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, fmt.Errorf("unknown room"), true, 0)
}
}
func (mx *MatrixHandler) HandleRedaction(_ context.Context, evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
}
user := mx.bridge.Child.GetIUser(evt.Sender, true)
if user == nil {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal != nil {
portal.ReceiveMatrixEvent(user, evt)
} else {
mx.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, fmt.Errorf("unknown room"), true, 0)
}
}
func (mx *MatrixHandler) HandleReceipt(_ context.Context, evt *event.Event) {
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil {
return
}
rrPortal, ok := portal.(ReadReceiptHandlingPortal)
if !ok {
return
}
for eventID, receipts := range *evt.Content.AsReceipt() {
for userID, receipt := range receipts[event.ReceiptTypeRead] {
user := mx.bridge.Child.GetIUser(userID, false)
if user == nil {
// Not a bridge user
continue
}
customPuppet := user.GetIDoublePuppet()
if val, ok := receipt.Extra[appservice.DoublePuppetKey].(string); ok && customPuppet != nil && val == mx.bridge.Name {
// Ignore double puppeted read receipts.
mx.log.Debug().Interface("content", evt.Content.Raw).Msg("Ignoring double-puppeted read receipt")
// But do start disappearing messages, because the user read the chat
dp, ok := portal.(DisappearingPortal)
if ok {
dp.ScheduleDisappearing()
}
} else {
rrPortal.HandleMatrixReadReceipt(user, eventID, receipt)
}
}
}
}
func (mx *MatrixHandler) HandleTyping(_ context.Context, evt *event.Event) {
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil {
return
}
typingPortal, ok := portal.(TypingPortal)
if !ok {
return
}
typingPortal.HandleMatrixTyping(evt.Content.AsTyping().UserIDs)
}
func (mx *MatrixHandler) HandlePowerLevels(_ context.Context, evt *event.Event) {
if mx.shouldIgnoreEvent(evt) {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil {
return
}
powerLevelPortal, ok := portal.(PowerLevelHandlingPortal)
if ok {
user := mx.bridge.Child.GetIUser(evt.Sender, true)
powerLevelPortal.HandleMatrixPowerLevels(user, evt)
}
}
func (mx *MatrixHandler) HandleJoinRule(_ context.Context, evt *event.Event) {
if mx.shouldIgnoreEvent(evt) {
return
}
portal := mx.bridge.Child.GetIPortal(evt.RoomID)
if portal == nil {
return
}
joinRulePortal, ok := portal.(JoinRuleHandlingPortal)
if ok {
user := mx.bridge.Child.GetIUser(evt.Sender, true)
joinRulePortal.HandleMatrixJoinRule(user, evt)
}
}

View file

@ -0,0 +1,61 @@
// Copyright (c) 2021 Sumner Evans
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package bridge
import (
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
)
func (br *Bridge) SendMessageSuccessCheckpoint(evt *event.Event, step status.MessageCheckpointStep, retryNum int) {
br.SendMessageCheckpoint(evt, step, nil, status.MsgStatusSuccess, retryNum)
}
func (br *Bridge) SendMessageErrorCheckpoint(evt *event.Event, step status.MessageCheckpointStep, err error, permanent bool, retryNum int) {
s := status.MsgStatusWillRetry
if permanent {
s = status.MsgStatusPermFailure
}
br.SendMessageCheckpoint(evt, step, err, s, retryNum)
}
func (br *Bridge) SendMessageCheckpoint(evt *event.Event, step status.MessageCheckpointStep, err error, s status.MessageCheckpointStatus, retryNum int) {
checkpoint := status.NewMessageCheckpoint(evt, step, s, retryNum)
if err != nil {
checkpoint.Info = err.Error()
}
go br.SendRawMessageCheckpoint(checkpoint)
}
func (br *Bridge) SendRawMessageCheckpoint(cp *status.MessageCheckpoint) {
err := br.SendMessageCheckpoints([]*status.MessageCheckpoint{cp})
if err != nil {
br.ZLog.Warn().Err(err).Interface("message_checkpoint", cp).Msg("Error sending message checkpoint")
} else {
br.ZLog.Debug().Interface("message_checkpoint", cp).Msg("Sent message checkpoint")
}
}
func (br *Bridge) SendMessageCheckpoints(checkpoints []*status.MessageCheckpoint) error {
checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints}
if br.Websocket {
return br.AS.SendWebsocket(&appservice.WebsocketRequest{
Command: "message_checkpoint",
Data: checkpointsJSON,
})
}
endpoint := br.Config.Homeserver.MessageSendCheckpointEndpoint
if endpoint == "" {
return nil
}
return checkpointsJSON.SendHTTP(endpoint, br.AS.Registration.AppToken)
}

26
bridge/no-crypto.go Normal file
View file

@ -0,0 +1,26 @@
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build !cgo || nocrypto
package bridge
import (
"errors"
)
func NewCryptoHelper(bridge *Bridge) Crypto {
if bridge.Config.Bridge.GetEncryptionConfig().Allow {
bridge.ZLog.Warn().Msg("Bridge built without end-to-bridge encryption, but encryption is enabled in config")
} else {
bridge.ZLog.Debug().Msg("Bridge built without end-to-bridge encryption")
}
return nil
}
var NoSessionFound = errors.New("nil")
var UnknownMessageIndex = NoSessionFound
var DuplicateMessageIndex = NoSessionFound

View file

@ -12,17 +12,16 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"reflect"
"time"
"github.com/tidwall/sjson"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"golang.org/x/exp/maps"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -54,42 +53,12 @@ const (
StateLoggedOut BridgeStateEvent = "LOGGED_OUT"
)
func (e BridgeStateEvent) IsValid() bool {
switch e {
case
StateStarting,
StateUnconfigured,
StateRunning,
StateBridgeUnreachable,
StateConnecting,
StateBackfilling,
StateConnected,
StateTransientDisconnect,
StateBadCredentials,
StateUnknownError,
StateLoggedOut:
return true
default:
return false
}
}
type BridgeStateUserAction string
const (
UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE"
UserActionRelogin BridgeStateUserAction = "RELOGIN"
UserActionRestart BridgeStateUserAction = "RESTART"
)
type RemoteProfile struct {
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
Avatar id.ContentURIString `json:"avatar,omitempty"`
AvatarFile *event.EncryptedFileInfo `json:"avatar_file,omitempty"`
}
func coalesce[T ~string](a, b T) T {
@ -105,14 +74,11 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile {
other.Username = coalesce(rp.Username, other.Username)
other.Name = coalesce(rp.Name, other.Name)
other.Avatar = coalesce(rp.Avatar, other.Avatar)
if rp.AvatarFile != nil {
other.AvatarFile = rp.AvatarFile
}
return other
}
func (rp *RemoteProfile) IsZero() bool {
return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil)
func (rp *RemoteProfile) IsEmpty() bool {
return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "")
}
type BridgeState struct {
@ -124,12 +90,10 @@ type BridgeState struct {
Error BridgeStateErrorCode `json:"error,omitempty"`
Message string `json:"message,omitempty"`
UserAction BridgeStateUserAction `json:"user_action,omitempty"`
UserID id.UserID `json:"user_id,omitempty"`
RemoteID networkid.UserLoginID `json:"remote_id,omitempty"`
RemoteName string `json:"remote_name,omitempty"`
RemoteProfile RemoteProfile `json:"remote_profile,omitzero"`
UserID id.UserID `json:"user_id,omitempty"`
RemoteID string `json:"remote_id,omitempty"`
RemoteName string `json:"remote_name,omitempty"`
RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"`
Reason string `json:"reason,omitempty"`
Info map[string]interface{} `json:"info,omitempty"`
@ -141,15 +105,31 @@ type GlobalBridgeState struct {
}
type BridgeStateFiller interface {
GetMXID() id.UserID
GetRemoteID() string
GetRemoteName() string
}
type StandaloneCustomBridgeStateFiller interface {
FillBridgeState(BridgeState) BridgeState
}
// Deprecated: use BridgeStateFiller instead
type StandaloneCustomBridgeStateFiller = BridgeStateFiller
type CustomBridgeStateFiller interface {
BridgeStateFiller
StandaloneCustomBridgeStateFiller
}
func (pong BridgeState) Fill(user BridgeStateFiller) BridgeState {
func (pong BridgeState) Fill(user any) BridgeState {
if user != nil {
pong = user.FillBridgeState(pong)
if std, ok := user.(BridgeStateFiller); ok {
pong.UserID = std.GetMXID()
pong.RemoteID = std.GetRemoteID()
pong.RemoteName = std.GetRemoteName()
}
if custom, ok := user.(StandaloneCustomBridgeStateFiller); ok {
pong = custom.FillBridgeState(pong)
}
}
pong.Timestamp = jsontime.UnixNow()
@ -208,8 +188,7 @@ func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool {
return pong != nil &&
pong.StateEvent == newPong.StateEvent &&
pong.RemoteName == newPong.RemoteName &&
pong.UserAction == newPong.UserAction &&
pong.RemoteProfile == newPong.RemoteProfile &&
ptr.Val(pong.RemoteProfile) == ptr.Val(newPong.RemoteProfile) &&
pong.Error == newPong.Error &&
maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) &&
pong.Timestamp.Add(time.Duration(pong.TTL)*time.Second).After(time.Now())

View file

@ -169,13 +169,13 @@ type CheckpointsJSON struct {
Checkpoints []*MessageCheckpoint `json:"checkpoints"`
}
func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, endpoint string, token string) error {
func (cj *CheckpointsJSON) SendHTTP(endpoint string, token string) error {
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(cj); err != nil {
return fmt.Errorf("failed to encode message checkpoint JSON: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
if err != nil {
@ -186,10 +186,7 @@ func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, endpo
req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (checkpoint sender)")
req.Header.Set("Content-Type", "application/json")
if cli == nil {
cli = http.DefaultClient
}
resp, err := cli.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return mautrix.HTTPError{
Request: req,

163
bridge/websocket.go Normal file
View file

@ -0,0 +1,163 @@
package bridge
import (
"context"
"errors"
"fmt"
"sync"
"time"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/appservice"
)
const defaultReconnectBackoff = 2 * time.Second
const maxReconnectBackoff = 2 * time.Minute
const reconnectBackoffReset = 5 * time.Minute
func (br *Bridge) startWebsocket(wg *sync.WaitGroup) {
log := br.ZLog.With().Str("action", "appservice websocket").Logger()
var wgOnce sync.Once
onConnect := func() {
wssBr, ok := br.Child.(WebsocketStartingBridge)
if ok {
wssBr.OnWebsocketConnect()
}
if br.latestState != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
br.latestState.Timestamp = jsontime.UnixNow()
err := br.SendBridgeState(ctx, br.latestState)
if err != nil {
log.Err(err).Msg("Failed to resend latest bridge state after websocket reconnect")
} else {
log.Debug().Any("bridge_state", br.latestState).Msg("Resent bridge state after websocket reconnect")
}
}()
}
wgOnce.Do(wg.Done)
select {
case br.wsStarted <- struct{}{}:
default:
}
}
reconnectBackoff := defaultReconnectBackoff
lastDisconnect := time.Now().UnixNano()
br.wsStopped = make(chan struct{})
defer func() {
log.Debug().Msg("Appservice websocket loop finished")
close(br.wsStopped)
}()
addr := br.Config.Homeserver.WSProxy
if addr == "" {
addr = br.Config.Homeserver.Address
}
for {
err := br.AS.StartWebsocket(addr, onConnect)
if errors.Is(err, appservice.ErrWebsocketManualStop) {
return
} else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced {
log.Info().Msg("Appservice websocket closed by another instance of the bridge, shutting down...")
br.ManualStop(0)
return
} else if err != nil {
log.Err(err).Msg("Error in appservice websocket")
}
if br.Stopping {
return
}
now := time.Now().UnixNano()
if lastDisconnect+reconnectBackoffReset.Nanoseconds() < now {
reconnectBackoff = defaultReconnectBackoff
} else {
reconnectBackoff *= 2
if reconnectBackoff > maxReconnectBackoff {
reconnectBackoff = maxReconnectBackoff
}
}
lastDisconnect = now
log.Info().
Int("backoff_seconds", int(reconnectBackoff.Seconds())).
Msg("Websocket disconnected, reconnecting...")
select {
case <-br.wsShortCircuitReconnectBackoff:
log.Debug().Msg("Reconnect backoff was short-circuited")
case <-time.After(reconnectBackoff):
}
if br.Stopping {
return
}
}
}
type wsPingData struct {
Timestamp int64 `json:"timestamp"`
}
func (br *Bridge) PingServer() (start, serverTs, end time.Time) {
if !br.Websocket {
panic(fmt.Errorf("PingServer called without websocket enabled"))
}
if !br.AS.HasWebsocket() {
br.ZLog.Debug().Msg("Received server ping request, but no websocket connected. Trying to short-circuit backoff sleep")
select {
case br.wsShortCircuitReconnectBackoff <- struct{}{}:
default:
br.ZLog.Warn().Msg("Failed to ping websocket: not connected and no backoff?")
return
}
select {
case <-br.wsStarted:
case <-time.After(15 * time.Second):
if !br.AS.HasWebsocket() {
br.ZLog.Warn().Msg("Failed to ping websocket: didn't connect after 15 seconds of waiting")
return
}
}
}
start = time.Now()
var resp wsPingData
br.ZLog.Debug().Msg("Pinging appservice websocket")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := br.AS.RequestWebsocket(ctx, &appservice.WebsocketRequest{
Command: "ping",
Data: &wsPingData{Timestamp: start.UnixMilli()},
}, &resp)
end = time.Now()
if err != nil {
br.ZLog.Warn().Err(err).Dur("duration", end.Sub(start)).Msg("Websocket ping returned error")
br.AS.StopWebsocket(fmt.Errorf("websocket ping returned error in %s: %w", end.Sub(start), err))
} else {
serverTs = time.Unix(0, resp.Timestamp*int64(time.Millisecond))
br.ZLog.Debug().
Dur("duration", end.Sub(start)).
Dur("req_duration", serverTs.Sub(start)).
Dur("resp_duration", end.Sub(serverTs)).
Msg("Websocket ping returned success")
}
return
}
func (br *Bridge) websocketServerPinger() {
interval := time.Duration(br.Config.Homeserver.WSPingInterval) * time.Second
clock := time.NewTicker(interval)
defer func() {
br.ZLog.Info().Msg("Stopping websocket pinger")
clock.Stop()
}()
br.ZLog.Info().Dur("interval_duration", interval).Msg("Starting websocket pinger")
for {
select {
case <-clock.C:
br.PingServer()
case <-br.wsStopPinger:
return
}
if br.Stopping {
return
}
}
}

View file

@ -38,10 +38,8 @@ func (br *Bridge) RunBackfillQueue() {
return
}
ctx, cancel := context.WithCancel(log.WithContext(context.Background()))
br.stopBackfillQueue.Clear()
stopChan := br.stopBackfillQueue.GetChan()
go func() {
<-stopChan
<-br.stopBackfillQueue
cancel()
}()
batchDelay := time.Duration(br.Config.Backfill.Queue.BatchDelay) * time.Second
@ -63,7 +61,7 @@ func (br *Bridge) RunBackfillQueue() {
}
}
noTasksFoundCount = 0
case <-stopChan:
case <-br.stopBackfillQueue:
if !timer.Stop() {
select {
case <-timer.C:
@ -80,13 +78,13 @@ func (br *Bridge) RunBackfillQueue() {
time.Sleep(BackfillQueueErrorBackoff)
continue
} else if backfillTask != nil {
br.DoBackfillTask(ctx, backfillTask)
br.doBackfillTask(ctx, backfillTask)
noTasksFoundCount = 0
}
}
}
func (br *Bridge) DoBackfillTask(ctx context.Context, task *database.BackfillTask) {
func (br *Bridge) doBackfillTask(ctx context.Context, task *database.BackfillTask) {
log := zerolog.Ctx(ctx).With().
Object("portal_key", task.PortalKey).
Str("login_id", string(task.UserLoginID)).

View file

@ -9,20 +9,15 @@ package bridgev2
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/exsync"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
)
@ -50,17 +45,8 @@ type Bridge struct {
ghostsByID map[networkid.UserID]*Ghost
cacheLock sync.Mutex
didSplitPortals bool
Background bool
ExternallyManagedDB bool
stopping atomic.Bool
wakeupBackfillQueue chan struct{}
stopBackfillQueue *exsync.Event
BackgroundCtx context.Context
cancelBackgroundCtx context.CancelFunc
stopBackfillQueue chan struct{}
}
func NewBridge(
@ -88,7 +74,7 @@ func NewBridge(
ghostsByID: make(map[networkid.UserID]*Ghost),
wakeupBackfillQueue: make(chan struct{}),
stopBackfillQueue: exsync.NewEvent(),
stopBackfillQueue: make(chan struct{}),
}
if br.Config == nil {
br.Config = &bridgeconfig.BridgeConfig{CommandPrefix: "!bridge"}
@ -114,89 +100,29 @@ func (e DBUpgradeError) Unwrap() error {
return e.Err
}
func (br *Bridge) Start(ctx context.Context) error {
ctx = br.Log.WithContext(ctx)
err := br.StartConnectors(ctx)
func (br *Bridge) Start() error {
err := br.StartConnectors()
if err != nil {
return err
}
err = br.StartLogins(ctx)
err = br.StartLogins()
if err != nil {
return err
}
go br.PostStart(ctx)
return nil
}
func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, params *ConnectBackgroundParams) error {
br.Background = true
br.stopping.Store(false)
err := br.StartConnectors(ctx)
if err != nil {
return err
}
if loginID == "" {
br.Log.Info().Msg("No login ID provided to RunOnce, running all logins for 20 seconds")
err = br.StartLogins(ctx)
if err != nil {
return err
}
defer br.StopWithTimeout(5 * time.Second)
select {
case <-time.After(20 * time.Second):
case <-ctx.Done():
}
return nil
}
defer br.stop(true, 5*time.Second)
login, err := br.GetExistingUserLoginByID(ctx, loginID)
if err != nil {
return fmt.Errorf("failed to get user login: %w", err)
} else if login == nil {
return ErrNotLoggedIn
}
syncClient, ok := login.Client.(BackgroundSyncingNetworkAPI)
if !ok {
br.Log.Warn().Msg("Network connector doesn't implement background mode, using fallback mechanism for RunOnce")
login.Client.Connect(ctx)
defer login.DisconnectWithTimeout(5 * time.Second)
select {
case <-time.After(20 * time.Second):
case <-ctx.Done():
}
br.stopping.Store(true)
return nil
} else {
br.Log.Info().Str("user_login_id", string(login.ID)).Msg("Starting individual user login in background mode")
return syncClient.ConnectBackground(login.Log.WithContext(ctx), params)
}
}
func (br *Bridge) StartConnectors(ctx context.Context) error {
func (br *Bridge) StartConnectors() error {
br.Log.Info().Msg("Starting bridge")
br.stopping.Store(false)
if br.BackgroundCtx == nil || br.BackgroundCtx.Err() != nil {
br.BackgroundCtx, br.cancelBackgroundCtx = context.WithCancel(context.Background())
br.BackgroundCtx = br.Log.WithContext(br.BackgroundCtx)
}
ctx := br.Log.WithContext(context.Background())
if !br.ExternallyManagedDB {
err := br.DB.Upgrade(ctx)
if err != nil {
return DBUpgradeError{Err: err, Section: "main"}
}
}
if !br.Background {
var postMigrate func()
br.didSplitPortals, postMigrate = br.MigrateToSplitPortals(ctx)
if postMigrate != nil {
defer postMigrate()
}
err := br.DB.Upgrade(ctx)
if err != nil {
return DBUpgradeError{Err: err, Section: "main"}
}
didSplitPortals := br.MigrateToSplitPortals(ctx)
br.Log.Info().Msg("Starting Matrix connector")
err := br.Matrix.Start(ctx)
err = br.Matrix.Start(ctx)
if err != nil {
return fmt.Errorf("failed to start Matrix connector: %w", err)
}
@ -205,40 +131,16 @@ func (br *Bridge) StartConnectors(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to start network connector: %w", err)
}
if br.Network.GetCapabilities().DisappearingMessages && !br.Background {
if br.Network.GetCapabilities().DisappearingMessages {
go br.DisappearLoop.Start()
}
if didSplitPortals || br.Config.ResendBridgeInfo {
br.ResendBridgeInfo(ctx)
}
return nil
}
func (br *Bridge) PostStart(ctx context.Context) {
if br.Background {
return
}
rawBridgeInfoVer := br.DB.KV.Get(ctx, database.KeyBridgeInfoVersion)
bridgeInfoVer, capVer, err := parseBridgeInfoVersion(rawBridgeInfoVer)
if err != nil {
br.Log.Err(err).Str("db_bridge_info_version", rawBridgeInfoVer).Msg("Failed to parse bridge info version")
return
}
expectedBridgeInfoVer, expectedCapVer := br.Network.GetBridgeInfoVersion()
doResendBridgeInfo := bridgeInfoVer != expectedBridgeInfoVer || br.didSplitPortals || br.Config.ResendBridgeInfo
doResendCapabilities := capVer != expectedCapVer || br.didSplitPortals
if doResendBridgeInfo || doResendCapabilities {
br.ResendBridgeInfo(ctx, doResendBridgeInfo, doResendCapabilities)
}
br.DB.KV.Set(ctx, database.KeyBridgeInfoVersion, fmt.Sprintf("%d,%d", expectedBridgeInfoVer, expectedCapVer))
}
func parseBridgeInfoVersion(version string) (info, capabilities int, err error) {
_, err = fmt.Sscanf(version, "%d,%d", &info, &capabilities)
if version == "" {
err = nil
}
return
}
func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps bool) {
func (br *Bridge) ResendBridgeInfo(ctx context.Context) {
log := zerolog.Ctx(ctx).With().Str("action", "resend bridge info").Logger()
portals, err := br.GetAllPortalsWithMXID(ctx)
if err != nil {
@ -246,103 +148,30 @@ func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps b
return
}
for _, portal := range portals {
if resendInfo {
portal.UpdateBridgeInfo(ctx)
}
if resendCaps {
logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey)
if err != nil {
log.Err(err).
Stringer("room_id", portal.MXID).
Object("portal_key", portal.PortalKey).
Msg("Failed to get user logins in portal")
} else {
found := false
for _, login := range logins {
if portal.CapState.ID == "" || login.ID == portal.CapState.Source {
portal.UpdateCapabilities(ctx, login, true)
found = true
}
}
if !found && len(logins) > 0 {
portal.CapState.Source = ""
portal.UpdateCapabilities(ctx, logins[0], true)
} else if !found {
log.Warn().
Stringer("room_id", portal.MXID).
Object("portal_key", portal.PortalKey).
Msg("No user login found to update capabilities")
}
}
}
portal.UpdateBridgeInfo(ctx)
}
log.Info().
Bool("capabilities", resendCaps).
Bool("info", resendInfo).
Msg("Resent bridge info to all portals")
log.Info().Msg("Resent bridge info to all portals")
}
func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func()) {
func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool {
log := zerolog.Ctx(ctx).With().Str("action", "migrate to split portals").Logger()
ctx = log.WithContext(ctx)
if !br.Config.SplitPortals || br.DB.KV.Get(ctx, database.KeySplitPortalsEnabled) == "true" {
return false, nil
return false
}
affected, err := br.DB.Portal.MigrateToSplitPortals(ctx)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to migrate portals")
os.Exit(31)
return false, nil
log.Err(err).Msg("Failed to migrate portals")
return false
}
log.Info().Int64("rows_affected", affected).Msg("Migrated to split portals")
affected2, err := br.DB.Portal.FixParentsAfterSplitPortalMigration(ctx)
if err != nil {
log.Err(err).Msg("Failed to fix parent portals after split portal migration")
os.Exit(31)
return false, nil
}
log.Info().Int64("rows_affected", affected2).Msg("Updated parent receivers after split portal migration")
withoutReceiver, err := br.DB.Portal.GetAllWithoutReceiver(ctx)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get portals that failed to migrate")
os.Exit(31)
return false, nil
}
var roomsToDelete []id.RoomID
log.Info().Int("remaining_portals", len(withoutReceiver)).Msg("Deleting remaining portals without receiver")
for _, portal := range withoutReceiver {
if err = br.DB.Portal.Delete(ctx, portal.PortalKey); err != nil {
log.Err(err).
Str("portal_id", string(portal.ID)).
Stringer("mxid", portal.MXID).
Msg("Failed to delete portal database row that failed to migrate")
} else if portal.MXID != "" {
log.Debug().
Str("portal_id", string(portal.ID)).
Stringer("mxid", portal.MXID).
Msg("Marked portal room for deletion from homeserver")
roomsToDelete = append(roomsToDelete, portal.MXID)
} else {
log.Debug().
Str("portal_id", string(portal.ID)).
Msg("Deleted portal row with no Matrix room")
}
}
br.DB.KV.Set(ctx, database.KeySplitPortalsEnabled, "true")
log.Info().Msg("Finished split portal migration successfully")
return affected > 0, func() {
for _, roomID := range roomsToDelete {
if err = br.Bot.DeleteRoom(ctx, roomID, true); err != nil {
log.Err(err).
Stringer("mxid", roomID).
Msg("Failed to delete portal room that failed to migrate")
}
}
log.Info().Int("room_count", len(roomsToDelete)).Msg("Finished deleting rooms that failed to migrate")
}
return affected > 0
}
func (br *Bridge) StartLogins(ctx context.Context) error {
func (br *Bridge) StartLogins() error {
ctx := br.Log.WithContext(context.Background())
userIDs, err := br.DB.UserLogin.GetAllUserIDsWithLogins(ctx)
if err != nil {
return fmt.Errorf("failed to get users with logins: %w", err)
@ -366,93 +195,30 @@ func (br *Bridge) StartLogins(ctx context.Context) error {
br.Log.Info().Msg("No user logins found")
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured})
}
if !br.Background {
go br.RunBackfillQueue()
}
go br.RunBackfillQueue()
br.Log.Info().Msg("Bridge started")
return nil
}
func (br *Bridge) ResetNetworkConnections() {
nrn, ok := br.Network.(NetworkResettingNetwork)
if ok {
br.Log.Info().Msg("Resetting network connections with NetworkConnector.ResetNetworkConnections")
nrn.ResetNetworkConnections()
return
}
br.Log.Info().Msg("Network connector doesn't support ResetNetworkConnections, recreating clients manually")
for _, login := range br.GetAllCachedUserLogins() {
login.Log.Debug().Msg("Disconnecting and recreating client for network reset")
ctx := login.Log.WithContext(br.BackgroundCtx)
login.Client.Disconnect()
err := login.recreateClient(ctx)
if err != nil {
login.Log.Err(err).Msg("Failed to recreate client during network reset")
login.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: "bridgev2-network-reset-fail",
Info: map[string]any{"go_error": err.Error()},
})
} else {
login.Client.Connect(ctx)
}
}
br.Log.Info().Msg("Finished resetting all user logins")
}
func (br *Bridge) GetHTTPClientSettings() exhttp.ClientSettings {
mchs, ok := br.Matrix.(MatrixConnectorWithHTTPSettings)
if ok {
return mchs.GetHTTPClientSettings()
}
return exhttp.SensibleClientSettings
}
func (br *Bridge) IsStopping() bool {
return br.stopping.Load()
}
func (br *Bridge) Stop() {
br.stop(false, 0)
}
func (br *Bridge) StopWithTimeout(timeout time.Duration) {
br.stop(false, timeout)
}
func (br *Bridge) stop(isRunOnce bool, timeout time.Duration) {
br.Log.Info().Msg("Shutting down bridge")
br.stopping.Store(true)
br.DisappearLoop.Stop()
br.stopBackfillQueue.Set()
br.Matrix.PreStop()
if !isRunOnce {
br.cacheLock.Lock()
var wg sync.WaitGroup
wg.Add(len(br.userLoginsByID))
for _, login := range br.userLoginsByID {
go func() {
login.DisconnectWithTimeout(timeout)
wg.Done()
}()
}
br.cacheLock.Unlock()
wg.Wait()
}
close(br.stopBackfillQueue)
br.Matrix.Stop()
if br.cancelBackgroundCtx != nil {
br.cancelBackgroundCtx()
br.cacheLock.Lock()
var wg sync.WaitGroup
wg.Add(len(br.userLoginsByID))
for _, login := range br.userLoginsByID {
go login.Disconnect(wg.Done)
}
wg.Wait()
br.cacheLock.Unlock()
if stopNet, ok := br.Network.(StoppableNetwork); ok {
stopNet.Stop()
}
if !br.ExternallyManagedDB {
err := br.DB.Close()
if err != nil {
br.Log.Warn().Err(err).Msg("Failed to close database")
}
err := br.DB.Close()
if err != nil {
br.Log.Warn().Err(err).Msg("Failed to close database")
}
br.Log.Info().Msg("Shutdown complete")
}

View file

@ -34,6 +34,7 @@ type AppserviceConfig struct {
EphemeralEvents bool `yaml:"ephemeral_events"`
AsyncTransactions bool `yaml:"async_transactions"`
MSC4190 bool `yaml:"msc4190"`
UsernameTemplate string `yaml:"username_template"`
usernameTemplate *template.Template `yaml:"-"`
@ -77,11 +78,7 @@ func (asc *AppserviceConfig) copyToRegistration(registration *appservice.Registr
registration.RateLimited = &falseVal
registration.EphemeralEvents = asc.EphemeralEvents
registration.SoruEphemeralEvents = asc.EphemeralEvents
}
func (ec *EncryptionConfig) applyUnstableFlags(registration *appservice.Registration) {
registration.MSC4190 = ec.MSC4190
registration.MSC3202 = ec.Appservice
registration.MSC4190 = asc.MSC4190
}
// GenerateRegistration generates a registration file for the homeserver.
@ -90,7 +87,6 @@ func (config *Config) GenerateRegistration() *appservice.Registration {
config.AppService.HSToken = registration.ServerToken
config.AppService.ASToken = registration.AppToken
config.AppService.copyToRegistration(registration)
config.Encryption.applyUnstableFlags(registration)
registration.SenderLocalpart = random.String(32)
botRegex := regexp.MustCompile(fmt.Sprintf("^@%s:%s$",
@ -109,7 +105,6 @@ func (config *Config) MakeAppService() *appservice.AppService {
as.Host.Hostname = config.AppService.Hostname
as.Host.Port = config.AppService.Port
as.Registration = config.AppService.GetRegistration()
config.Encryption.applyUnstableFlags(as.Registration)
return as
}

View file

@ -14,11 +14,6 @@ type BackfillConfig struct {
Threads BackfillThreadsConfig `yaml:"threads"`
Queue BackfillQueueConfig `yaml:"queue"`
// Flag to indicate that the creator will not run the backfill queue but will still paginate
// backfill by calling DoBackfillTask directly. Note that this is not used anywhere within
// mautrix-go and exists so bridges can use it to decide when to drop backfill data.
WillPaginateManually bool `yaml:"will_paginate_manually"`
}
type BackfillThreadsConfig struct {
@ -34,12 +29,10 @@ type BackfillQueueConfig struct {
MaxBatchesOverride map[string]int `yaml:"max_batches_override"`
}
func (bqc *BackfillQueueConfig) GetOverride(names ...string) int {
for _, name := range names {
override, ok := bqc.MaxBatchesOverride[name]
if ok {
return override
}
func (bqc *BackfillQueueConfig) GetOverride(name string) int {
override, ok := bqc.MaxBatchesOverride[name]
if !ok {
return bqc.MaxBatches
}
return bqc.MaxBatches
return override
}

View file

@ -7,8 +7,6 @@
package bridgeconfig
import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/zeroconfig"
"gopkg.in/yaml.v3"
@ -33,8 +31,6 @@ type Config struct {
Encryption EncryptionConfig `yaml:"encryption"`
Logging zeroconfig.Config `yaml:"logging"`
EnvConfigPrefix string `yaml:"env_config_prefix"`
ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"`
}
@ -62,40 +58,30 @@ type CleanupOnLogouts struct {
}
type BridgeConfig struct {
CommandPrefix string `yaml:"command_prefix"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
AsyncEvents bool `yaml:"async_events"`
SplitPortals bool `yaml:"split_portals"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"`
BridgeStatusNotices string `yaml:"bridge_status_notices"`
UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"`
UnknownErrorMaxAutoReconnects int `yaml:"unknown_error_max_auto_reconnects"`
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
BridgeNotices bool `yaml:"bridge_notices"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"`
MuteOnlyOnCreate bool `yaml:"mute_only_on_create"`
DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"`
CrossRoomReplies bool `yaml:"cross_room_replies"`
OutgoingMessageReID bool `yaml:"outgoing_message_re_id"`
RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"`
KickMatrixUsers bool `yaml:"kick_matrix_users"`
CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"`
Relay RelayConfig `yaml:"relay"`
Permissions PermissionConfig `yaml:"permissions"`
Backfill BackfillConfig `yaml:"backfill"`
CommandPrefix string `yaml:"command_prefix"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
AsyncEvents bool `yaml:"async_events"`
SplitPortals bool `yaml:"split_portals"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"`
MuteOnlyOnCreate bool `yaml:"mute_only_on_create"`
OutgoingMessageReID bool `yaml:"outgoing_message_re_id"`
CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"`
Relay RelayConfig `yaml:"relay"`
Permissions PermissionConfig `yaml:"permissions"`
Backfill BackfillConfig `yaml:"backfill"`
}
type MatrixConfig struct {
MessageStatusEvents bool `yaml:"message_status_events"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageErrorNotices bool `yaml:"message_error_notices"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
FederateRooms bool `yaml:"federate_rooms"`
UploadFileThreshold int64 `yaml:"upload_file_threshold"`
GhostExtraProfileInfo bool `yaml:"ghost_extra_profile_info"`
MessageStatusEvents bool `yaml:"message_status_events"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageErrorNotices bool `yaml:"message_error_notices"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
FederateRooms bool `yaml:"federate_rooms"`
UploadFileThreshold int64 `yaml:"upload_file_threshold"`
}
type AnalyticsConfig struct {
@ -105,9 +91,9 @@ type AnalyticsConfig struct {
}
type ProvisioningConfig struct {
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
EnableSessionTransfers bool `yaml:"enable_session_transfers"`
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
}
type DirectMediaConfig struct {
@ -117,12 +103,10 @@ type DirectMediaConfig struct {
}
type PublicMediaConfig struct {
Enabled bool `yaml:"enabled"`
SigningKey string `yaml:"signing_key"`
Expiry int `yaml:"expiry"`
HashLength int `yaml:"hash_length"`
PathPrefix string `yaml:"path_prefix"`
UseDatabase bool `yaml:"use_database"`
Enabled bool `yaml:"enabled"`
SigningKey string `yaml:"signing_key"`
HashLength int `yaml:"hash_length"`
Expiry int `yaml:"expiry"`
}
type DoublePuppetConfig struct {

View file

@ -15,9 +15,6 @@ type EncryptionConfig struct {
Default bool `yaml:"default"`
Require bool `yaml:"require"`
Appservice bool `yaml:"appservice"`
MSC4190 bool `yaml:"msc4190"`
MSC4392 bool `yaml:"msc4392"`
SelfSign bool `yaml:"self_sign"`
PlaintextMentions bool `yaml:"plaintext_mentions"`

View file

@ -133,7 +133,9 @@ func doMigrateLegacy(helper up.Helper, python bool) {
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chat_list"}, []string{"matrix", "sync_direct_chat_list"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "federate_rooms"}, []string{"matrix", "federate_rooms"})
CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "prefix"}, []string{"provisioning", "prefix"})
CopyToOtherLocation(helper, up.Str, []string{"bridge", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"})
CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "prefix"}, []string{"provisioning", "prefix"})
CopyToOtherLocation(helper, up.Str, []string{"appservice", "provisioning", "shared_secret"}, []string{"provisioning", "shared_secret"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "provisioning", "debug_endpoints"}, []string{"provisioning", "debug_endpoints"})

View file

@ -24,7 +24,6 @@ type Permissions struct {
DoublePuppet bool `yaml:"double_puppet"`
Admin bool `yaml:"admin"`
ManageRelay bool `yaml:"manage_relay"`
MaxLogins int `yaml:"max_logins"`
}
type PermissionConfig map[string]*Permissions
@ -41,7 +40,10 @@ func (pc PermissionConfig) IsConfigured() bool {
_, hasExampleDomain := pc["example.com"]
_, hasExampleUser := pc["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
return len(pc) > exampleLen
if len(pc) <= exampleLen {
return false
}
return true
}
func (pc PermissionConfig) Get(userID id.UserID) Permissions {

View file

@ -30,19 +30,10 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Bool, "bridge", "async_events")
helper.Copy(up.Bool, "bridge", "split_portals")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key")
helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices")
helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect")
helper.Copy(up.Int, "bridge", "unknown_error_max_auto_reconnects")
helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
helper.Copy(up.Bool, "bridge", "bridge_notices")
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
helper.Copy(up.List, "bridge", "only_bridge_tags")
helper.Copy(up.Bool, "bridge", "mute_only_on_create")
helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages")
helper.Copy(up.Bool, "bridge", "cross_room_replies")
helper.Copy(up.Bool, "bridge", "revert_failed_state_changes")
helper.Copy(up.Bool, "bridge", "kick_matrix_users")
helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed")
@ -91,6 +82,7 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Str, "appservice", "bot", "avatar")
helper.Copy(up.Bool, "appservice", "ephemeral_events")
helper.Copy(up.Bool, "appservice", "async_transactions")
helper.Copy(up.Bool, "appservice", "msc4190")
helper.Copy(up.Str, "appservice", "as_token")
helper.Copy(up.Str, "appservice", "hs_token")
helper.Copy(up.Str, "appservice", "username_template")
@ -101,12 +93,12 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Bool, "matrix", "sync_direct_chat_list")
helper.Copy(up.Bool, "matrix", "federate_rooms")
helper.Copy(up.Int, "matrix", "upload_file_threshold")
helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info")
helper.Copy(up.Str|up.Null, "analytics", "token")
helper.Copy(up.Str|up.Null, "analytics", "url")
helper.Copy(up.Str|up.Null, "analytics", "user_id")
helper.Copy(up.Str, "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "provisioning", "shared_secret")
@ -114,7 +106,6 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Str, "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "provisioning", "debug_endpoints")
helper.Copy(up.Bool, "provisioning", "enable_session_transfers")
helper.Copy(up.Bool, "direct_media", "enabled")
helper.Copy(up.Str|up.Null, "direct_media", "media_id_prefix")
@ -136,8 +127,6 @@ func doUpgrade(helper up.Helper) {
}
helper.Copy(up.Int, "public_media", "expiry")
helper.Copy(up.Int, "public_media", "hash_length")
helper.Copy(up.Str|up.Null, "public_media", "path_prefix")
helper.Copy(up.Bool, "public_media", "use_database")
helper.Copy(up.Bool, "backfill", "enabled")
helper.Copy(up.Int, "backfill", "max_initial_messages")
@ -158,13 +147,6 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Bool, "encryption", "default")
helper.Copy(up.Bool, "encryption", "require")
helper.Copy(up.Bool, "encryption", "appservice")
if val, ok := helper.Get(up.Bool, "appservice", "msc4190"); ok {
helper.Set(up.Bool, val, "encryption", "msc4190")
} else {
helper.Copy(up.Bool, "encryption", "msc4190")
}
helper.Copy(up.Bool, "encryption", "msc4392")
helper.Copy(up.Bool, "encryption", "self_sign")
helper.Copy(up.Bool, "encryption", "allow_key_sharing")
if secret, ok := helper.Get(up.Str, "encryption", "pickle_key"); !ok || secret == "generate" {
helper.Set(up.Str, random.String(64), "encryption", "pickle_key")
@ -187,8 +169,6 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Int, "encryption", "rotation", "messages")
helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Str|up.Null, "env_config_prefix")
helper.Copy(up.Map, "logging")
}
@ -216,7 +196,6 @@ var SpacedBlocks = [][]string{
{"backfill"},
{"double_puppet"},
{"encryption"},
{"env_config_prefix"},
{"logging"},
}

View file

@ -8,37 +8,20 @@ package bridgev2
import (
"context"
"fmt"
"math/rand/v2"
"runtime/debug"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exfmt"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/bridge/status"
)
var CatchBridgeStateQueuePanics = true
type BridgeStateQueue struct {
prevUnsent *status.BridgeState
prevSent *status.BridgeState
errorSent bool
ch chan status.BridgeState
bridge *Bridge
login *UserLogin
firstTransientDisconnect time.Time
cancelScheduledNotice atomic.Pointer[context.CancelFunc]
stopChan chan struct{}
stopReconnect atomic.Pointer[context.CancelFunc]
unknownErrorReconnects int
user status.StandaloneCustomBridgeStateFiller
}
func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
@ -58,221 +41,51 @@ func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
}
}
func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue {
func (br *Bridge) NewBridgeStateQueue(user status.StandaloneCustomBridgeStateFiller) *BridgeStateQueue {
bsq := &BridgeStateQueue{
ch: make(chan status.BridgeState, 10),
stopChan: make(chan struct{}),
bridge: br,
login: login,
ch: make(chan status.BridgeState, 10),
bridge: br,
user: user,
}
go bsq.loop()
return bsq
}
func (bsq *BridgeStateQueue) Destroy() {
close(bsq.stopChan)
close(bsq.ch)
bsq.StopUnknownErrorReconnect()
}
func (bsq *BridgeStateQueue) StopUnknownErrorReconnect() {
if bsq == nil {
return
}
if cancelFn := bsq.stopReconnect.Swap(nil); cancelFn != nil {
(*cancelFn)()
}
if cancelFn := bsq.cancelScheduledNotice.Swap(nil); cancelFn != nil {
(*cancelFn)()
}
}
func (bsq *BridgeStateQueue) loop() {
if CatchBridgeStateQueuePanics {
defer func() {
err := recover()
if err != nil {
bsq.login.Log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Any(zerolog.ErrorFieldName, err).
Msg("Panic in bridge state loop")
}
}()
}
defer func() {
err := recover()
if err != nil {
bsq.bridge.Log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Any(zerolog.ErrorFieldName, err).
Msg("Panic in bridge state loop")
}
}()
for state := range bsq.ch {
bsq.immediateSendBridgeState(state)
}
}
func (bsq *BridgeStateQueue) scheduleNotice(triggeredBy status.BridgeState) {
log := bsq.login.Log.With().Str("action", "transient disconnect notice").Logger()
ctx := log.WithContext(bsq.bridge.BackgroundCtx)
if !bsq.waitForTransientDisconnectReconnect(ctx) {
return
}
prevUnsent := bsq.GetPrevUnsent()
prev := bsq.GetPrev()
if triggeredBy.Timestamp != prev.Timestamp || len(bsq.ch) > 0 || bsq.errorSent ||
prevUnsent.StateEvent != status.StateTransientDisconnect || prev.StateEvent != status.StateTransientDisconnect {
log.Trace().Any("triggered_by", triggeredBy).Msg("Not sending delayed transient disconnect notice")
return
}
log.Debug().Any("triggered_by", triggeredBy).Msg("Sending delayed transient disconnect notice")
bsq.sendNotice(ctx, triggeredBy, true)
}
func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.BridgeState, isDelayed bool) {
noticeConfig := bsq.bridge.Config.BridgeStatusNotices
isError := state.StateEvent == status.StateBadCredentials ||
state.StateEvent == status.StateUnknownError ||
state.UserAction == status.UserActionOpenNative ||
(isDelayed && state.StateEvent == status.StateTransientDisconnect)
sendNotice := noticeConfig == "all" || (noticeConfig == "errors" &&
(isError || (bsq.errorSent && state.StateEvent == status.StateConnected)))
if state.StateEvent != status.StateTransientDisconnect && state.StateEvent != status.StateUnknownError {
bsq.firstTransientDisconnect = time.Time{}
}
if !sendNotice {
if !bsq.errorSent && !isDelayed && noticeConfig == "errors" && state.StateEvent == status.StateTransientDisconnect {
if bsq.firstTransientDisconnect.IsZero() {
bsq.firstTransientDisconnect = time.Now()
}
go bsq.scheduleNotice(state)
}
return
}
managementRoom, err := bsq.login.User.GetManagementRoom(ctx)
if err != nil {
bsq.login.Log.Err(err).Msg("Failed to get management room")
return
}
name := bsq.login.RemoteName
if name == "" {
name = fmt.Sprintf("`%s`", bsq.login.ID)
}
message := fmt.Sprintf("State update for %s: `%s`", name, state.StateEvent)
if state.Error != "" {
message += fmt.Sprintf(" (`%s`)", state.Error)
}
if isDelayed {
message += fmt.Sprintf(" not resolved after waiting %s", exfmt.Duration(TransientDisconnectNoticeDelay))
}
if state.Message != "" {
message += fmt.Sprintf(": %s", state.Message)
}
content := format.RenderMarkdown(message, true, false)
if !isError {
content.MsgType = event.MsgNotice
}
_, err = bsq.bridge.Bot.SendMessage(ctx, managementRoom, event.EventMessage, &event.Content{
Parsed: content,
Raw: map[string]any{
"fi.mau.bridge_state": state,
},
}, nil)
if err != nil {
bsq.login.Log.Err(err).Msg("Failed to send bridge state notice")
} else {
bsq.errorSent = isError
}
}
func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeState) {
log := bsq.login.Log.With().Str("action", "unknown error reconnect").Logger()
ctx := log.WithContext(bsq.bridge.BackgroundCtx)
if !bsq.waitForUnknownErrorReconnect(ctx) {
return
}
prevUnsent := bsq.GetPrevUnsent()
prev := bsq.GetPrev()
if triggeredBy.Timestamp != prev.Timestamp {
log.Debug().Msg("Not reconnecting as a new bridge state was sent after the unknown error")
return
} else if len(bsq.ch) > 0 {
log.Warn().Msg("Not reconnecting as there are unsent bridge states")
return
} else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError {
log.Debug().Msg("Not reconnecting as the previous state was not an unknown error")
return
} else if bsq.unknownErrorReconnects > bsq.bridge.Config.UnknownErrorMaxAutoReconnects {
log.Warn().Msg("Not reconnecting as the maximum number of unknown error reconnects has been reached")
return
}
bsq.unknownErrorReconnects++
log.Info().
Int("reconnect_num", bsq.unknownErrorReconnects).
Msg("Disconnecting and reconnecting login due to unknown error")
bsq.login.Disconnect()
log.Debug().Msg("Disconnection finished, recreating client and reconnecting")
err := bsq.login.recreateClient(ctx)
if err != nil {
log.Err(err).Msg("Failed to recreate client after unknown error")
return
}
bsq.login.Client.Connect(ctx)
log.Debug().Msg("Reconnection finished")
}
func (bsq *BridgeStateQueue) waitForUnknownErrorReconnect(ctx context.Context) bool {
reconnectIn := bsq.bridge.Config.UnknownErrorAutoReconnect
// Don't allow too low values
if reconnectIn < 1*time.Minute {
return false
}
reconnectIn += time.Duration(rand.Int64N(int64(float64(reconnectIn)*0.4)) - int64(float64(reconnectIn)*0.2))
return bsq.waitForReconnect(ctx, reconnectIn, &bsq.stopReconnect)
}
const TransientDisconnectNoticeDelay = 3 * time.Minute
func (bsq *BridgeStateQueue) waitForTransientDisconnectReconnect(ctx context.Context) bool {
timeUntilSchedule := time.Until(bsq.firstTransientDisconnect.Add(TransientDisconnectNoticeDelay))
zerolog.Ctx(ctx).Trace().
Stringer("duration", timeUntilSchedule).
Msg("Waiting before sending notice about transient disconnect")
return bsq.waitForReconnect(ctx, timeUntilSchedule, &bsq.cancelScheduledNotice)
}
func (bsq *BridgeStateQueue) waitForReconnect(
ctx context.Context, reconnectIn time.Duration, ptr *atomic.Pointer[context.CancelFunc],
) bool {
cancelCtx, cancel := context.WithCancel(ctx)
defer cancel()
if oldCancel := ptr.Swap(&cancel); oldCancel != nil {
(*oldCancel)()
}
select {
case <-time.After(reconnectIn):
return ptr.CompareAndSwap(&cancel, nil)
case <-cancelCtx.Done():
return false
case <-bsq.stopChan:
return false
}
}
func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState) {
if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) {
bsq.login.Log.Debug().
Str("state_event", string(state.StateEvent)).
Msg("Not sending bridge state as it's a duplicate")
return
}
if state.StateEvent == status.StateUnknownError {
go bsq.unknownErrorReconnect(state)
}
ctx := bsq.login.Log.WithContext(context.Background())
bsq.sendNotice(ctx, state, false)
retryIn := 2
for {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
if bsq.prevSent != nil && bsq.prevSent.ShouldDeduplicate(&state) {
bsq.bridge.Log.Debug().
Str("state_event", string(state.StateEvent)).
Msg("Not sending bridge state as it's a duplicate")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := bsq.bridge.Matrix.SendBridgeStatus(ctx, &state)
cancel()
if err != nil {
bsq.login.Log.Warn().Err(err).
bsq.bridge.Log.Warn().Err(err).
Int("retry_in_seconds", retryIn).
Msg("Failed to update bridge state")
time.Sleep(time.Duration(retryIn) * time.Second)
@ -282,7 +95,7 @@ func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState)
}
} else {
bsq.prevSent = &state
bsq.login.Log.Debug().
bsq.bridge.Log.Debug().
Any("bridge_state", state).
Msg("Sent new bridge state")
return
@ -295,11 +108,11 @@ func (bsq *BridgeStateQueue) Send(state status.BridgeState) {
return
}
state = state.Fill(bsq.login)
state = state.Fill(bsq.user)
bsq.prevUnsent = &state
if len(bsq.ch) >= 8 {
bsq.login.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item")
bsq.bridge.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item")
select {
case <-bsq.ch:
default:
@ -308,7 +121,7 @@ func (bsq *BridgeStateQueue) Send(state status.BridgeState) {
select {
case bsq.ch <- state:
default:
bsq.login.Log.Error().Msg("Bridge state queue is full, dropped new state")
bsq.bridge.Log.Error().Msg("Bridge state queue is full, dropped new state")
}
}

View file

@ -55,43 +55,3 @@ var CommandDeleteAllPortals = &FullHandler{
},
RequiresAdmin: true,
}
var CommandSetManagementRoom = &FullHandler{
Func: func(ce *Event) {
if ce.User.ManagementRoom == ce.RoomID {
ce.Reply("This room is already your management room")
return
} else if ce.Portal != nil {
ce.Reply("This is a portal room: you can't set this as your management room")
return
}
members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID)
if err != nil {
ce.Log.Err(err).Msg("Failed to get room members to check if room can be a management room")
ce.Reply("Failed to get room members")
return
}
_, hasBot := members[ce.Bot.GetMXID()]
if !hasBot {
// This reply will probably fail, but whatever
ce.Reply("The bridge bot must be in the room to set it as your management room")
return
} else if len(members) != 2 {
ce.Reply("Your management room must not have any members other than you and the bridge bot")
return
}
ce.User.ManagementRoom = ce.RoomID
err = ce.User.Save(ce.Ctx)
if err != nil {
ce.Log.Err(err).Msg("Failed to save management room")
ce.Reply("Failed to save management room")
} else {
ce.Reply("Management room updated")
}
},
Name: "set-management-room",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Mark this room as your management room",
},
}

View file

@ -7,13 +7,10 @@
package commands
import (
"encoding/json"
"strings"
"time"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
)
var CommandRegisterPush = &FullHandler{
@ -60,66 +57,4 @@ var CommandRegisterPush = &FullHandler{
},
RequiresAdmin: true,
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.PushableNetworkAPI],
}
var CommandSendAccountData = &FullHandler{
Func: func(ce *Event) {
if len(ce.Args) < 2 {
ce.Reply("Usage: `$cmdprefix debug-account-data <type> <content>")
return
}
var content event.Content
evtType := event.Type{Type: ce.Args[0], Class: event.AccountDataEventType}
ce.RawArgs = strings.TrimSpace(strings.Trim(ce.RawArgs, ce.Args[0]))
err := json.Unmarshal([]byte(ce.RawArgs), &content)
if err != nil {
ce.Reply("Failed to parse JSON: %v", err)
return
}
err = content.ParseRaw(evtType)
if err != nil {
ce.Reply("Failed to deserialize content: %v", err)
return
}
res := ce.Bridge.QueueMatrixEvent(ce.Ctx, &event.Event{
Sender: ce.User.MXID,
Type: evtType,
Timestamp: time.Now().UnixMilli(),
RoomID: ce.RoomID,
Content: content,
})
ce.Reply("Result: %+v", res)
},
Name: "debug-account-data",
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Send a room account data event to the bridge",
Args: "<_type_> <_content_>",
},
RequiresAdmin: true,
RequiresPortal: true,
RequiresLogin: true,
}
var CommandResetNetwork = &FullHandler{
Func: func(ce *Event) {
if strings.Contains(strings.ToLower(ce.RawArgs), "--reset-transport") {
nrn, ok := ce.Bridge.Network.(bridgev2.NetworkResettingNetwork)
if ok {
nrn.ResetHTTPTransport()
} else {
ce.Reply("Network connector does not support resetting HTTP transport")
}
}
ce.Bridge.ResetNetworkConnections()
ce.React("✅️")
},
Name: "debug-reset-network",
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Reset network connections to the remote network",
Args: "[--reset-transport]",
},
RequiresAdmin: true,
}

View file

@ -10,7 +10,6 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
@ -93,8 +92,9 @@ func (ce *Event) Redact(req ...mautrix.ReqRedact) {
// MarkRead marks the command event as read.
func (ce *Event) MarkRead() {
err := ce.Bot.MarkRead(ce.Ctx, ce.RoomID, ce.EventID, time.Now())
if err != nil {
ce.Log.Err(err).Msg("Failed to mark command as read")
}
// TODO
//err := ce.Bot.SendReceipt(ce.Ctx, ce.RoomID, ce.EventID, event.ReceiptTypeRead, nil)
//if err != nil {
// ce.Log.Err(err).Msg("Failed to mark command as read")
//}
}

View file

@ -7,7 +7,6 @@
package commands
import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
)
@ -38,18 +37,6 @@ type AliasedCommandHandler interface {
GetAliases() []string
}
func NetworkAPIImplements[T bridgev2.NetworkAPI](val bridgev2.NetworkAPI) bool {
_, ok := val.(T)
return ok
}
func NetworkConnectorImplements[T bridgev2.NetworkConnector](val bridgev2.NetworkConnector) bool {
_, ok := val.(T)
return ok
}
type ImplementationChecker[T any] func(val T) bool
type FullHandler struct {
Func func(*Event)
@ -62,9 +49,6 @@ type FullHandler struct {
RequiresLogin bool
RequiresEventLevel event.Type
RequiresLoginPermission bool
NetworkAPI ImplementationChecker[bridgev2.NetworkAPI]
NetworkConnector ImplementationChecker[bridgev2.NetworkConnector]
}
func (fh *FullHandler) GetHelp() HelpMeta {
@ -80,15 +64,9 @@ func (fh *FullHandler) GetAliases() []string {
return fh.Aliases
}
func (fh *FullHandler) ImplementationsFulfilled(ce *Event) bool {
// TODO add dedicated method to get an empty NetworkAPI instead of getting default login
client := ce.User.GetDefaultLogin()
return (fh.NetworkAPI == nil || client == nil || fh.NetworkAPI(client.Client)) &&
(fh.NetworkConnector == nil || fh.NetworkConnector(ce.Bridge.Network))
}
func (fh *FullHandler) ShowInHelp(ce *Event) bool {
return fh.ImplementationsFulfilled(ce) && (!fh.RequiresAdmin || ce.User.Permissions.Admin)
return true
//return !fh.RequiresAdmin || ce.User.GetPermissionLevel() >= bridgeconfig.PermissionLevelAdmin
}
func (fh *FullHandler) userHasRoomPermission(ce *Event) bool {

View file

@ -19,9 +19,9 @@ import (
"github.com/skip2/go-qrcode"
"go.mau.fi/util/curl"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -70,15 +70,6 @@ func fnLogin(ce *Event) {
}
ce.Args = ce.Args[1:]
}
if reauth == nil && ce.User.HasTooManyLogins() {
ce.Reply(
"You have reached the maximum number of logins (%d). "+
"Please logout from an existing login before creating a new one. "+
"If you want to re-authenticate an existing login, use the `$cmdprefix relogin` command.",
ce.User.Permissions.MaxLogins,
)
return
}
flows := ce.Bridge.Network.GetLoginFlows()
var chosenFlowID string
if len(ce.Args) > 0 {
@ -121,7 +112,6 @@ func fnLogin(ce *Event) {
ce.Reply("Failed to start login: %v", err)
return
}
ce.Log.Debug().Any("first_step", nextStep).Msg("Created login process")
nextStep = checkLoginCommandDirectParams(ce, login, nextStep)
if nextStep != nil {
@ -200,14 +190,11 @@ type userInputLoginCommandState struct {
func (uilcs *userInputLoginCommandState) promptNext(ce *Event) {
field := uilcs.RemainingFields[0]
parts := []string{fmt.Sprintf("Please enter your %s", field.Name)}
if field.Description != "" {
parts = append(parts, field.Description)
ce.Reply("Please enter your %s\n%s", field.Name, field.Description)
} else {
ce.Reply("Please enter your %s", field.Name)
}
if len(field.Options) > 0 {
parts = append(parts, fmt.Sprintf("Options: `%s`", strings.Join(field.Options, "`, `")))
}
ce.Reply(strings.Join(parts, "\n"))
StoreCommandState(ce.User, &CommandState{
Next: MinimalCommandHandlerFunc(uilcs.submitNext),
Action: "Login",
@ -252,19 +239,14 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error {
return fmt.Errorf("failed to upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
FileName: "qr.png",
URL: qrMXC,
File: qrFile,
MsgType: event.MsgImage,
FileName: "qr.png",
URL: qrMXC,
File: qrFile,
Body: qr,
Format: event.FormatHTML,
FormattedBody: fmt.Sprintf("<pre><code>%s</code></pre>", html.EscapeString(qr)),
Info: &event.FileInfo{
MimeType: "image/png",
Width: qrSizePx,
Height: qrSizePx,
Size: len(qrData),
},
}
if *prevEventID != "" {
content.SetEdit(*prevEventID)
@ -279,36 +261,6 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error {
return nil
}
func sendUserInputAttachments(ce *Event, atts []*bridgev2.LoginUserInputAttachment) error {
for _, att := range atts {
if att.FileName == "" {
return fmt.Errorf("missing attachment filename")
}
mxc, file, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, att.Content, att.FileName, att.Info.MimeType)
if err != nil {
return fmt.Errorf("failed to upload attachment %q: %w", att.FileName, err)
}
content := &event.MessageEventContent{
MsgType: att.Type,
FileName: att.FileName,
URL: mxc,
File: file,
Info: &event.FileInfo{
MimeType: att.Info.MimeType,
Width: att.Info.Width,
Height: att.Info.Height,
Size: att.Info.Size,
},
Body: att.FileName,
}
_, err = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
if err != nil {
return nil
}
}
return nil
}
type contextKey int
const (
@ -321,13 +273,6 @@ func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait,
prevEvent = new(id.EventID)
ce.Ctx = context.WithValue(ce.Ctx, contextKeyPrevEventID, prevEvent)
}
cancelCtx, cancelFunc := context.WithCancel(ce.Ctx)
defer cancelFunc()
StoreCommandState(ce.User, &CommandState{
Action: "Login",
Cancel: cancelFunc,
})
defer StoreCommandState(ce.User, nil)
switch step.DisplayAndWaitParams.Type {
case bridgev2.LoginDisplayTypeQR:
err := sendQR(ce, step.DisplayAndWaitParams.Data, prevEvent)
@ -347,7 +292,7 @@ func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait,
login.Cancel()
return
}
nextStep, err := login.Wait(cancelCtx)
nextStep, err := login.Wait(ce.Ctx)
// Redact the QR code, unless the next step is refreshing the code (in which case the event is just edited)
if *prevEvent != "" && (nextStep == nil || nextStep.StepID != step.StepID) {
_, _ = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventRedaction, &event.Content{
@ -500,7 +445,6 @@ func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string {
}
func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginStep, override *bridgev2.UserLogin) {
ce.Log.Debug().Any("next_step", step).Msg("Got next login step")
if step.Instructions != "" {
ce.Reply(step.Instructions)
}
@ -515,10 +459,6 @@ func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginSte
Override: override,
}).prompt(ce)
case bridgev2.LoginStepTypeUserInput:
err := sendUserInputAttachments(ce, step.UserInputParams.Attachments)
if err != nil {
ce.Reply("Failed to send attachments: %v", err)
}
(&userInputLoginCommandState{
Login: login.(bridgev2.LoginProcessUserInput),
RemainingFields: step.UserInputParams.Fields,

View file

@ -17,7 +17,8 @@ import (
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -41,11 +42,10 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor {
}
proc.AddHandlers(
CommandHelp, CommandCancel,
CommandRegisterPush, CommandSendAccountData, CommandResetNetwork,
CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom,
CommandRegisterPush, CommandDeletePortal, CommandDeleteAllPortals,
CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin,
CommandSetRelay, CommandUnsetRelay,
CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandMute,
CommandResolveIdentifier, CommandStartChat, CommandSearch,
CommandSudo, CommandDoIn,
)
return proc

View file

@ -37,7 +37,7 @@ func fnSetRelay(ce *Event) {
}
onlySetDefaultRelays := !ce.User.Permissions.Admin && ce.Bridge.Config.Relay.AdminOnly
var relay *bridgev2.UserLogin
if len(ce.Args) == 0 && ce.Portal.Receiver == "" {
if len(ce.Args) == 0 {
relay = ce.User.GetDefaultLogin()
isLoggedIn := relay != nil
if onlySetDefaultRelays {
@ -73,19 +73,9 @@ func fnSetRelay(ce *Event) {
}
}
} else {
var targetID networkid.UserLoginID
if ce.Portal.Receiver != "" {
targetID = ce.Portal.Receiver
if len(ce.Args) > 0 && ce.Args[0] != string(targetID) {
ce.Reply("In split portals, only the receiver (%s) can be set as relay", targetID)
return
}
} else {
targetID = networkid.UserLoginID(ce.Args[0])
}
relay = ce.Bridge.GetCachedUserLoginByID(targetID)
relay = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if relay == nil {
ce.Reply("User login with ID `%s` not found", targetID)
ce.Reply("User login with ID `%s` not found", ce.Args[0])
return
} else if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, relay.ID) {
// All good

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -8,21 +8,13 @@ package commands
import (
"context"
"errors"
"fmt"
"html"
"maps"
"slices"
"strings"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/provisionutil"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
@ -35,36 +27,6 @@ var CommandResolveIdentifier = &FullHandler{
Args: "[_login ID_] <_identifier_>",
},
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI],
}
var CommandSyncChat = &FullHandler{
Func: func(ce *Event) {
login, _, err := ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, false)
if err != nil {
ce.Log.Err(err).Msg("Failed to find login for sync")
ce.Reply("Failed to find login: %v", err)
return
} else if login == nil {
ce.Reply("No login found for sync")
return
}
info, err := login.Client.GetChatInfo(ce.Ctx, ce.Portal)
if err != nil {
ce.Log.Err(err).Msg("Failed to get chat info for sync")
ce.Reply("Failed to get chat info: %v", err)
return
}
ce.Portal.UpdateInfo(ce.Ctx, info, login, nil, time.Time{})
ce.React("✅️")
},
Name: "sync-portal",
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Sync the current portal room",
},
RequiresPortal: true,
RequiresLogin: true,
}
var CommandStartChat = &FullHandler{
@ -77,18 +39,11 @@ var CommandStartChat = &FullHandler{
Args: "[_login ID_] <_identifier_>",
},
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI],
}
func getClientForStartingChat[T bridgev2.NetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) {
var remainingArgs []string
if len(ce.Args) > 1 {
remainingArgs = ce.Args[1:]
}
var login *bridgev2.UserLogin
if len(ce.Args) > 0 {
login = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
}
func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) {
remainingArgs := ce.Args[1:]
login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if login == nil || login.UserMXID != ce.User.MXID {
remainingArgs = ce.Args
login = ce.User.GetDefaultLogin()
@ -100,13 +55,24 @@ func getClientForStartingChat[T bridgev2.NetworkAPI](ce *Event, thing string) (*
return login, api, remainingArgs
}
func formatResolveIdentifierResult(resp *provisionutil.RespResolveIdentifier) string {
if resp.MXID != "" {
return fmt.Sprintf("`%s` / [%s](%s)", resp.ID, resp.Name, resp.MXID.URI().MatrixToURL())
} else if resp.Name != "" {
return fmt.Sprintf("`%s` / %s", resp.ID, resp.Name)
func formatResolveIdentifierResult(ctx context.Context, resp *bridgev2.ResolveIdentifierResponse) string {
var targetName string
var targetMXID id.UserID
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(ctx, resp.UserInfo)
}
targetName = resp.Ghost.Name
targetMXID = resp.Ghost.Intent.GetMXID()
} else if resp.UserInfo != nil && resp.UserInfo.Name != nil {
targetName = *resp.UserInfo.Name
}
if targetMXID != "" {
return fmt.Sprintf("`%s` / [%s](%s)", resp.UserID, targetName, targetMXID.URI().MatrixToURL())
} else if targetName != "" {
return fmt.Sprintf("`%s` / %s", resp.UserID, targetName)
} else {
return fmt.Sprintf("`%s`", resp.ID)
return fmt.Sprintf("`%s`", resp.UserID)
}
}
@ -119,137 +85,65 @@ func fnResolveIdentifier(ce *Event) {
if api == nil {
return
}
allLogins := ce.User.GetUserLogins()
createChat := ce.Command == "start-chat" || ce.Command == "pm"
identifier := strings.Join(identifierParts, " ")
resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, identifier, createChat)
for i := 0; i < len(allLogins) && errors.Is(err, bridgev2.ErrResolveIdentifierTryNext); i++ {
resp, err = provisionutil.ResolveIdentifier(ce.Ctx, allLogins[i], identifier, createChat)
}
resp, err := api.ResolveIdentifier(ce.Ctx, identifier, createChat)
if err != nil {
ce.Log.Err(err).Msg("Failed to resolve identifier")
ce.Reply("Failed to resolve identifier: %v", err)
return
} else if resp == nil {
ce.ReplyAdvanced(fmt.Sprintf("Identifier <code>%s</code> not found", html.EscapeString(identifier)), false, true)
return
}
formattedName := formatResolveIdentifierResult(resp)
formattedName := formatResolveIdentifierResult(ce.Ctx, resp)
if createChat {
name := resp.Portal.Name
if name == "" {
name = resp.Portal.MXID.String()
if resp.Chat == nil {
ce.Reply("Interface error: network connector did not return chat for create chat request")
return
}
if !resp.JustCreated {
ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL())
portal := resp.Chat.Portal
if portal == nil {
portal, err = ce.Bridge.GetPortalByKey(ce.Ctx, resp.Chat.PortalKey)
if err != nil {
ce.Log.Err(err).Msg("Failed to get portal")
ce.Reply("Failed to get portal: %v", err)
return
}
}
if resp.Chat.PortalInfo == nil {
resp.Chat.PortalInfo, err = api.GetChatInfo(ce.Ctx, portal)
if err != nil {
ce.Log.Err(err).Msg("Failed to get portal info")
ce.Reply("Failed to get portal info: %v", err)
return
}
}
if portal.MXID != "" {
name := portal.Name
if name == "" {
name = portal.MXID.String()
}
portal.UpdateInfo(ce.Ctx, resp.Chat.PortalInfo, login, nil, time.Time{})
ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL())
} else {
ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL())
err = portal.CreateMatrixRoom(ce.Ctx, login, resp.Chat.PortalInfo)
if err != nil {
ce.Log.Err(err).Msg("Failed to create room")
ce.Reply("Failed to create room: %v", err)
return
}
name := portal.Name
if name == "" {
name = portal.MXID.String()
}
ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL())
}
} else {
ce.Reply("Found %s", formattedName)
}
}
var CommandCreateGroup = &FullHandler{
Func: fnCreateGroup,
Name: "create-group",
Aliases: []string{"create"},
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Create a new group chat for the current Matrix room",
Args: "[_group type_]",
},
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.GroupCreatingNetworkAPI],
}
func getState[T any](ctx context.Context, roomID id.RoomID, evtType event.Type, provider bridgev2.MatrixConnectorWithArbitraryRoomState) (content T) {
evt, err := provider.GetStateEvent(ctx, roomID, evtType, "")
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("event_type", evtType).Msg("Failed to get state event for group creation")
} else if evt != nil {
content, _ = evt.Content.Parsed.(T)
}
return
}
func fnCreateGroup(ce *Event) {
ce.Bridge.Matrix.GetCapabilities()
login, api, remainingArgs := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating group")
if api == nil {
return
}
stateProvider, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState)
if !ok {
ce.Reply("Matrix connector doesn't support fetching room state")
return
}
members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID)
if err != nil {
ce.Log.Err(err).Msg("Failed to get room members for group creation")
ce.Reply("Failed to get room members: %v", err)
return
}
caps := ce.Bridge.Network.GetCapabilities()
params := &bridgev2.GroupCreateParams{
Username: "",
Participants: make([]networkid.UserID, 0, len(members)-2),
Parent: nil, // TODO check space parent event
Name: getState[*event.RoomNameEventContent](ce.Ctx, ce.RoomID, event.StateRoomName, stateProvider),
Avatar: getState[*event.RoomAvatarEventContent](ce.Ctx, ce.RoomID, event.StateRoomAvatar, stateProvider),
Topic: getState[*event.TopicEventContent](ce.Ctx, ce.RoomID, event.StateTopic, stateProvider),
Disappear: getState[*event.BeeperDisappearingTimer](ce.Ctx, ce.RoomID, event.StateBeeperDisappearingTimer, stateProvider),
RoomID: ce.RoomID,
}
for userID, member := range members {
if userID == ce.User.MXID || userID == ce.Bot.GetMXID() || !member.Membership.IsInviteOrJoin() {
continue
}
if parsedUserID, ok := ce.Bridge.Matrix.ParseGhostMXID(userID); ok {
params.Participants = append(params.Participants, parsedUserID)
} else if !ce.Bridge.Config.SplitPortals {
if user, err := ce.Bridge.GetExistingUserByMXID(ce.Ctx, userID); err != nil {
ce.Log.Err(err).Stringer("user_id", userID).Msg("Failed to get user for room member")
} else if user != nil {
// TODO add user logins to participants
//for _, login := range user.GetUserLogins() {
// params.Participants = append(params.Participants, login.GetUserID())
//}
}
}
}
if len(caps.Provisioning.GroupCreation) == 0 {
ce.Reply("No group creation types defined in network capabilities")
return
} else if len(remainingArgs) > 0 {
params.Type = remainingArgs[0]
} else if len(caps.Provisioning.GroupCreation) == 1 {
for params.Type = range caps.Provisioning.GroupCreation {
// The loop assigns the variable we want
}
} else {
types := strings.Join(slices.Collect(maps.Keys(caps.Provisioning.GroupCreation)), "`, `")
ce.Reply("Please specify type of group to create: `%s`", types)
return
}
resp, err := provisionutil.CreateGroup(ce.Ctx, login, params)
if err != nil {
ce.Reply("Failed to create group: %v", err)
return
}
var postfix string
if len(resp.FailedParticipants) > 0 {
failedParticipantsStrings := make([]string, len(resp.FailedParticipants))
i := 0
for participantID, meta := range resp.FailedParticipants {
failedParticipantsStrings[i] = fmt.Sprintf("* %s: %s", format.SafeMarkdownCode(participantID), meta.Reason)
i++
}
postfix += "\n\nFailed to add some participants:\n" + strings.Join(failedParticipantsStrings, "\n")
}
ce.Reply("Successfully created group `%s`%s", resp.ID, postfix)
}
var CommandSearch = &FullHandler{
Func: fnSearch,
Name: "search",
@ -259,7 +153,6 @@ var CommandSearch = &FullHandler{
Args: "<_query_>",
},
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.UserSearchingNetworkAPI],
}
func fnSearch(ce *Event) {
@ -267,67 +160,35 @@ func fnSearch(ce *Event) {
ce.Reply("Usage: `$cmdprefix search <query>`")
return
}
login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users")
_, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users")
if api == nil {
return
}
resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " "))
results, err := api.SearchUsers(ce.Ctx, strings.Join(queryParts, " "))
if err != nil {
ce.Log.Err(err).Msg("Failed to search for users")
ce.Reply("Failed to search for users: %v", err)
return
}
resultsString := make([]string, len(resp.Results))
for i, res := range resp.Results {
formattedName := formatResolveIdentifierResult(res)
resultsString := make([]string, len(results))
for i, res := range results {
formattedName := formatResolveIdentifierResult(ce.Ctx, res)
resultsString[i] = fmt.Sprintf("* %s", formattedName)
if res.Portal != nil && res.Portal.MXID != "" {
portalName := res.Portal.Name
if portalName == "" {
portalName = res.Portal.MXID.String()
if res.Chat != nil {
if res.Chat.Portal == nil {
res.Chat.Portal, err = ce.Bridge.GetExistingPortalByKey(ce.Ctx, res.Chat.PortalKey)
if err != nil {
ce.Log.Err(err).Object("portal_key", res.Chat.PortalKey).Msg("Failed to get DM portal")
}
}
if res.Chat.Portal != nil && res.Chat.Portal.MXID != "" {
portalName := res.Chat.Portal.Name
if portalName == "" {
portalName = res.Chat.Portal.MXID.String()
}
resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Chat.Portal.MXID.URI().MatrixToURL())
}
resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Portal.MXID.URI().MatrixToURL())
}
}
ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n"))
}
var CommandMute = &FullHandler{
Func: fnMute,
Name: "mute",
Aliases: []string{"unmute"},
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Mute or unmute a chat on the remote network",
Args: "[duration]",
},
RequiresPortal: true,
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.MuteHandlingNetworkAPI],
}
func fnMute(ce *Event) {
_, api, _ := getClientForStartingChat[bridgev2.MuteHandlingNetworkAPI](ce, "muting chats")
var mutedUntil int64
if ce.Command == "mute" {
mutedUntil = -1
if len(ce.Args) > 0 {
duration, err := time.ParseDuration(ce.Args[0])
if err != nil {
ce.Reply("Invalid duration: %v", err)
return
}
mutedUntil = time.Now().Add(duration).UnixMilli()
}
}
err := api.HandleMute(ce.Ctx, &bridgev2.MatrixMute{
MatrixEventBase: bridgev2.MatrixEventBase[*event.BeeperMuteEventContent]{
Content: &event.BeeperMuteEventContent{MutedUntil: mutedUntil},
Portal: ce.Portal,
},
})
if err != nil {
ce.Reply("Failed to %s chat: %v", ce.Command, err)
} else {
ce.React("✅️")
}
}

View file

@ -78,11 +78,6 @@ const (
dispatched_at=$9, completed_at=$10, next_dispatch_min_ts=$11
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3
`
markBackfillTaskNotDoneQuery = `
UPDATE backfill_task
SET is_done = false
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND user_login_id = $4
`
getNextBackfillQuery = `
SELECT
bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done,
@ -91,13 +86,6 @@ const (
WHERE bridge_id = $1 AND next_dispatch_min_ts < $2 AND is_done = false AND user_login_id <> ''
ORDER BY next_dispatch_min_ts LIMIT 1
`
getNextBackfillQueryForPortal = `
SELECT
bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done,
cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts
FROM backfill_task
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND is_done = false AND user_login_id <> ''
`
deleteBackfillQueueQuery = `
DELETE FROM backfill_task
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3
@ -132,18 +120,10 @@ func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) erro
return btq.Exec(ctx, updateBackfillQueueQuery, bq.sqlVariables()...)
}
func (btq *BackfillTaskQuery) MarkNotDone(ctx context.Context, portalKey networkid.PortalKey, userLoginID networkid.UserLoginID) error {
return btq.Exec(ctx, markBackfillTaskNotDoneQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver, userLoginID)
}
func (btq *BackfillTaskQuery) GetNext(ctx context.Context) (*BackfillTask, error) {
return btq.QueryOne(ctx, getNextBackfillQuery, btq.BridgeID, time.Now().UnixNano())
}
func (btq *BackfillTaskQuery) GetNextForPortal(ctx context.Context, portalKey networkid.PortalKey) (*BackfillTask, error) {
return btq.QueryOne(ctx, getNextBackfillQueryForPortal, btq.BridgeID, portalKey.ID, portalKey.Receiver)
}
func (btq *BackfillTaskQuery) Delete(ctx context.Context, portalKey networkid.PortalKey) error {
return btq.Exec(ctx, deleteBackfillQueueQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver)
}

View file

@ -7,7 +7,13 @@
package database
import (
"encoding/json"
"reflect"
"strings"
"go.mau.fi/util/dbutil"
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
"maunium.net/go/mautrix/bridgev2/networkid"
@ -28,7 +34,6 @@ type Database struct {
UserPortal *UserPortalQuery
BackfillTask *BackfillTaskQuery
KV *KVQuery
PublicMedia *PublicMediaQuery
}
type MetaMerger interface {
@ -136,12 +141,6 @@ func New(bridgeID networkid.BridgeID, mt MetaTypes, db *dbutil.Database) *Databa
BridgeID: bridgeID,
Database: db,
},
PublicMedia: &PublicMediaQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*PublicMedia]) *PublicMedia {
return &PublicMedia{}
}),
},
}
}
@ -152,3 +151,55 @@ func ensureBridgeIDMatches(ptr *networkid.BridgeID, expected networkid.BridgeID)
panic("bridge ID mismatch")
}
}
func GetNumberFromMap[T constraints.Integer | constraints.Float](m map[string]any, key string) (T, bool) {
if val, found := m[key]; found {
floatVal, ok := val.(float64)
if ok {
return T(floatVal), true
}
tVal, ok := val.(T)
if ok {
return tVal, true
}
}
return 0, false
}
func unmarshalMerge(input []byte, data any, extra *map[string]any) error {
err := json.Unmarshal(input, data)
if err != nil {
return err
}
err = json.Unmarshal(input, extra)
if err != nil {
return err
}
if *extra == nil {
*extra = make(map[string]any)
}
return nil
}
func marshalMerge(data any, extra map[string]any) ([]byte, error) {
if extra == nil {
return json.Marshal(data)
}
merged := make(map[string]any)
maps.Copy(merged, extra)
dataRef := reflect.ValueOf(data).Elem()
dataType := dataRef.Type()
for _, field := range reflect.VisibleFields(dataType) {
parts := strings.Split(field.Tag.Get("json"), ",")
if len(parts) == 0 || len(parts[0]) == 0 || parts[0] == "-" {
continue
}
fieldVal := dataRef.FieldByIndex(field.Index)
if fieldVal.IsZero() {
delete(merged, parts[0])
} else {
merged[parts[0]] = fieldVal.Interface()
}
}
return json.Marshal(merged)
}

View file

@ -12,94 +12,56 @@ import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// Deprecated: use [event.DisappearingType]
type DisappearingType = event.DisappearingType
// DisappearingType represents the type of a disappearing message timer.
type DisappearingType string
// Deprecated: use constants in event package
const (
DisappearingTypeNone = event.DisappearingTypeNone
DisappearingTypeAfterRead = event.DisappearingTypeAfterRead
DisappearingTypeAfterSend = event.DisappearingTypeAfterSend
DisappearingTypeNone DisappearingType = ""
DisappearingTypeAfterRead DisappearingType = "after_read"
DisappearingTypeAfterSend DisappearingType = "after_send"
)
// DisappearingSetting represents a disappearing message timer setting
// by combining a type with a timer and an optional start timestamp.
type DisappearingSetting struct {
Type event.DisappearingType
Type DisappearingType
Timer time.Duration
DisappearAt time.Time
}
func DisappearingSettingFromEvent(evt *event.BeeperDisappearingTimer) DisappearingSetting {
if evt == nil || evt.Type == event.DisappearingTypeNone {
return DisappearingSetting{}
}
return DisappearingSetting{
Type: evt.Type,
Timer: evt.Timer.Duration,
}
}
func (ds DisappearingSetting) Normalize() DisappearingSetting {
if ds.Type == event.DisappearingTypeNone {
ds.Timer = 0
} else if ds.Timer == 0 {
ds.Type = event.DisappearingTypeNone
}
return ds
}
func (ds DisappearingSetting) StartingAt(start time.Time) DisappearingSetting {
ds.DisappearAt = start.Add(ds.Timer)
return ds
}
func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer {
if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 {
return &event.BeeperDisappearingTimer{}
}
return &event.BeeperDisappearingTimer{
Type: ds.Type,
Timer: jsontime.MS(ds.Timer),
}
}
type DisappearingMessageQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*DisappearingMessage]
}
type DisappearingMessage struct {
BridgeID networkid.BridgeID
RoomID id.RoomID
EventID id.EventID
Timestamp time.Time
BridgeID networkid.BridgeID
RoomID id.RoomID
EventID id.EventID
DisappearingSetting
}
const (
upsertDisappearingMessageQuery = `
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (bridge_id, mxid) DO UPDATE SET timer=excluded.timer, disappear_at=excluded.disappear_at
`
startDisappearingMessagesQuery = `
UPDATE disappearing_message
SET disappear_at=$1 + timer
WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read' AND timestamp<=$4
RETURNING bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at
WHERE bridge_id=$2 AND mx_room=$3 AND disappear_at IS NULL AND type='after_read'
RETURNING bridge_id, mx_room, mxid, type, timer, disappear_at
`
getUpcomingDisappearingMessagesQuery = `
SELECT bridge_id, mx_room, mxid, timestamp, type, timer, disappear_at
SELECT bridge_id, mx_room, mxid, type, timer, disappear_at
FROM disappearing_message WHERE bridge_id = $1 AND disappear_at IS NOT NULL AND disappear_at < $2
ORDER BY disappear_at LIMIT $3
ORDER BY disappear_at
`
deleteDisappearingMessageQuery = `
DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2
@ -111,12 +73,12 @@ func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMe
return dmq.Exec(ctx, upsertDisappearingMessageQuery, dm.sqlVariables()...)
}
func (dmq *DisappearingMessageQuery) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID, beforeTS.UnixNano())
func (dmq *DisappearingMessageQuery) StartAll(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID)
}
func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration, limit int) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano(), limit)
func (dmq *DisappearingMessageQuery) GetUpcoming(ctx context.Context, duration time.Duration) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).UnixNano())
}
func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.EventID) error {
@ -124,19 +86,17 @@ func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.Even
}
func (d *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
var timestamp int64
var disappearAt sql.NullInt64
err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, &timestamp, &d.Type, &d.Timer, &disappearAt)
err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, &d.Type, &d.Timer, &disappearAt)
if err != nil {
return nil, err
}
if disappearAt.Valid {
d.DisappearAt = time.Unix(0, disappearAt.Int64)
}
d.Timestamp = time.Unix(0, timestamp)
return d, nil
}
func (d *DisappearingMessage) sqlVariables() []any {
return []any{d.BridgeID, d.RoomID, d.EventID, d.Timestamp.UnixNano(), d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)}
return []any{d.BridgeID, d.RoomID, d.EventID, d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)}
}

View file

@ -7,17 +7,12 @@
package database
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/crypto/canonicaljson"
"maunium.net/go/mautrix/id"
)
@ -27,55 +22,6 @@ type GhostQuery struct {
*dbutil.QueryHelper[*Ghost]
}
type ExtraProfile map[string]json.RawMessage
func (ep *ExtraProfile) Set(key string, value any) error {
if key == "displayname" || key == "avatar_url" {
return fmt.Errorf("cannot set reserved profile key %q", key)
}
marshaled, err := json.Marshal(value)
if err != nil {
return err
}
if *ep == nil {
*ep = make(ExtraProfile)
}
(*ep)[key] = canonicaljson.CanonicalJSONAssumeValid(marshaled)
return nil
}
func (ep *ExtraProfile) With(key string, value any) *ExtraProfile {
exerrors.PanicIfNotNil(ep.Set(key, value))
return ep
}
func canonicalizeIfObject(data json.RawMessage) json.RawMessage {
if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
return canonicaljson.CanonicalJSONAssumeValid(data)
}
return data
}
func (ep *ExtraProfile) CopyTo(dest *ExtraProfile) (changed bool) {
if len(*ep) == 0 {
return
}
if *dest == nil {
*dest = make(ExtraProfile)
}
for key, val := range *ep {
if key == "displayname" || key == "avatar_url" {
continue
}
existing, exists := (*dest)[key]
if !exists || !bytes.Equal(canonicalizeIfObject(existing), val) {
(*dest)[key] = val
changed = true
}
}
return
}
type Ghost struct {
BridgeID networkid.BridgeID
ID networkid.UserID
@ -89,14 +35,13 @@ type Ghost struct {
ContactInfoSet bool
IsBot bool
Identifiers []string
ExtraProfile ExtraProfile
Metadata any
}
const (
getGhostBaseQuery = `
SELECT bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
FROM ghost
`
getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2`
@ -104,14 +49,13 @@ const (
insertGhostQuery = `
INSERT INTO ghost (
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
updateGhostQuery = `
UPDATE ghost SET name=$3, avatar_id=$4, avatar_hash=$5, avatar_mxc=$6,
name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10,
identifiers=$11, extra_profile=$12, metadata=$13
name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, identifiers=$11, metadata=$12
WHERE bridge_id=$1 AND id=$2
`
)
@ -142,7 +86,7 @@ func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) {
&g.BridgeID, &g.ID,
&g.Name, &g.AvatarID, &avatarHash, &g.AvatarMXC,
&g.NameSet, &g.AvatarSet, &g.ContactInfoSet, &g.IsBot,
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: &g.ExtraProfile}, dbutil.JSON{Data: g.Metadata},
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata},
)
if err != nil {
return nil, err
@ -172,6 +116,6 @@ func (g *Ghost) sqlVariables() []any {
g.BridgeID, g.ID,
g.Name, g.AvatarID, avatarHash, g.AvatarMXC,
g.NameSet, g.AvatarSet, g.ContactInfoSet, g.IsBot,
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.ExtraProfile}, dbutil.JSON{Data: g.Metadata},
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata},
}
}

View file

@ -20,10 +20,7 @@ import (
type Key string
const (
KeySplitPortalsEnabled Key = "split_portals_enabled"
KeyBridgeInfoVersion Key = "bridge_info_version"
KeyEncryptionStateResynced Key = "encryption_state_resynced"
KeyRecoveryKey Key = "recovery_key"
KeySplitPortalsEnabled Key = "split_portals_enabled"
)
type KVQuery struct {

View file

@ -11,12 +11,9 @@ import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
@ -27,7 +24,6 @@ type MessageQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*dbutil.QueryHelper[*Message]
chunkDeleteLock sync.Mutex
}
type Message struct {
@ -37,43 +33,37 @@ type Message struct {
PartID networkid.PartID
MXID id.EventID
Room networkid.PortalKey
SenderID networkid.UserID
SenderMXID id.UserID
Timestamp time.Time
EditCount int
IsDoublePuppeted bool
Room networkid.PortalKey
SenderID networkid.UserID
SenderMXID id.UserID
Timestamp time.Time
EditCount int
ThreadRoot networkid.MessageID
ReplyTo networkid.MessageOptionalPartID
SendTxnID networkid.RawTransactionID
Metadata any
}
const (
getMessageBaseQuery = `
SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid,
timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id,
send_txn_id, metadata
timestamp, edit_count, thread_root_id, reply_to_id, reply_to_part_id, metadata
FROM message
`
getAllMessagePartsByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3`
getMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 AND part_id=$4`
getMessagePartByRowIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND rowid=$2`
getMessageByMXIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND mxid=$2`
getMessageByTxnIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND (mxid=$3 OR send_txn_id=$4)`
getLastMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id DESC LIMIT 1`
getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1`
getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5`
getOldestMessageInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp ASC, part_id ASC LIMIT 1`
getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS FIRST, timestamp ASC, part_id ASC LIMIT 1`
getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS LAST, timestamp DESC, part_id DESC LIMIT 1`
getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp ASC, part_id ASC LIMIT 1`
getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp DESC, part_id DESC LIMIT 1`
getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, part_id DESC LIMIT $4`
getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1`
getLastNonFakeMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 AND mxid NOT LIKE '~fake:%' ORDER BY timestamp DESC, part_id DESC LIMIT 1`
getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1`
countMessagesInPortalQuery = `
SELECT COUNT(*) FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3
@ -82,17 +72,15 @@ const (
insertMessageQuery = `
INSERT INTO message (
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid,
timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id,
send_txn_id, metadata
timestamp, edit_count, thread_root_id, reply_to_id, reply_to_part_id, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING rowid
`
updateMessageQuery = `
UPDATE message SET id=$2, part_id=$3, mxid=$4, room_id=$5, room_receiver=$6, sender_id=$7, sender_mxid=$8,
timestamp=$9, edit_count=$10, double_puppeted=$11, thread_root_id=$12, reply_to_id=$13,
reply_to_part_id=$14, send_txn_id=$15, metadata=$16
WHERE bridge_id=$1 AND rowid=$17
timestamp=$9, edit_count=$10, thread_root_id=$11, reply_to_id=$12, reply_to_part_id=$13, metadata=$14
WHERE bridge_id=$1 AND rowid=$15
`
deleteAllMessagePartsByIDQuery = `
DELETE FROM message WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3
@ -100,10 +88,6 @@ const (
deleteMessagePartByRowIDQuery = `
DELETE FROM message WHERE bridge_id=$1 AND rowid=$2
`
deleteMessageChunkQuery = `
DELETE FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND rowid > $4 AND rowid <= $5
`
getMaxMessageRowIDQuery = `SELECT MAX(rowid) FROM message WHERE bridge_id=$1`
)
func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) ([]*Message, error) {
@ -118,10 +102,6 @@ func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Me
return mq.QueryOne(ctx, getMessageByMXIDQuery, mq.BridgeID, mxid)
}
func (mq *MessageQuery) GetPartByTxnID(ctx context.Context, receiver networkid.UserLoginID, mxid id.EventID, txnID networkid.RawTransactionID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByTxnIDQuery, mq.BridgeID, receiver, mxid, txnID)
}
func (mq *MessageQuery) GetLastPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getLastMessagePartByIDQuery, mq.BridgeID, receiver, id)
}
@ -146,10 +126,6 @@ func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal ne
return mq.QueryOne(ctx, getLastMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano())
}
func (mq *MessageQuery) GetLastNonFakePartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) {
return mq.QueryOne(ctx, getLastNonFakeMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano())
}
func (mq *MessageQuery) GetMessagesBetweenTimeQuery(ctx context.Context, portal networkid.PortalKey, start, end time.Time) ([]*Message, error) {
return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, start.UnixNano(), end.UnixNano())
}
@ -188,85 +164,6 @@ func (mq *MessageQuery) Delete(ctx context.Context, rowID int64) error {
return mq.Exec(ctx, deleteMessagePartByRowIDQuery, mq.BridgeID, rowID)
}
func (mq *MessageQuery) deleteChunk(ctx context.Context, portal networkid.PortalKey, minRowID, maxRowID int64) (int64, error) {
res, err := mq.GetDB().Exec(ctx, deleteMessageChunkQuery, mq.BridgeID, portal.ID, portal.Receiver, minRowID, maxRowID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (mq *MessageQuery) getMaxRowID(ctx context.Context) (maxRowID int64, err error) {
err = mq.GetDB().QueryRow(ctx, getMaxMessageRowIDQuery, mq.BridgeID).Scan(&maxRowID)
return
}
const deleteChunkSize = 100_000
func (mq *MessageQuery) DeleteInChunks(ctx context.Context, portal networkid.PortalKey) error {
if mq.GetDB().Dialect != dbutil.SQLite {
return nil
}
log := zerolog.Ctx(ctx).With().
Str("action", "delete messages in chunks").
Stringer("portal_key", portal).
Logger()
if !mq.chunkDeleteLock.TryLock() {
log.Warn().Msg("Portal deletion lock is being held, waiting...")
mq.chunkDeleteLock.Lock()
log.Debug().Msg("Acquired portal deletion lock after waiting")
}
defer mq.chunkDeleteLock.Unlock()
total, err := mq.CountMessagesInPortal(ctx, portal)
if err != nil {
return fmt.Errorf("failed to count messages in portal: %w", err)
} else if total < deleteChunkSize/3 {
return nil
}
globalMaxRowID, err := mq.getMaxRowID(ctx)
if err != nil {
return fmt.Errorf("failed to get max row ID: %w", err)
}
log.Debug().
Int("total_count", total).
Int64("global_max_row_id", globalMaxRowID).
Msg("Portal has lots of messages, deleting in chunks to avoid database locks")
maxRowID := int64(deleteChunkSize)
globalMaxRowID += deleteChunkSize * 1.2
var dbTimeUsed time.Duration
globalStart := time.Now()
for total > 500 && maxRowID < globalMaxRowID {
start := time.Now()
count, err := mq.deleteChunk(ctx, portal, maxRowID-deleteChunkSize, maxRowID)
duration := time.Since(start)
dbTimeUsed += duration
if err != nil {
return fmt.Errorf("failed to delete chunk of messages before %d: %w", maxRowID, err)
}
total -= int(count)
maxRowID += deleteChunkSize
sleepTime := max(10*time.Millisecond, min(250*time.Millisecond, time.Duration(count/100)*time.Millisecond))
log.Debug().
Int64("max_row_id", maxRowID).
Int64("deleted_count", count).
Int("remaining_count", total).
Dur("duration", duration).
Dur("sleep_time", sleepTime).
Msg("Deleted chunk of messages")
select {
case <-time.After(sleepTime):
case <-ctx.Done():
return ctx.Err()
}
}
log.Debug().
Int("remaining_count", total).
Dur("db_time_used", dbTimeUsed).
Dur("total_duration", time.Since(globalStart)).
Msg("Finished chunked delete of messages in portal")
return nil
}
func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid.PortalKey) (count int, err error) {
err = mq.GetDB().QueryRow(ctx, countMessagesInPortalQuery, mq.BridgeID, key.ID, key.Receiver).Scan(&count)
return
@ -274,28 +171,22 @@ func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid
func (m *Message) Scan(row dbutil.Scannable) (*Message, error) {
var timestamp int64
var threadRootID, replyToID, replyToPartID, sendTxnID sql.NullString
var doublePuppeted sql.NullBool
var threadRootID, replyToID, replyToPartID sql.NullString
err := row.Scan(
&m.RowID, &m.BridgeID, &m.ID, &m.PartID, &m.MXID, &m.Room.ID, &m.Room.Receiver, &m.SenderID, &m.SenderMXID,
&timestamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, &sendTxnID,
dbutil.JSON{Data: m.Metadata},
&timestamp, &m.EditCount, &threadRootID, &replyToID, &replyToPartID, dbutil.JSON{Data: m.Metadata},
)
if err != nil {
return nil, err
}
m.Timestamp = time.Unix(0, timestamp)
m.ThreadRoot = networkid.MessageID(threadRootID.String)
m.IsDoublePuppeted = doublePuppeted.Valid
if replyToID.Valid {
m.ReplyTo.MessageID = networkid.MessageID(replyToID.String)
if replyToPartID.Valid {
m.ReplyTo.PartID = (*networkid.PartID)(&replyToPartID.String)
}
}
if sendTxnID.Valid {
m.SendTxnID = networkid.RawTransactionID(sendTxnID.String)
}
return m, nil
}
@ -309,8 +200,7 @@ func (m *Message) ensureHasMetadata(metaType MetaTypeCreator) *Message {
func (m *Message) sqlVariables() []any {
return []any{
m.BridgeID, m.ID, m.PartID, m.MXID, m.Room.ID, m.Room.Receiver, m.SenderID, m.SenderMXID,
m.Timestamp.UnixNano(), m.EditCount, m.IsDoublePuppeted, dbutil.StrPtr(m.ThreadRoot),
dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.StrPtr(m.SendTxnID),
m.Timestamp.UnixNano(), m.EditCount, dbutil.StrPtr(m.ThreadRoot), dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID,
dbutil.JSON{Data: m.Metadata},
}
}
@ -320,9 +210,6 @@ func (m *Message) updateSQLVariables() []any {
}
const FakeMXIDPrefix = "~fake:"
const TxnMXIDPrefix = "~txn:"
const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:"
const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:"
func (m *Message) SetFakeMXID() {
hash := sha256.Sum256([]byte(m.ID))

View file

@ -16,7 +16,6 @@ import (
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -35,53 +34,35 @@ type PortalQuery struct {
*dbutil.QueryHelper[*Portal]
}
type CapStateFlags uint32
func (csf CapStateFlags) Has(flag CapStateFlags) bool {
return csf&flag != 0
}
const (
CapStateFlagDisappearingTimerSet CapStateFlags = 1 << iota
)
type CapabilityState struct {
Source networkid.UserLoginID `json:"source"`
ID string `json:"id"`
Flags CapStateFlags `json:"flags"`
}
type Portal struct {
BridgeID networkid.BridgeID
networkid.PortalKey
MXID id.RoomID
ParentKey networkid.PortalKey
RelayLoginID networkid.UserLoginID
OtherUserID networkid.UserID
Name string
Topic string
AvatarID networkid.AvatarID
AvatarHash [32]byte
AvatarMXC id.ContentURIString
NameSet bool
TopicSet bool
AvatarSet bool
NameIsCustom bool
InSpace bool
MessageRequest bool
RoomType RoomType
Disappear DisappearingSetting
CapState CapabilityState
Metadata any
ParentKey networkid.PortalKey
RelayLoginID networkid.UserLoginID
OtherUserID networkid.UserID
Name string
Topic string
AvatarID networkid.AvatarID
AvatarHash [32]byte
AvatarMXC id.ContentURIString
NameSet bool
TopicSet bool
AvatarSet bool
NameIsCustom bool
InSpace bool
RoomType RoomType
Disappear DisappearingSetting
Metadata any
}
const (
getPortalBaseQuery = `
SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_login_id, other_user_id,
name, topic, avatar_id, avatar_hash, avatar_mxc,
name_set, topic_set, avatar_set, name_is_custom, in_space, message_request,
room_type, disappear_type, disappear_timer, cap_state,
name_set, topic_set, avatar_set, name_is_custom, in_space,
room_type, disappear_type, disappear_timer,
metadata
FROM portal
`
@ -89,9 +70,7 @@ const (
getPortalByIDWithUncertainReceiverQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='')`
getPortalByMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid=$2`
getAllPortalsWithMXIDQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND mxid IS NOT NULL`
getAllPortalsWithoutReceiver = getPortalBaseQuery + `WHERE bridge_id=$1 AND (receiver='' OR (parent_id<>'' AND parent_receiver='')) ORDER BY parent_id DESC`
getAllDMPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND other_user_id=$2`
getDMPortalQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND room_type='dm' AND receiver=$2 AND other_user_id=$3`
getAllPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1`
getChildPortalsQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND parent_id=$2 AND parent_receiver=$3`
@ -102,11 +81,11 @@ const (
bridge_id, id, receiver, mxid,
parent_id, parent_receiver, relay_login_id, other_user_id,
name, topic, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, topic_set, name_is_custom, in_space, message_request,
room_type, disappear_type, disappear_timer, cap_state,
name_set, avatar_set, topic_set, name_is_custom, in_space,
room_type, disappear_type, disappear_timer,
metadata, relay_bridge_id
) VALUES (
$1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
$1, $2, $3, $4, $5, $6, cast($7 AS TEXT), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE $1 END
)
`
@ -115,8 +94,8 @@ const (
SET mxid=$4, parent_id=$5, parent_receiver=$6,
relay_login_id=cast($7 AS TEXT), relay_bridge_id=CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE bridge_id END,
other_user_id=$8, name=$9, topic=$10, avatar_id=$11, avatar_hash=$12, avatar_mxc=$13,
name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18, message_request=$19,
room_type=$20, disappear_type=$21, disappear_timer=$22, cap_state=$23, metadata=$24
name_set=$14, avatar_set=$15, topic_set=$16, name_is_custom=$17, in_space=$18,
room_type=$19, disappear_type=$20, disappear_timer=$21, metadata=$22
WHERE bridge_id=$1 AND id=$2 AND receiver=$3
`
deletePortalQuery = `
@ -126,33 +105,15 @@ const (
reIDPortalQuery = `UPDATE portal SET id=$4, receiver=$5 WHERE bridge_id=$1 AND id=$2 AND receiver=$3`
migrateToSplitPortalsQuery = `
UPDATE portal
SET receiver=new_receiver
FROM (
SELECT bridge_id, id, COALESCE((
SELECT login_id
FROM user_portal
WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver=''
LIMIT 1
), (
SELECT login_id
FROM user_portal
WHERE portal.parent_id<>'' AND bridge_id=portal.bridge_id AND portal_id=portal.parent_id
LIMIT 1
), (
SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1
), '') AS new_receiver
FROM portal
WHERE receiver='' AND bridge_id=$1
) updates
WHERE portal.bridge_id=updates.bridge_id AND portal.id=updates.id AND portal.receiver='' AND NOT EXISTS (
SELECT 1 FROM portal p2 WHERE p2.bridge_id=updates.bridge_id AND p2.id=updates.id AND p2.receiver=updates.new_receiver
)
`
fixParentsAfterSplitPortalMigrationQuery = `
UPDATE portal
SET parent_receiver=receiver
WHERE bridge_id=$1 AND parent_receiver='' AND receiver<>'' AND parent_id<>''
AND EXISTS(SELECT 1 FROM portal pp WHERE pp.bridge_id=$1 AND pp.id=portal.parent_id AND pp.receiver=portal.receiver);
SET receiver=COALESCE((
SELECT login_id
FROM user_portal
WHERE bridge_id=portal.bridge_id AND portal_id=portal.id AND portal_receiver=''
LIMIT 1
), (
SELECT id FROM user_login WHERE bridge_id=portal.bridge_id LIMIT 1
), '')
WHERE receiver='' AND bridge_id=$1
`
)
@ -180,10 +141,6 @@ func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery, pq.BridgeID)
}
func (pq *PortalQuery) GetAllWithoutReceiver(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsWithoutReceiver, pq.BridgeID)
}
func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsQuery, pq.BridgeID)
}
@ -192,10 +149,6 @@ func (pq *PortalQuery) GetAllDMsWith(ctx context.Context, otherUserID networkid.
return pq.QueryMany(ctx, getAllDMPortalsQuery, pq.BridgeID, otherUserID)
}
func (pq *PortalQuery) GetDM(ctx context.Context, receiver networkid.UserLoginID, otherUserID networkid.UserID) (*Portal, error) {
return pq.QueryOne(ctx, getDMPortalQuery, pq.BridgeID, receiver, otherUserID)
}
func (pq *PortalQuery) GetChildren(ctx context.Context, parentKey networkid.PortalKey) ([]*Portal, error) {
return pq.QueryMany(ctx, getChildPortalsQuery, pq.BridgeID, parentKey.ID, parentKey.Receiver)
}
@ -226,14 +179,6 @@ func (pq *PortalQuery) MigrateToSplitPortals(ctx context.Context) (int64, error)
return res.RowsAffected()
}
func (pq *PortalQuery) FixParentsAfterSplitPortalMigration(ctx context.Context) (int64, error) {
res, err := pq.GetDB().Exec(ctx, fixParentsAfterSplitPortalMigrationQuery, pq.BridgeID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
var mxid, parentID, parentReceiver, relayLoginID, otherUserID, disappearType sql.NullString
var disappearTimer sql.NullInt64
@ -242,9 +187,9 @@ func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
&p.BridgeID, &p.ID, &p.Receiver, &mxid,
&parentID, &parentReceiver, &relayLoginID, &otherUserID,
&p.Name, &p.Topic, &p.AvatarID, &avatarHash, &p.AvatarMXC,
&p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace, &p.MessageRequest,
&p.NameSet, &p.TopicSet, &p.AvatarSet, &p.NameIsCustom, &p.InSpace,
&p.RoomType, &disappearType, &disappearTimer,
dbutil.JSON{Data: &p.CapState}, dbutil.JSON{Data: p.Metadata},
dbutil.JSON{Data: p.Metadata},
)
if err != nil {
return nil, err
@ -257,7 +202,7 @@ func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
}
if disappearType.Valid {
p.Disappear = DisappearingSetting{
Type: event.DisappearingType(disappearType.String),
Type: DisappearingType(disappearType.String),
Timer: time.Duration(disappearTimer.Int64),
}
}
@ -289,8 +234,8 @@ func (p *Portal) sqlVariables() []any {
p.BridgeID, p.ID, p.Receiver, dbutil.StrPtr(p.MXID),
dbutil.StrPtr(p.ParentKey.ID), p.ParentKey.Receiver, dbutil.StrPtr(p.RelayLoginID), dbutil.StrPtr(p.OtherUserID),
p.Name, p.Topic, p.AvatarID, avatarHash, p.AvatarMXC,
p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace, p.MessageRequest,
p.NameSet, p.TopicSet, p.AvatarSet, p.NameIsCustom, p.InSpace,
p.RoomType, dbutil.StrPtr(p.Disappear.Type), dbutil.NumPtr(p.Disappear.Timer),
dbutil.JSON{Data: p.CapState}, dbutil.JSON{Data: p.Metadata},
dbutil.JSON{Data: p.Metadata},
}
}

View file

@ -1,72 +0,0 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
)
type PublicMediaQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*PublicMedia]
}
type PublicMedia struct {
BridgeID networkid.BridgeID
PublicID string
MXC id.ContentURI
Keys *attachment.EncryptedFile
MimeType string
Expiry time.Time
}
const (
upsertPublicMediaQuery = `
INSERT INTO public_media (bridge_id, public_id, mxc, keys, mimetype, expiry)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (bridge_id, public_id) DO UPDATE SET expiry=EXCLUDED.expiry
`
getPublicMediaQuery = `
SELECT bridge_id, public_id, mxc, keys, mimetype, expiry
FROM public_media WHERE bridge_id=$1 AND public_id=$2
`
)
func (pmq *PublicMediaQuery) Put(ctx context.Context, pm *PublicMedia) error {
ensureBridgeIDMatches(&pm.BridgeID, pmq.BridgeID)
return pmq.Exec(ctx, upsertPublicMediaQuery, pm.sqlVariables()...)
}
func (pmq *PublicMediaQuery) Get(ctx context.Context, publicID string) (*PublicMedia, error) {
return pmq.QueryOne(ctx, getPublicMediaQuery, pmq.BridgeID, publicID)
}
func (pm *PublicMedia) Scan(row dbutil.Scannable) (*PublicMedia, error) {
var expiry sql.NullInt64
var mimetype sql.NullString
err := row.Scan(&pm.BridgeID, &pm.PublicID, &pm.MXC, dbutil.JSON{Data: &pm.Keys}, &mimetype, &expiry)
if err != nil {
return nil, err
}
if expiry.Valid {
pm.Expiry = time.Unix(0, expiry.Int64)
}
pm.MimeType = mimetype.String
return pm, nil
}
func (pm *PublicMedia) sqlVariables() []any {
return []any{pm.BridgeID, pm.PublicID, &pm.MXC, dbutil.JSONPtr(pm.Keys), dbutil.StrPtr(pm.MimeType), dbutil.ConvertedPtr(pm.Expiry, time.Time.UnixNano)}
}

View file

@ -1,4 +1,4 @@
-- v0 -> v27 (compatible with v9+): Latest revision
-- v0 -> v18 (compatible with v9+): Latest revision
CREATE TABLE "user" (
bridge_id TEXT NOT NULL,
mxid TEXT NOT NULL,
@ -48,11 +48,9 @@ CREATE TABLE portal (
topic_set BOOLEAN NOT NULL,
name_is_custom BOOLEAN NOT NULL DEFAULT false,
in_space BOOLEAN NOT NULL,
message_request BOOLEAN NOT NULL DEFAULT false,
room_type TEXT NOT NULL,
disappear_type TEXT,
disappear_timer BIGINT,
cap_state jsonb,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id, receiver),
@ -64,8 +62,6 @@ CREATE TABLE portal (
REFERENCES user_login (bridge_id, id)
ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid);
CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver);
CREATE TABLE ghost (
bridge_id TEXT NOT NULL,
@ -80,7 +76,6 @@ CREATE TABLE ghost (
contact_info_set BOOLEAN NOT NULL,
is_bot BOOLEAN NOT NULL,
identifiers jsonb NOT NULL,
extra_profile jsonb,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id)
@ -92,7 +87,7 @@ CREATE TABLE message (
-- would try to set bridge_id to null as well.
-- only: sqlite (line commented)
-- rowid INTEGER PRIMARY KEY,
-- rowid INTEGER PRIMARY KEY,
-- only: postgres
rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
@ -107,11 +102,9 @@ CREATE TABLE message (
sender_mxid TEXT NOT NULL,
timestamp BIGINT NOT NULL,
edit_count INTEGER NOT NULL,
double_puppeted BOOLEAN,
thread_root_id TEXT,
reply_to_id TEXT,
reply_to_part_id TEXT,
send_txn_id TEXT,
metadata jsonb NOT NULL,
CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver)
@ -121,8 +114,7 @@ CREATE TABLE message (
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id),
CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid),
CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id)
CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid)
);
CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver);
@ -130,18 +122,12 @@ CREATE TABLE disappearing_message (
bridge_id TEXT NOT NULL,
mx_room TEXT NOT NULL,
mxid TEXT NOT NULL,
timestamp BIGINT NOT NULL DEFAULT 0,
type TEXT NOT NULL,
timer BIGINT NOT NULL,
disappear_at BIGINT,
PRIMARY KEY (bridge_id, mxid),
CONSTRAINT disappearing_message_portal_fkey
FOREIGN KEY (bridge_id, mx_room)
REFERENCES portal (bridge_id, mxid)
ON DELETE CASCADE
PRIMARY KEY (bridge_id, mxid)
);
CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room);
CREATE TABLE reaction (
bridge_id TEXT NOT NULL,
@ -220,14 +206,3 @@ CREATE TABLE kv_store (
PRIMARY KEY (bridge_id, key)
);
CREATE TABLE public_media (
bridge_id TEXT NOT NULL,
public_id TEXT NOT NULL,
mxc TEXT NOT NULL,
keys jsonb,
mimetype TEXT,
expiry BIGINT,
PRIMARY KEY (bridge_id, public_id)
);

View file

@ -1,2 +0,0 @@
-- v19 (compatible with v9+): Add double puppeted state to messages
ALTER TABLE message ADD COLUMN double_puppeted BOOLEAN;

View file

@ -1,2 +0,0 @@
-- v20 (compatible with v9+): Add portal capability state
ALTER TABLE portal ADD COLUMN cap_state jsonb;

View file

@ -1,8 +0,0 @@
-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid
CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid);
DELETE FROM disappearing_message WHERE mx_room NOT IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL);
ALTER TABLE disappearing_message
ADD CONSTRAINT disappearing_message_portal_fkey
FOREIGN KEY (bridge_id, mx_room)
REFERENCES portal (bridge_id, mxid)
ON DELETE CASCADE;

View file

@ -1,24 +0,0 @@
-- v21 (compatible with v9+): Add foreign key constraint from disappearing_message.mx_room to portals.mxid
CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid);
CREATE TABLE disappearing_message_new (
bridge_id TEXT NOT NULL,
mx_room TEXT NOT NULL,
mxid TEXT NOT NULL,
type TEXT NOT NULL,
timer BIGINT NOT NULL,
disappear_at BIGINT,
PRIMARY KEY (bridge_id, mxid),
CONSTRAINT disappearing_message_portal_fkey
FOREIGN KEY (bridge_id, mx_room)
REFERENCES portal (bridge_id, mxid)
ON DELETE CASCADE
);
WITH portal_mxids AS (SELECT mxid FROM portal WHERE mxid IS NOT NULL)
INSERT INTO disappearing_message_new (bridge_id, mx_room, mxid, type, timer, disappear_at)
SELECT bridge_id, mx_room, mxid, type, timer, disappear_at
FROM disappearing_message WHERE mx_room IN portal_mxids;
DROP TABLE disappearing_message;
ALTER TABLE disappearing_message_new RENAME TO disappearing_message;

View file

@ -1,6 +0,0 @@
-- v22 (compatible with v9+): Add message send transaction ID column
ALTER TABLE message ADD COLUMN send_txn_id TEXT;
-- only: postgres
ALTER TABLE message ADD CONSTRAINT message_txn_id_unique UNIQUE (bridge_id, room_receiver, send_txn_id);
-- only: sqlite
CREATE UNIQUE INDEX message_txn_id_unique ON message (bridge_id, room_receiver, send_txn_id);

View file

@ -1,2 +0,0 @@
-- v23 (compatible with v9+): Add event timestamp for disappearing messages
ALTER TABLE disappearing_message ADD COLUMN timestamp BIGINT NOT NULL DEFAULT 0;

View file

@ -1,11 +0,0 @@
-- v24 (compatible with v9+): Custom URLs for public media
CREATE TABLE public_media (
bridge_id TEXT NOT NULL,
public_id TEXT NOT NULL,
mxc TEXT NOT NULL,
keys jsonb,
mimetype TEXT,
expiry BIGINT,
PRIMARY KEY (bridge_id, public_id)
);

View file

@ -1,2 +0,0 @@
-- v25 (compatible with v9+): Flag for message request portals
ALTER TABLE portal ADD COLUMN message_request BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,3 +0,0 @@
-- v26 (compatible with v9+): Add room index for disappearing message table and portal parents
CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room);
CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver);

View file

@ -1,2 +0,0 @@
-- v27 (compatible with v9+): Add column for extra ghost profile metadata
ALTER TABLE ghost ADD COLUMN extra_profile jsonb;

View file

@ -12,8 +12,8 @@ import (
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
)
@ -116,7 +116,7 @@ func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin {
func (u *UserLogin) sqlVariables() []any {
var remoteProfile dbutil.JSON
if !u.RemoteProfile.IsZero() {
if !u.RemoteProfile.IsEmpty() {
remoteProfile.Data = &u.RemoteProfile
}
return []any{u.BridgeID, u.UserMXID, u.ID, u.RemoteName, remoteProfile, dbutil.StrPtr(u.SpaceRoom), dbutil.JSON{Data: u.Metadata}}

View file

@ -67,9 +67,6 @@ const (
markLoginAsPreferredQuery = `
UPDATE user_portal SET preferred=(login_id=$3) WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$4 AND portal_receiver=$5
`
markAllNotInSpaceQuery = `
UPDATE user_portal SET in_space=false WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3
`
deleteUserPortalQuery = `
DELETE FROM user_portal WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5
`
@ -113,10 +110,6 @@ func (upq *UserPortalQuery) MarkAsPreferred(ctx context.Context, login *UserLogi
return upq.Exec(ctx, markLoginAsPreferredQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) MarkAllNotInSpace(ctx context.Context, portal networkid.PortalKey) error {
return upq.Exec(ctx, markAllNotInSpaceQuery, upq.BridgeID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) Delete(ctx context.Context, up *UserPortal) error {
return upq.Exec(ctx, deleteUserPortalQuery, up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver)
}

View file

@ -8,7 +8,6 @@ package bridgev2
import (
"context"
"sync/atomic"
"time"
"github.com/rs/zerolog"
@ -21,44 +20,27 @@ import (
type DisappearLoop struct {
br *Bridge
nextCheck atomic.Pointer[time.Time]
stop atomic.Pointer[context.CancelFunc]
NextCheck time.Time
stop context.CancelFunc
}
const DisappearCheckInterval = 1 * time.Hour
func (dl *DisappearLoop) Start() {
log := dl.br.Log.With().Str("component", "disappear loop").Logger()
ctx, stop := context.WithCancel(log.WithContext(context.Background()))
if oldStop := dl.stop.Swap(&stop); oldStop != nil {
(*oldStop)()
}
ctx := log.WithContext(context.Background())
ctx, dl.stop = context.WithCancel(ctx)
log.Debug().Msg("Disappearing message loop starting")
for {
nextCheck := time.Now().Add(DisappearCheckInterval)
dl.nextCheck.Store(&nextCheck)
const MessageLimit = 200
messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval, MessageLimit)
dl.NextCheck = time.Now().Add(DisappearCheckInterval)
messages, err := dl.br.DB.DisappearingMessage.GetUpcoming(ctx, DisappearCheckInterval)
if err != nil {
log.Err(err).Msg("Failed to get upcoming disappearing messages")
} else if len(messages) > 0 {
if len(messages) >= MessageLimit {
lastDisappearTime := messages[len(messages)-1].DisappearAt
log.Debug().
Int("message_count", len(messages)).
Time("last_due", lastDisappearTime).
Msg("Deleting disappearing messages synchronously and checking again immediately")
// Store the expected next check time to avoid Add spawning unnecessary goroutines.
// This can be in the past, in which case Add will put everything in the db, which is also fine.
dl.nextCheck.Store(&lastDisappearTime)
// If there are many messages, process them synchronously and then check again.
dl.sleepAndDisappear(ctx, messages...)
continue
}
go dl.sleepAndDisappear(ctx, messages...)
}
select {
case <-time.After(time.Until(dl.GetNextCheck())):
case <-time.After(time.Until(dl.NextCheck)):
case <-ctx.Done():
log.Debug().Msg("Disappearing message loop stopping")
return
@ -66,34 +48,20 @@ func (dl *DisappearLoop) Start() {
}
}
func (dl *DisappearLoop) GetNextCheck() time.Time {
if dl == nil {
return time.Time{}
}
nextCheck := dl.nextCheck.Load()
if nextCheck == nil {
return time.Time{}
}
return *nextCheck
}
func (dl *DisappearLoop) Stop() {
if dl == nil {
return
}
if stop := dl.stop.Load(); stop != nil {
(*stop)()
if dl.stop != nil {
dl.stop()
}
}
func (dl *DisappearLoop) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) {
startedMessages, err := dl.br.DB.DisappearingMessage.StartAllBefore(ctx, roomID, beforeTS)
func (dl *DisappearLoop) StartAll(ctx context.Context, roomID id.RoomID) {
startedMessages, err := dl.br.DB.DisappearingMessage.StartAll(ctx, roomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to start disappearing messages")
return
}
startedMessages = slices.DeleteFunc(startedMessages, func(dm *database.DisappearingMessage) bool {
return dm.DisappearAt.After(dl.GetNextCheck())
return dm.DisappearAt.After(dl.NextCheck)
})
slices.SortFunc(startedMessages, func(a, b *database.DisappearingMessage) int {
return a.DisappearAt.Compare(b.DisappearAt)
@ -110,25 +78,14 @@ func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessa
Stringer("event_id", dm.EventID).
Msg("Failed to save disappearing message")
}
if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.GetNextCheck()) {
go dl.sleepAndDisappear(zerolog.Ctx(ctx).WithContext(dl.br.BackgroundCtx), dm)
if !dm.DisappearAt.IsZero() && dm.DisappearAt.Before(dl.NextCheck) {
go dl.sleepAndDisappear(context.WithoutCancel(ctx), dm)
}
}
func (dl *DisappearLoop) sleepAndDisappear(ctx context.Context, dms ...*database.DisappearingMessage) {
for _, msg := range dms {
timeUntilDisappear := time.Until(msg.DisappearAt)
if timeUntilDisappear <= 0 {
if ctx.Err() != nil {
return
}
} else {
select {
case <-time.After(timeUntilDisappear):
case <-ctx.Done():
return
}
}
time.Sleep(time.Until(msg.DisappearAt))
resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{
Parsed: &event.RedactionEventContent{
Redacts: msg.EventID,

View file

@ -12,7 +12,6 @@ import (
"net/http"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
// ErrIgnoringRemoteEvent can be returned by [RemoteMessage.ConvertMessage] or [RemoteEdit.ConvertEdit]
@ -38,53 +37,32 @@ var ErrNotLoggedIn = errors.New("not logged in")
// but direct media is not enabled.
var ErrDirectMediaNotEnabled = errors.New("direct media is not enabled")
var ErrPortalIsDeleted = errors.New("portal is deleted")
var ErrPortalNotFoundInEventHandler = errors.New("portal not found to handle remote event")
// Common message status errors
var (
ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage()
ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringAcceptRequestRelayedUser error = WrapErrorInStatus(errors.New("ignoring accept message request event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported)
ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true)
ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false)
ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true)
ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false)
ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported)
ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported)
ErrMediaDurationTooLong error = WrapErrorInStatus(errors.New("media duration too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported)
ErrVoiceMessageDurationTooLong error = WrapErrorInStatus(errors.New("voice message too long")).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported)
ErrMediaTooLarge error = WrapErrorInStatus(errors.New("media too large")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported)
ErrIgnoringMNotice error = WrapErrorInStatus(errors.New("ignoring m.notice message")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false)
ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true)
ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true)
ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true)
ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrBeeperAIStreamNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support Beeper AI stream events")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld)
ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld)
ErrPublicMediaDisabled = WrapErrorInStatus(errors.New("public media is not enabled in the bridge config")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true)
ErrPublicMediaDatabaseDisabled = WrapErrorInStatus(errors.New("public media database storage is disabled")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true)
ErrPublicMediaGenerateFailed = WrapErrorInStatus(errors.New("failed to generate public media URL")).WithIsCertain(true).WithMessage("failed to generate public media URL").WithErrorReason(event.MessageStatusUnsupported).WithSendNotice(true)
ErrDisappearingTimerUnsupported error = WrapErrorInStatus(errors.New("invalid disappearing timer")).WithIsCertain(true)
ErrPanicInEventHandler error = WrapErrorInStatus(errors.New("panic in event handler")).WithSendNotice(true).WithErrorAsMessage()
ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false)
ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage()
ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage()
ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage()
ErrLocationMessagesNotAllowed error = WrapErrorInStatus(errors.New("location messages are not supported here")).WithIsCertain(true).WithErrorAsMessage()
ErrEditTargetTooOld error = WrapErrorInStatus(errors.New("the message is too old to be edited")).WithIsCertain(true).WithErrorAsMessage()
ErrEditTargetTooManyEdits error = WrapErrorInStatus(errors.New("the message has been edited too many times")).WithIsCertain(true).WithErrorAsMessage()
ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage()
ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage()
ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false)
ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage()
ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true)
ErrDatabaseError error = WrapErrorInStatus(errors.New("database error")).WithMessage("internal database error").WithIsCertain(true).WithSendNotice(true)
ErrTargetMessageNotFound error = WrapErrorInStatus(errors.New("target message not found")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false)
ErrUnsupportedMessageType error = WrapErrorInStatus(errors.New("unsupported message type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true)
ErrUnsupportedMediaType error = WrapErrorInStatus(errors.New("unsupported media type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true)
ErrMediaDownloadFailed error = WrapErrorInStatus(errors.New("failed to download media")).WithMessage("failed to download media").WithIsCertain(true).WithSendNotice(true)
ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true)
ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true)
ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false)
ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false)
)
// Common login interface errors

View file

@ -9,15 +9,12 @@ package bridgev2
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exmime"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
@ -88,13 +85,7 @@ func (br *Bridge) GetGhostByMXID(ctx context.Context, mxid id.UserID) (*Ghost, e
func (br *Bridge) GetGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) {
br.cacheLock.Lock()
defer br.cacheLock.Unlock()
ghost, err := br.unlockedGetGhostByID(ctx, id, false)
if err != nil {
return nil, err
} else if ghost == nil {
panic(fmt.Errorf("unlockedGetGhostByID(ctx, %q, false) returned nil", id))
}
return ghost, nil
return br.unlockedGetGhostByID(ctx, id, false)
}
func (br *Bridge) GetExistingGhostByID(ctx context.Context, id networkid.UserID) (*Ghost, error) {
@ -137,11 +128,10 @@ func (a *Avatar) Reupload(ctx context.Context, intent MatrixAPI, currentHash [32
}
type UserInfo struct {
Identifiers []string
Name *string
Avatar *Avatar
IsBot *bool
ExtraProfile database.ExtraProfile
Identifiers []string
Name *string
Avatar *Avatar
IsBot *bool
ExtraUpdates ExtraUpdater[*Ghost]
}
@ -162,7 +152,7 @@ func (ghost *Ghost) UpdateName(ctx context.Context, name string) bool {
}
func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool {
if ghost.AvatarID == avatar.ID && (avatar.Remove || ghost.AvatarMXC != "") && ghost.AvatarSet {
if ghost.AvatarID == avatar.ID && ghost.AvatarSet {
return false
}
ghost.AvatarID = avatar.ID
@ -172,7 +162,7 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool {
ghost.AvatarSet = false
zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload avatar")
return true
} else if newHash == ghost.AvatarHash && ghost.AvatarMXC != "" && ghost.AvatarSet {
} else if newHash == ghost.AvatarHash && ghost.AvatarSet {
return true
}
ghost.AvatarHash = newHash
@ -189,9 +179,23 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool {
return true
}
func (ghost *Ghost) getExtraProfileMeta() any {
func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool) bool {
if identifiers != nil {
slices.Sort(identifiers)
}
if ghost.ContactInfoSet &&
(identifiers == nil || slices.Equal(identifiers, ghost.Identifiers)) &&
(isBot == nil || *isBot == ghost.IsBot) {
return false
}
if identifiers != nil {
ghost.Identifiers = identifiers
}
if isBot != nil {
ghost.IsBot = *isBot
}
bridgeName := ghost.Bridge.Network.GetName()
baseExtra := &event.BeeperProfileExtra{
meta := &event.BeeperProfileExtra{
RemoteID: string(ghost.ID),
Identifiers: ghost.Identifiers,
Service: bridgeName.BeeperBridgeType,
@ -199,36 +203,7 @@ func (ghost *Ghost) getExtraProfileMeta() any {
IsBridgeBot: false,
IsNetworkBot: ghost.IsBot,
}
if len(ghost.ExtraProfile) == 0 {
return baseExtra
}
mergedExtra := maps.Clone(ghost.ExtraProfile)
baseExtraMarshaled := exerrors.Must(json.Marshal(baseExtra))
exerrors.PanicIfNotNil(json.Unmarshal(baseExtraMarshaled, &mergedExtra))
return mergedExtra
}
func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool {
if !ghost.Bridge.Matrix.GetCapabilities().ExtraProfileMeta {
ghost.ContactInfoSet = false
return false
}
if identifiers != nil {
slices.Sort(identifiers)
}
changed := extraProfile.CopyTo(&ghost.ExtraProfile)
if identifiers != nil {
changed = changed || !slices.Equal(identifiers, ghost.Identifiers)
ghost.Identifiers = identifiers
}
if isBot != nil {
changed = changed || *isBot != ghost.IsBot
ghost.IsBot = *isBot
}
if ghost.ContactInfoSet && !changed {
return false
}
err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta())
err := ghost.Intent.SetExtraProfileMeta(ctx, meta)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata")
} else {
@ -250,7 +225,7 @@ func (br *Bridge) allowAggressiveUpdateForType(evtType RemoteEventType) bool {
}
func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin, evtType RemoteEventType) {
if ghost.Name != "" && ghost.NameSet && ghost.AvatarSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) {
if ghost.Name != "" && ghost.NameSet && !ghost.Bridge.allowAggressiveUpdateForType(evtType) {
return
}
info, err := source.Client.GetUserInfo(ctx, ghost)
@ -260,16 +235,12 @@ func (ghost *Ghost) UpdateInfoIfNecessary(ctx context.Context, source *UserLogin
zerolog.Ctx(ctx).Debug().
Bool("has_name", ghost.Name != "").
Bool("name_set", ghost.NameSet).
Bool("has_avatar", ghost.AvatarMXC != "").
Bool("avatar_set", ghost.AvatarSet).
Msg("Updating ghost info in IfNecessary call")
ghost.UpdateInfo(ctx, info)
} else {
zerolog.Ctx(ctx).Trace().
Bool("has_name", ghost.Name != "").
Bool("name_set", ghost.NameSet).
Bool("has_avatar", ghost.AvatarMXC != "").
Bool("avatar_set", ghost.AvatarSet).
Msg("No ghost info received in IfNecessary call")
}
}
@ -297,14 +268,9 @@ func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) {
}
if info.Avatar != nil {
update = ghost.UpdateAvatar(ctx, info.Avatar) || update
} else if oldAvatar == "" && !ghost.AvatarSet {
// Special case: nil avatar means we're not expecting one ever, if we don't currently have
// one we flag it as set to avoid constantly refetching in UpdateInfoIfNecessary.
ghost.AvatarSet = true
update = true
}
if info.Identifiers != nil || info.IsBot != nil || info.ExtraProfile != nil {
update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot, info.ExtraProfile) || update
if info.Identifiers != nil || info.IsBot != nil {
update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot) || update
}
if info.ExtraUpdates != nil {
update = info.ExtraUpdates(ctx, ghost) || update

View file

@ -13,7 +13,6 @@ import (
"strings"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
)
// LoginProcess represents a single occurrence of a user logging into the remote network.
@ -160,12 +159,6 @@ type LoginCookiesParams struct {
// The snippet will evaluate to a promise that resolves when the relevant fields are found.
// Fields that are not present in the promise result must be extracted another way.
ExtractJS string `json:"extract_js,omitempty"`
// A regex pattern that the URL should match before the client closes the webview.
//
// The client may submit the login if the user closes the webview after all cookies are collected
// even if this URL is not reached, but it should only automatically close the webview after
// both cookies and the URL match.
WaitForURLPattern string `json:"wait_for_url_pattern,omitempty"`
}
type LoginInputFieldType string
@ -179,8 +172,6 @@ const (
LoginInputFieldTypeToken LoginInputFieldType = "token"
LoginInputFieldTypeURL LoginInputFieldType = "url"
LoginInputFieldTypeDomain LoginInputFieldType = "domain"
LoginInputFieldTypeSelect LoginInputFieldType = "select"
LoginInputFieldTypeCaptchaCode LoginInputFieldType = "captcha_code"
)
type LoginInputDataField struct {
@ -192,13 +183,8 @@ type LoginInputDataField struct {
Name string `json:"name"`
// The description of the field shown to the user.
Description string `json:"description"`
// A default value that the client can pre-fill the field with.
DefaultValue string `json:"default_value,omitempty"`
// A regex pattern that the client can use to validate input client-side.
Pattern string `json:"pattern,omitempty"`
// For fields of type select, the valid options.
// Pattern may also be filled with a regex that matches the same options.
Options []string `json:"options,omitempty"`
// A function that validates the input and optionally cleans it up before it's submitted to the connector.
Validate func(string) (string, error) `json:"-"`
}
@ -273,23 +259,6 @@ func (f *LoginInputDataField) FillDefaultValidate() {
type LoginUserInputParams struct {
// The fields that the user needs to fill in.
Fields []LoginInputDataField `json:"fields"`
// Attachments to display alongside the input fields.
Attachments []*LoginUserInputAttachment `json:"attachments"`
}
type LoginUserInputAttachment struct {
Type event.MessageType `json:"type,omitempty"`
FileName string `json:"filename,omitempty"`
Content []byte `json:"content,omitempty"`
Info LoginUserInputAttachmentInfo `json:"info,omitempty"`
}
type LoginUserInputAttachmentInfo struct {
MimeType string `json:"mimetype,omitempty"`
Width int `json:"w,omitempty"`
Height int `json:"h,omitempty"`
Size int `json:"size,omitempty"`
}
type LoginCompleteParams struct {

View file

@ -10,34 +10,34 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"unsafe"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
_ "go.mau.fi/util/dbutil/litestream"
"go.mau.fi/util/exbytes"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"golang.org/x/sync/semaphore"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/mediaproxy"
@ -81,8 +81,6 @@ type Connector struct {
MediaConfig mautrix.RespMediaConfig
SpecVersions *mautrix.RespVersions
SpecCaps *mautrix.RespCapabilities
specCapsLock sync.Mutex
Capabilities *bridgev2.MatrixCapabilities
IgnoreUnsupportedServer bool
@ -104,7 +102,6 @@ type Connector struct {
var (
_ bridgev2.MatrixConnector = (*Connector)(nil)
_ bridgev2.MatrixConnectorWithServer = (*Connector)(nil)
_ bridgev2.MatrixConnectorWithArbitraryRoomState = (*Connector)(nil)
_ bridgev2.MatrixConnectorWithPostRoomBridgeHandling = (*Connector)(nil)
_ bridgev2.MatrixConnectorWithPublicMedia = (*Connector)(nil)
_ bridgev2.MatrixConnectorWithNameDisambiguation = (*Connector)(nil)
@ -144,20 +141,13 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) {
br.EventProcessor.On(event.EventReaction, br.handleRoomEvent)
br.EventProcessor.On(event.EventRedaction, br.handleRoomEvent)
br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent)
br.EventProcessor.On(event.EphemeralEventEncrypted, br.handleEncryptedEvent)
br.EventProcessor.On(event.StateMember, br.handleRoomEvent)
br.EventProcessor.On(event.StatePowerLevels, br.handleRoomEvent)
br.EventProcessor.On(event.StateRoomName, br.handleRoomEvent)
br.EventProcessor.On(event.BeeperSendState, br.handleRoomEvent)
br.EventProcessor.On(event.StateRoomAvatar, br.handleRoomEvent)
br.EventProcessor.On(event.StateTopic, br.handleRoomEvent)
br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent)
br.EventProcessor.On(event.StateBeeperDisappearingTimer, br.handleRoomEvent)
br.EventProcessor.On(event.BeeperDeleteChat, br.handleRoomEvent)
br.EventProcessor.On(event.BeeperAcceptMessageRequest, br.handleRoomEvent)
br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent)
br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent)
br.EventProcessor.On(event.BeeperEphemeralEventAIStream, br.handleEphemeralEvent)
br.Bot = br.AS.BotIntent()
br.Crypto = NewCryptoHelper(br)
br.Bridge.Commands.(*commands.Processor).AddHandlers(
@ -179,17 +169,6 @@ func (br *Connector) Start(ctx context.Context) error {
if err != nil {
return err
}
needsStateResync := br.Config.Encryption.Default &&
br.Bridge.DB.KV.Get(ctx, database.KeyEncryptionStateResynced) != "true"
if needsStateResync {
dbExists, err := br.StateStore.TableExists(ctx, "mx_version")
if err != nil {
return fmt.Errorf("failed to check if mx_version table exists: %w", err)
} else if !dbExists {
needsStateResync = false
br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true")
}
}
err = br.StateStore.Upgrade(ctx)
if err != nil {
return bridgev2.DBUpgradeError{Section: "matrix_state", Err: err}
@ -233,59 +212,17 @@ func (br *Connector) Start(ctx context.Context) error {
br.wsStopPinger = make(chan struct{}, 1)
go br.websocketServerPinger()
}
if needsStateResync {
br.ResyncEncryptionState(ctx)
}
return nil
}
func (br *Connector) ResyncEncryptionState(ctx context.Context) {
log := zerolog.Ctx(ctx)
roomIDScanner := dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID])
rooms, err := roomIDScanner.NewRowIter(br.Bridge.DB.Query(ctx, `
SELECT rooms.room_id
FROM (SELECT DISTINCT(room_id) FROM mx_user_profile WHERE room_id<>'') rooms
LEFT JOIN mx_room_state ON rooms.room_id = mx_room_state.room_id
WHERE mx_room_state.encryption IS NULL
`)).AsList()
if err != nil {
log.Err(err).Msg("Failed to get room list to resync state")
return
}
var failedCount, successCount, forbiddenCount int
for _, roomID := range rooms {
if roomID == "" {
continue
}
var outContent *event.EncryptionEventContent
err = br.Bot.Client.StateEvent(ctx, roomID, event.StateEncryption, "", &outContent)
if errors.Is(err, mautrix.MForbidden) {
// Most likely non-existent room
log.Debug().Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room")
forbiddenCount++
} else if err != nil {
log.Err(err).Stringer("room_id", roomID).Msg("Failed to get state for room")
failedCount++
} else {
successCount++
}
}
br.Bridge.DB.KV.Set(ctx, database.KeyEncryptionStateResynced, "true")
log.Info().
Int("success_count", successCount).
Int("forbidden_count", forbiddenCount).
Int("failed_count", failedCount).
Msg("Resynced rooms")
}
func (br *Connector) GetPublicAddress() string {
if br.Config.AppService.PublicAddress == "https://bridge.example.com" {
return ""
}
return strings.TrimRight(br.Config.AppService.PublicAddress, "/")
return br.Config.AppService.PublicAddress
}
func (br *Connector) GetRouter() *http.ServeMux {
func (br *Connector) GetRouter() *mux.Router {
if br.GetPublicAddress() != "" {
return br.AS.Router
}
@ -296,37 +233,13 @@ func (br *Connector) GetCapabilities() *bridgev2.MatrixCapabilities {
return br.Capabilities
}
func sendStopSignal(ch chan struct{}) {
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
}
func (br *Connector) PreStop() {
func (br *Connector) Stop() {
br.stopping = true
br.AS.Stop()
if stopWebsocket := br.AS.StopWebsocket; stopWebsocket != nil {
stopWebsocket(appservice.ErrWebsocketManualStop)
}
sendStopSignal(br.wsStopPinger)
sendStopSignal(br.wsShortCircuitReconnectBackoff)
}
func (br *Connector) Stop() {
br.EventProcessor.Stop()
if br.Crypto != nil {
br.Crypto.Stop()
}
if wsStopChan := br.wsStopped; wsStopChan != nil {
select {
case <-wsStopChan:
case <-time.After(4 * time.Second):
br.Log.Warn().Msg("Timed out waiting for websocket to close")
}
}
}
var MinSpecVersion = mautrix.SpecV14
@ -344,18 +257,16 @@ func (br *Connector) logInitialRequestError(err error, defaultMessage string) {
}
func (br *Connector) ensureConnection(ctx context.Context) {
triedToRegister := false
for {
versions, err := br.Bot.Versions(ctx)
if err != nil {
if errors.Is(err, mautrix.MForbidden) && !triedToRegister {
if errors.Is(err, mautrix.MForbidden) {
br.Log.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying")
err = br.Bot.EnsureRegistered(ctx)
if err != nil {
br.logInitialRequestError(err, "Failed to register after /versions failed with M_FORBIDDEN")
os.Exit(16)
}
triedToRegister = true
} else if errors.Is(err, mautrix.MUnknownToken) || errors.Is(err, mautrix.MExclusive) {
br.logInitialRequestError(err, "/versions request failed with auth error")
os.Exit(16)
@ -368,9 +279,6 @@ func (br *Connector) ensureConnection(ctx context.Context) {
*br.AS.SpecVersions = *versions
br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites)
br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending)
br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange)
br.Capabilities.ExtraProfileMeta = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) ||
(br.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && br.Config.Matrix.GhostExtraProfileInfo)
break
}
}
@ -411,23 +319,50 @@ func (br *Connector) ensureConnection(ctx context.Context) {
br.Log.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection")
return
}
br.Bot.EnsureAppserviceConnection(ctx)
}
func (br *Connector) fetchCapabilities(ctx context.Context) *mautrix.RespCapabilities {
br.specCapsLock.Lock()
defer br.specCapsLock.Unlock()
if br.SpecCaps != nil {
return br.SpecCaps
var pingResp *mautrix.RespAppservicePing
var txnID string
var retryCount int
const maxRetries = 6
for {
txnID = br.Bot.TxnID()
pingResp, err = br.Bot.AppservicePing(ctx, br.Config.AppService.ID, txnID)
if err == nil {
break
}
var httpErr mautrix.HTTPError
var pingErrBody string
if errors.As(err, &httpErr) && httpErr.RespError != nil {
if val, ok := httpErr.RespError.ExtraData["body"].(string); ok {
pingErrBody = strings.TrimSpace(val)
}
}
outOfRetries := retryCount >= maxRetries
level := zerolog.ErrorLevel
if outOfRetries {
level = zerolog.FatalLevel
}
evt := br.Log.WithLevel(level).Err(err).Str("txn_id", txnID)
if pingErrBody != "" {
bodyBytes := []byte(pingErrBody)
if json.Valid(bodyBytes) {
evt.RawJSON("body", bodyBytes)
} else {
evt.Str("body", pingErrBody)
}
}
if outOfRetries {
evt.Msg("Homeserver -> bridge connection is not working")
br.Log.Info().Msg("See https://docs.mau.fi/faq/as-ping for more info")
os.Exit(13)
}
evt.Msg("Homeserver -> bridge connection is not working, retrying in 5 seconds...")
time.Sleep(5 * time.Second)
retryCount++
}
caps, err := br.Bot.Capabilities(ctx)
if err != nil {
br.Log.Err(err).Msg("Failed to fetch capabilities from homeserver")
return nil
}
br.SpecCaps = caps
return caps
br.Log.Debug().
Str("txn_id", txnID).
Int64("duration_ms", pingResp.DurationMS).
Msg("Homeserver -> bridge connection works")
}
func (br *Connector) fetchMediaConfig(ctx context.Context) {
@ -495,15 +430,11 @@ func (br *Connector) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI {
func (br *Connector) SendBridgeStatus(ctx context.Context, state *status.BridgeState) error {
if br.Websocket {
br.hasSentAnyStates = true
return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{
return br.AS.SendWebsocket(&appservice.WebsocketRequest{
Command: "bridge_status",
Data: state,
})
} else if br.Config.Homeserver.StatusEndpoint != "" {
// Connecting states aren't really relevant unless the bridge runs somewhere with an unreliable network
if state.StateEvent == status.StateConnecting {
return nil
}
return state.SendHTTP(ctx, br.Config.Homeserver.StatusEndpoint, br.Config.AppService.ASToken)
} else {
return nil
@ -519,17 +450,13 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2
return ""
}
log := zerolog.Ctx(ctx)
if !evt.IsSourceEventDoublePuppeted {
err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{ms.ToCheckpoint(evt)})
if err != nil {
log.Err(err).Msg("Failed to send message checkpoint")
}
err := br.SendMessageCheckpoints([]*status.MessageCheckpoint{ms.ToCheckpoint(evt)})
if err != nil {
log.Err(err).Msg("Failed to send message checkpoint")
}
if !ms.DisableMSS && br.Config.Matrix.MessageStatusEvents {
mssEvt := ms.ToMSSEvent(evt)
_, err := br.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, mssEvt)
_, err = br.Bot.SendMessageEvent(ctx, evt.RoomID, event.BeeperMessageStatus, mssEvt)
if err != nil {
log.Err(err).
Stringer("room_id", evt.RoomID).
@ -538,8 +465,7 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2
Msg("Failed to send MSS event")
}
}
if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && evt.MessageType != event.MsgNotice &&
(ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) {
if ms.SendNotice && br.Config.Matrix.MessageErrorNotices && (ms.Status == event.MessageStatusFail || ms.Status == event.MessageStatusRetriable || ms.Step == status.MsgStepDecrypted) {
content := ms.ToNoticeEvent(evt)
if editEvent != "" {
content.SetEdit(editEvent)
@ -556,7 +482,7 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2
}
}
if ms.Status == event.MessageStatusSuccess && br.Config.Matrix.DeliveryReceipts {
err := br.Bot.SendReceipt(ctx, evt.RoomID, evt.SourceEventID, event.ReceiptTypeRead, nil)
err = br.Bot.SendReceipt(ctx, evt.RoomID, evt.SourceEventID, event.ReceiptTypeRead, nil)
if err != nil {
log.Err(err).
Stringer("room_id", evt.RoomID).
@ -567,11 +493,11 @@ func (br *Connector) internalSendMessageStatus(ctx context.Context, ms *bridgev2
return ""
}
func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []*status.MessageCheckpoint) error {
func (br *Connector) SendMessageCheckpoints(checkpoints []*status.MessageCheckpoint) error {
checkpointsJSON := status.CheckpointsJSON{Checkpoints: checkpoints}
if br.Websocket {
return br.AS.SendWebsocket(ctx, &appservice.WebsocketRequest{
return br.AS.SendWebsocket(&appservice.WebsocketRequest{
Command: "message_checkpoint",
Data: checkpointsJSON,
})
@ -582,7 +508,7 @@ func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []*
return nil
}
return checkpointsJSON.SendHTTP(ctx, br.AS.HTTPClient, endpoint, br.AS.Registration.AppToken)
return checkpointsJSON.SendHTTP(endpoint, br.AS.Registration.AppToken)
}
func (br *Connector) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) {
@ -622,31 +548,6 @@ func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*eve
return br.Bot.PowerLevels(ctx, roomID)
}
func (br *Connector) GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error) {
if stateKey == "" {
switch eventType {
case event.StateCreate:
createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID)
if err != nil || createEvt != nil {
return createEvt, err
}
case event.StateJoinRules:
joinRulesContent, err := br.Bot.StateStore.GetJoinRules(ctx, roomID)
if err != nil {
return nil, err
} else if joinRulesContent != nil {
return &event.Event{
Type: event.StateJoinRules,
RoomID: roomID,
StateKey: ptr.Ptr(""),
Content: event.Content{Parsed: joinRulesContent},
}, nil
}
}
}
return br.Bot.FullStateEvent(ctx, roomID, eventType, stateKey)
}
func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) {
fetched, err := br.Bot.StateStore.HasFetchedMembers(ctx, roomID)
if err != nil {
@ -674,10 +575,6 @@ func (br *Connector) IsConfusableName(ctx context.Context, roomID id.RoomID, use
return br.AS.StateStore.IsConfusableName(ctx, roomID, userID, name)
}
func (br *Connector) GetUniqueBridgeID() string {
return fmt.Sprintf("%s/%s", br.Config.Homeserver.Domain, br.Config.AppService.ID)
}
func (br *Connector) BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*bridgev2.MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error) {
if encrypted, err := br.StateStore.IsEncrypted(ctx, roomID); err != nil {
return nil, fmt.Errorf("failed to check if room is encrypted: %w", err)
@ -687,7 +584,7 @@ func (br *Connector) BatchSend(ctx context.Context, roomID id.RoomID, req *mautr
if intent != nil {
intent.AddDoublePuppetValueWithTS(&evt.Content, evt.Timestamp)
}
if evt.Type != event.EventEncrypted && evt.Type != event.EventReaction {
if evt.Type != event.EventEncrypted {
err = br.Crypto.Encrypt(ctx, roomID, evt.Type, &evt.Content)
if err != nil {
return nil, err
@ -719,11 +616,7 @@ func (br *Connector) GenerateDeterministicEventID(roomID id.RoomID, _ networkid.
eventID[1+hashB64Len] = ':'
copy(eventID[1+hashB64Len+1:], br.deterministicEventIDServer)
return id.EventID(exbytes.UnsafeString(eventID))
}
func (br *Connector) GenerateDeterministicRoomID(key networkid.PortalKey) id.RoomID {
return id.RoomID(fmt.Sprintf("!%s.%s:%s", key.ID, key.Receiver, br.ServerName()))
return id.EventID(unsafe.String(unsafe.SliceData(eventID), len(eventID)))
}
func (br *Connector) GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID {

View file

@ -14,7 +14,6 @@ import (
"fmt"
"os"
"runtime/debug"
"strings"
"sync"
"time"
@ -24,7 +23,6 @@ import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/event"
@ -38,9 +36,9 @@ func init() {
var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil)
var NoSessionFound = crypto.ErrNoSessionFound
var DuplicateMessageIndex = crypto.ErrDuplicateMessageIndex
var UnknownMessageIndex = olm.ErrUnknownMessageIndex
var NoSessionFound = crypto.NoSessionFound
var DuplicateMessageIndex = crypto.DuplicateMessageIndex
var UnknownMessageIndex = olm.UnknownMessageIndex
type CryptoHelper struct {
bridge *Connector
@ -79,7 +77,7 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()),
string(helper.bridge.Bridge.ID),
helper.bridge.AS.BotMXID(),
fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain),
fmt.Sprintf("@%s:%s", helper.bridge.Config.AppService.FormatUsername("%"), helper.bridge.AS.HomeserverDomain),
helper.bridge.Config.Encryption.PickleKey,
)
@ -136,19 +134,7 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
return err
}
if isExistingDevice {
if !helper.verifyKeysAreOnServer(ctx) {
return nil
}
} else {
err = helper.ShareKeys(ctx)
if err != nil {
return fmt.Errorf("failed to share device keys: %w", err)
}
}
if helper.bridge.Config.Encryption.SelfSign {
if !helper.doSelfSign(ctx) {
os.Exit(34)
}
helper.verifyKeysAreOnServer(ctx)
}
go helper.resyncEncryptionInfo(context.TODO())
@ -156,66 +142,30 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
return nil
}
func (helper *CryptoHelper) doSelfSign(ctx context.Context) bool {
log := zerolog.Ctx(ctx)
hasKeys, isVerified, err := helper.mach.GetOwnVerificationStatus(ctx)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check verification status")
return false
}
log.Debug().Bool("has_keys", hasKeys).Bool("is_verified", isVerified).Msg("Checked verification status")
keyInDB := helper.bridge.Bridge.DB.KV.Get(ctx, database.KeyRecoveryKey)
if !hasKeys || keyInDB == "overwrite" {
if keyInDB != "" && keyInDB != "overwrite" {
log.WithLevel(zerolog.FatalLevel).
Msg("No keys on server, but database already has recovery key. Delete `recovery_key` from `kv_store` manually to continue.")
return false
}
recoveryKey, err := helper.mach.GenerateAndVerifyWithRecoveryKey(ctx)
if recoveryKey != "" {
helper.bridge.Bridge.DB.KV.Set(ctx, database.KeyRecoveryKey, recoveryKey)
}
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to generate recovery key and self-sign")
return false
}
log.Info().Msg("Generated new recovery key and self-signed bot device")
} else if !isVerified {
if keyInDB == "" {
log.WithLevel(zerolog.FatalLevel).
Msg("Server already has cross-signing keys, but no key in database. Add `recovery_key` to `kv_store`, or set it to `overwrite` to generate new keys.")
return false
}
err = helper.mach.VerifyWithRecoveryKey(ctx, keyInDB)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to verify with recovery key")
return false
}
log.Info().Msg("Verified bot device with existing recovery key")
}
return true
}
func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
log := helper.log.With().Str("action", "resync encryption event").Logger()
rows, err := helper.store.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`)
roomIDs, err := dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.RoomID], err).AsList()
if err != nil {
log.Err(err).Msg("Failed to query rooms for resync")
return
}
roomIDs, err := dbutil.NewRowIter(rows, dbutil.ScanSingleColumn[id.RoomID]).AsList()
if err != nil {
log.Err(err).Msg("Failed to scan rooms for resync")
return
}
if len(roomIDs) > 0 {
log.Debug().Interface("room_ids", roomIDs).Msg("Resyncing rooms")
for _, roomID := range roomIDs {
var evt event.EncryptionEventContent
err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt)
if err != nil {
log.Err(err).Stringer("room_id", roomID).Msg("Failed to get encryption event")
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to get encryption event")
_, err = helper.store.DB.Exec(ctx, `
UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}'
`, roomID)
if err != nil {
log.Err(err).Stringer("room_id", roomID).Msg("Failed to unmark room for resync after failed sync")
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to unmark room for resync after failed sync")
}
} else {
maxAge := evt.RotationPeriodMillis
@ -238,9 +188,9 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL
`, maxAge, maxMessages, roomID)
if err != nil {
log.Err(err).Stringer("room_id", roomID).Msg("Failed to update megolm session table")
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to update megolm session table")
} else {
log.Debug().Stringer("room_id", roomID).Msg("Updated megolm session table")
log.Debug().Str("room_id", roomID.String()).Msg("Updated megolm session table")
}
}
}
@ -253,7 +203,7 @@ func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device
return &crypto.KeyShareRejectNoResponse
} else if device.Trust == id.TrustStateBlacklisted {
return &crypto.KeyShareRejectBlacklisted
} else if trustState, _ := helper.mach.ResolveTrustContext(ctx, device); trustState >= cfg.VerificationLevels.Share {
} else if trustState := helper.mach.ResolveTrust(device); trustState >= cfg.VerificationLevels.Share {
portal, err := helper.bridge.Bridge.GetPortalByMXID(ctx, info.RoomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal to handle key request")
@ -286,14 +236,14 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool
if err != nil {
return nil, false, fmt.Errorf("failed to find existing device ID: %w", err)
} else if len(deviceID) > 0 {
helper.log.Debug().Stringer("device_id", deviceID).Msg("Found existing device ID for bot in database")
helper.log.Debug().Str("device_id", deviceID.String()).Msg("Found existing device ID for bot in database")
}
// Create a new client instance with the default AS settings (including as_token),
// the Login call will then override the access token in the client.
client := helper.bridge.AS.NewMautrixClient(helper.bridge.AS.BotMXID())
initialDeviceDisplayName := fmt.Sprintf("%s bridge", helper.bridge.Bridge.Network.GetName().DisplayName)
if helper.bridge.Config.Encryption.MSC4190 {
if helper.bridge.Config.AppService.MSC4190 {
helper.log.Debug().Msg("Creating bot device with MSC4190")
err = client.CreateDeviceMSC4190(ctx, deviceID, initialDeviceDisplayName)
if err != nil {
@ -327,7 +277,7 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool
return client, deviceID != "", nil
}
func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) bool {
func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) {
helper.log.Debug().Msg("Making sure keys are still on server")
resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{
DeviceKeys: map[id.UserID]mautrix.DeviceIDList{
@ -340,11 +290,10 @@ func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) bool {
}
device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID]
if ok && len(device.Keys) > 0 {
return true
return
}
helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto")
helper.Reset(ctx, false)
return false
}
func (helper *CryptoHelper) Start() {
@ -439,7 +388,7 @@ func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtTy
var encrypted *event.EncryptedEventContent
encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content)
if err != nil {
if !errors.Is(err, crypto.ErrSessionExpired) && !errors.Is(err, crypto.ErrSessionNotShared) && !errors.Is(err, crypto.ErrNoGroupSession) {
if !errors.Is(err, crypto.SessionExpired) && !errors.Is(err, crypto.SessionNotShared) && !errors.Is(err, crypto.NoGroupSession) {
return
}
helper.log.Debug().Err(err).
@ -554,14 +503,14 @@ func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.D
func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
everything := []event.Type{{Type: "*"}}
return &mautrix.Filter{
Presence: &mautrix.FilterPart{NotTypes: everything},
AccountData: &mautrix.FilterPart{NotTypes: everything},
Room: &mautrix.RoomFilter{
Presence: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
Room: mautrix.RoomFilter{
IncludeLeave: false,
Ephemeral: &mautrix.FilterPart{NotTypes: everything},
AccountData: &mautrix.FilterPart{NotTypes: everything},
State: &mautrix.FilterPart{NotTypes: everything},
Timeline: &mautrix.FilterPart{NotTypes: everything},
Ephemeral: mautrix.FilterPart{NotTypes: everything},
AccountData: mautrix.FilterPart{NotTypes: everything},
State: mautrix.FilterPart{NotTypes: everything},
Timeline: mautrix.FilterPart{NotTypes: everything},
},
}
}

View file

@ -11,8 +11,8 @@ import (
"errors"
"fmt"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)

View file

@ -45,7 +45,7 @@ func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context,
WHERE room_id=$1
AND (membership='join' OR membership='invite')
AND user_id<>$2
AND user_id NOT LIKE $3 ESCAPE '\'
AND user_id NOT LIKE $3
`, roomID, store.UserID, store.GhostIDFormat)
if err != nil {
return

View file

@ -39,7 +39,7 @@ func (br *Connector) initDirectMedia() error {
if err != nil {
return fmt.Errorf("failed to initialize media proxy: %w", err)
}
br.MediaProxy.RegisterRoutes(br.AS.Router, br.Log.With().Str("component", "media proxy").Logger())
br.MediaProxy.RegisterRoutes(br.AS.Router)
br.dmaSigKey = sha256.Sum256(br.MediaProxy.GetServerKey().Priv.Seed())
dmn.SetUseDirectMedia()
br.Log.Debug().Str("server_name", br.MediaProxy.GetServerName()).Msg("Enabled direct media access")

View file

@ -9,7 +9,6 @@ package matrix
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -28,7 +27,6 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/crypto/canonicaljson"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
@ -45,13 +43,13 @@ type ASIntent struct {
var _ bridgev2.MatrixAPI = (*ASIntent)(nil)
var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil)
var _ bridgev2.EphemeralSendingMatrixAPI = (*ASIntent)(nil)
func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) {
if extra == nil {
extra = &bridgev2.MatrixSendExtra{}
}
if eventType == event.EventRedaction && !as.Connector.SpecVersions.Supports(mautrix.FeatureRedactSendAsEvent) {
// TODO remove this once hungryserv and synapse support sending m.room.redactions directly in all room versions
if eventType == event.EventRedaction {
parsedContent := content.Parsed.(*event.RedactionEventContent)
as.Matrix.AddDoublePuppetValue(content)
return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{
@ -59,11 +57,7 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType
Extra: content.Raw,
})
}
if (eventType != event.EventReaction || as.Connector.Config.Encryption.MSC4392) && eventType != event.EventRedaction {
msgContent, ok := content.Parsed.(*event.MessageEventContent)
if ok {
msgContent.AddPerMessageProfileFallback()
}
if eventType != event.EventReaction && eventType != event.EventRedaction {
if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil {
return nil, fmt.Errorf("failed to check if room is encrypted: %w", err)
} else if encrypted {
@ -84,27 +78,16 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType
eventType = event.EventEncrypted
}
}
return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()})
}
func (as *ASIntent) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error) {
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) {
return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support")
if extra.Timestamp.IsZero() {
return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content)
} else {
return as.Matrix.SendMassagedMessageEvent(ctx, roomID, eventType, content, extra.Timestamp.UnixMilli())
}
if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil {
return nil, fmt.Errorf("failed to check if room is encrypted: %w", err)
} else if encrypted && as.Connector.Crypto != nil {
if err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content); err != nil {
return nil, err
}
eventType = event.EventEncrypted
}
return as.Matrix.BeeperSendEphemeralEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{TransactionID: txnID})
}
func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) {
targetContent, ok := content.Parsed.(*event.MemberEventContent)
if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" {
targetContent := content.Parsed.(*event.MemberEventContent)
if targetContent.Displayname != "" || targetContent.AvatarURL != "" {
return
}
memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID)
@ -139,7 +122,11 @@ func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType e
if eventType == event.StateMember {
as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content)
}
resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: ts.UnixMilli()})
if ts.IsZero() {
resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content)
} else {
resp, err = as.Matrix.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, content, ts.UnixMilli())
}
if err != nil && eventType == event.StateMember {
var httpErr mautrix.HTTPError
if errors.As(err, &httpErr) && httpErr.RespError != nil &&
@ -406,13 +393,9 @@ func (as *ASIntent) UploadMediaStream(
err = fmt.Errorf("failed to get temp file info: %w", err)
return
}
size = info.Size()
if size > as.Connector.MediaConfig.UploadSize {
return "", nil, fmt.Errorf("file too large (%.2f MB > %.2f MB)", float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000)
}
req := mautrix.ReqUploadMedia{
Content: replFile,
ContentLength: size,
ContentLength: info.Size(),
ContentType: res.MimeType,
FileName: res.FileName,
}
@ -421,7 +404,6 @@ func (as *ASIntent) UploadMediaStream(
removeAndClose(replFile)
removeAndClose(tempFile)
}
req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx)
startedAsyncUpload = true
var resp *mautrix.RespCreateMXC
resp, err = as.Matrix.UploadAsync(ctx, req)
@ -454,7 +436,6 @@ func (as *ASIntent) doUploadReq(ctx context.Context, file *event.EncryptedFileIn
as.Connector.uploadSema.Release(int64(len(req.ContentBytes)))
}
}
req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx)
var resp *mautrix.RespCreateMXC
resp, err = as.Matrix.UploadAsync(ctx, req)
if resp != nil {
@ -486,78 +467,19 @@ func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIStr
return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL)
}
func dataToFields(data any) (map[string]json.RawMessage, error) {
fields, ok := data.(map[string]json.RawMessage)
if ok {
return fields, nil
}
d, err := json.Marshal(data)
if err != nil {
return nil, err
}
d = canonicaljson.CanonicalJSONAssumeValid(d)
err = json.Unmarshal(d, &fields)
return fields, err
}
func marshalField(val any) json.RawMessage {
data, _ := json.Marshal(val)
if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
return canonicaljson.CanonicalJSONAssumeValid(data)
}
return data
}
var nullJSON = json.RawMessage("null")
func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error {
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
return as.Matrix.BeeperUpdateProfile(ctx, data)
} else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo {
fields, err := dataToFields(data)
if err != nil {
return fmt.Errorf("failed to marshal fields: %w", err)
}
currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID)
if err != nil {
return fmt.Errorf("failed to get current profile: %w", err)
}
for key, val := range fields {
existing, ok := currentProfile.Extra[key]
if !ok {
if bytes.Equal(val, nullJSON) {
continue
}
err = as.Matrix.SetProfileField(ctx, key, val)
} else if !bytes.Equal(marshalField(existing), val) {
if bytes.Equal(val, nullJSON) {
err = as.Matrix.DeleteProfileField(ctx, key)
} else {
err = as.Matrix.SetProfileField(ctx, key, val)
}
}
if err != nil {
return fmt.Errorf("failed to set profile field %q: %w", key, err)
}
}
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
return nil
}
return nil
return as.Matrix.BeeperUpdateProfile(ctx, data)
}
func (as *ASIntent) GetMXID() id.UserID {
return as.Matrix.UserID
}
func (as *ASIntent) IsDoublePuppet() bool {
return as.Matrix.IsDoublePuppet()
}
func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error {
var params bridgev2.EnsureJoinedParams
if len(extra) > 0 {
params = extra[0]
}
err := as.Matrix.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{Via: params.Via})
func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID) error {
err := as.Matrix.EnsureJoined(ctx, roomID)
if err != nil {
return err
}
@ -583,39 +505,6 @@ func (br *Connector) getDefaultEncryptionEvent() *event.EncryptionEventContent {
return content
}
func (as *ASIntent) filterCreateRequestForV12(ctx context.Context, req *mautrix.ReqCreateRoom) {
if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
// Hungryserv doesn't override the capabilities endpoint nor do room versions
return
}
caps := as.Connector.fetchCapabilities(ctx)
roomVer := req.RoomVersion
if roomVer == "" && caps != nil && caps.RoomVersions != nil {
roomVer = id.RoomVersion(caps.RoomVersions.Default)
}
if roomVer != "" && !roomVer.PrivilegedRoomCreators() {
return
}
creators, _ := req.CreationContent["additional_creators"].([]id.UserID)
creators = append(slices.Clone(creators), as.GetMXID())
if req.PowerLevelOverride != nil {
for _, creator := range creators {
delete(req.PowerLevelOverride.Users, creator)
}
}
for _, evt := range req.InitialState {
if evt.Type != event.StatePowerLevels {
continue
}
content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent)
if ok {
for _, creator := range creators {
delete(content.Users, creator)
}
}
}
}
func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) {
if as.Connector.Config.Encryption.Default {
req.InitialState = append(req.InitialState, &event.Event{
@ -631,7 +520,6 @@ func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom)
}
req.CreationContent["m.federate"] = false
}
as.filterCreateRequestForV12(ctx, req)
resp, err := as.Matrix.CreateRoom(ctx, req)
if err != nil {
return "", err
@ -673,19 +561,8 @@ func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id.
}
func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error {
if roomID == "" {
return nil
}
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
err := as.Matrix.BeeperDeleteRoom(ctx, roomID)
if err != nil {
return err
}
err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal")
}
return nil
return as.Matrix.BeeperDeleteRoom(ctx, roomID)
}
members, err := as.Matrix.JoinedMembers(ctx, roomID)
if err != nil {
@ -773,23 +650,3 @@ func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.T
})
}
}
func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) {
evt, err := as.Matrix.Client.GetEvent(ctx, roomID, eventID)
if err != nil {
return nil, err
}
err = evt.Content.ParseRaw(evt.Type)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("failed to parse event content")
}
if evt.Type == event.EventEncrypted {
if as.Connector.Crypto == nil || as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt {
return nil, errors.New("can't decrypt the event")
}
return as.Connector.Crypto.Decrypt(ctx, evt)
}
return evt, nil
}

View file

@ -17,8 +17,8 @@ import (
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -27,11 +27,6 @@ func (br *Connector) handleRoomEvent(ctx context.Context, evt *event.Event) {
if br.shouldIgnoreEvent(evt) {
return
}
if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents && evt.Type != event.StateMember {
zerolog.Ctx(ctx).Debug().Msg("Dropping event from user with no permission to send events")
br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt))
return
}
if (evt.Type == event.EventMessage || evt.Type == event.EventSticker) && !evt.Mautrix.WasEncrypted && br.Config.Encryption.Require {
zerolog.Ctx(ctx).Warn().Msg("Dropping unencrypted event as encryption is configured to be required")
br.sendCryptoStatusError(ctx, evt, errMessageNotEncrypted, nil, 0, true)
@ -68,10 +63,6 @@ func (br *Connector) handleEphemeralEvent(ctx context.Context, evt *event.Event)
case event.EphemeralEventTyping:
typingContent := evt.Content.AsTyping()
typingContent.UserIDs = slices.DeleteFunc(typingContent.UserIDs, br.shouldIgnoreEventFromUser)
case event.BeeperEphemeralEventAIStream:
if br.shouldIgnoreEvent(evt) {
return
}
}
br.Bridge.QueueMatrixEvent(ctx, evt)
}
@ -85,11 +76,6 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event)
Str("event_id", evt.ID.String()).
Str("session_id", content.SessionID.String()).
Logger()
if !br.Config.Bridge.Permissions.Get(evt.Sender).SendEvents {
log.Debug().Msg("Dropping event from user with no permission to send events")
br.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt))
return
}
ctx = log.WithContext(ctx)
if br.Crypto == nil {
br.sendCryptoStatusError(ctx, evt, errNoCrypto, nil, 0, true)
@ -101,18 +87,17 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event)
decryptionStart := time.Now()
decrypted, err := br.Crypto.Decrypt(ctx, evt)
decryptionRetryCount := 0
var errorEventID id.EventID
if errors.Is(err, NoSessionFound) {
decryptionRetryCount = 1
log.Debug().
Int("wait_seconds", int(initialSessionWaitTimeout.Seconds())).
Msg("Couldn't find session, waiting for keys to arrive...")
go br.sendCryptoStatusError(ctx, evt, err, &errorEventID, 0, false)
go br.sendCryptoStatusError(ctx, evt, err, nil, 0, false)
if br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWaitTimeout) {
log.Debug().Msg("Got keys after waiting, trying to decrypt event again")
decrypted, err = br.Crypto.Decrypt(ctx, evt)
} else {
go br.waitLongerForSession(ctx, evt, decryptionStart, &errorEventID)
go br.waitLongerForSession(ctx, evt, decryptionStart)
return
}
}
@ -121,18 +106,18 @@ func (br *Connector) handleEncryptedEvent(ctx context.Context, evt *event.Event)
go br.sendCryptoStatusError(ctx, evt, err, nil, decryptionRetryCount, true)
return
}
br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, &errorEventID, time.Since(decryptionStart))
br.postDecrypt(ctx, evt, decrypted, decryptionRetryCount, nil, time.Since(decryptionStart))
}
func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time, errorEventID *id.EventID) {
func (br *Connector) waitLongerForSession(ctx context.Context, evt *event.Event, decryptionStart time.Time) {
log := zerolog.Ctx(ctx)
content := evt.Content.AsEncrypted()
log.Debug().
Int("wait_seconds", int(extendedSessionWaitTimeout.Seconds())).
Msg("Couldn't find session, requesting keys and waiting longer...")
//lint:ignore SA1019 RequestSession will gracefully request from all devices if DeviceID is blank
go br.Crypto.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
var errorEventID *id.EventID
go br.sendCryptoStatusError(ctx, evt, fmt.Errorf("%w. The bridge will retry for %d seconds", errNoDecryptionKeys, int(extendedSessionWaitTimeout.Seconds())), errorEventID, 1, false)
if !br.Crypto.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWaitTimeout) {
@ -157,7 +142,7 @@ type CommandProcessor interface {
}
func (br *Connector) sendSuccessCheckpoint(ctx context.Context, evt *event.Event, step status.MessageCheckpointStep, retryNum int) {
err := br.SendMessageCheckpoints(ctx, []*status.MessageCheckpoint{{
err := br.SendMessageCheckpoints([]*status.MessageCheckpoint{{
RoomID: evt.RoomID,
EventID: evt.ID,
EventType: evt.Type,
@ -184,7 +169,7 @@ func (br *Connector) shouldIgnoreEventFromUser(userID id.UserID) bool {
}
func (br *Connector) shouldIgnoreEvent(evt *event.Event) bool {
if br.shouldIgnoreEventFromUser(evt.Sender) && evt.Type != event.StateTombstone {
if br.shouldIgnoreEventFromUser(evt.Sender) {
return true
}
dpVal, ok := evt.Content.Raw[appservice.DoublePuppetKey]
@ -235,6 +220,7 @@ func (br *Connector) postDecrypt(ctx context.Context, original, decrypted *event
go br.sendSuccessCheckpoint(ctx, decrypted, status.MsgStepDecrypted, retryCount)
decrypted.Mautrix.CheckpointSent = true
decrypted.Mautrix.DecryptionDuration = duration
decrypted.Mautrix.EventSource |= event.SourceDecrypted
br.EventProcessor.Dispatch(ctx, decrypted)
if errorEventID != nil && *errorEventID != "" {
_, _ = br.Bot.RedactEvent(ctx, decrypted.RoomID, *errorEventID)

View file

@ -66,12 +66,7 @@ func (br *BridgeMain) LogDBUpgradeErrorAndExit(name string, err error, message s
} else if errors.Is(err, dbutil.ErrForeignTables) {
br.Log.Info().Msg("See https://docs.mau.fi/faq/foreign-tables for more info")
} else if errors.Is(err, dbutil.ErrNotOwned) {
var noe dbutil.NotOwnedError
if errors.As(err, &noe) && noe.Owner == br.Name {
br.Log.Info().Msg("The database appears to be on a very old pre-megabridge schema. Perhaps you need to run an older version of the bridge with migration support first?")
} else {
br.Log.Info().Msg("Sharing the same database with different programs is not supported")
}
br.Log.Info().Msg("Sharing the same database with different programs is not supported")
} else if errors.Is(err, dbutil.ErrUnsupportedDatabaseVersion) {
br.Log.Info().Msg("Downgrading the bridge is not supported")
}

View file

@ -1,161 +0,0 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package mxmain
import (
"fmt"
"iter"
"os"
"reflect"
"strconv"
"strings"
"go.mau.fi/util/random"
)
var randomParseFilePrefix = random.String(16) + "READFILE:"
func parseEnv(prefix string) iter.Seq2[[]string, string] {
return func(yield func([]string, string) bool) {
for _, s := range os.Environ() {
if !strings.HasPrefix(s, prefix) {
continue
}
kv := strings.SplitN(s, "=", 2)
key := strings.TrimPrefix(kv[0], prefix)
value := kv[1]
if strings.HasSuffix(key, "_FILE") {
key = strings.TrimSuffix(key, "_FILE")
value = randomParseFilePrefix + value
}
key = strings.ToLower(key)
if !strings.ContainsRune(key, '.') {
key = strings.ReplaceAll(key, "__", ".")
}
if !yield(strings.Split(key, "."), value) {
return
}
}
}
}
func reflectYAMLFieldName(f *reflect.StructField) string {
parts := strings.SplitN(f.Tag.Get("yaml"), ",", 2)
fieldName := parts[0]
if fieldName == "-" && len(parts) == 1 {
return ""
}
if fieldName == "" {
return strings.ToLower(f.Name)
}
return fieldName
}
type reflectGetResult struct {
val reflect.Value
valKind reflect.Kind
remainingPath []string
}
func reflectGetYAML(rv reflect.Value, path []string) (*reflectGetResult, bool) {
if len(path) == 0 {
return &reflectGetResult{val: rv, valKind: rv.Kind()}, true
}
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Map:
return &reflectGetResult{val: rv, remainingPath: path, valKind: rv.Type().Elem().Kind()}, true
case reflect.Struct:
fields := reflect.VisibleFields(rv.Type())
for _, field := range fields {
fieldName := reflectYAMLFieldName(&field)
if fieldName != "" && fieldName == path[0] {
return reflectGetYAML(rv.FieldByIndex(field.Index), path[1:])
}
}
default:
}
return nil, false
}
func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectGetResult, bool) {
if len(path) > 0 && path[0] == "network" {
return reflectGetYAML(network, path[1:])
}
return reflectGetYAML(main, path)
}
func formatKeyString(key []string) string {
return strings.Join(key, "->")
}
func UpdateConfigFromEnv(cfg, networkData any, prefix string) error {
cfgVal := reflect.ValueOf(cfg)
networkVal := reflect.ValueOf(networkData)
for key, value := range parseEnv(prefix) {
field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, key)
if !ok {
return fmt.Errorf("%s not found", formatKeyString(key))
}
if strings.HasPrefix(value, randomParseFilePrefix) {
filepath := strings.TrimPrefix(value, randomParseFilePrefix)
fileData, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("failed to read file %s for %s: %w", filepath, formatKeyString(key), err)
}
value = strings.TrimSpace(string(fileData))
}
var parsedVal any
var err error
switch field.valKind {
case reflect.String:
parsedVal = value
case reflect.Bool:
parsedVal, err = strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err)
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
parsedVal, err = strconv.ParseInt(value, 10, 64)
if err != nil {
return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err)
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
parsedVal, err = strconv.ParseUint(value, 10, 64)
if err != nil {
return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err)
}
case reflect.Float32, reflect.Float64:
parsedVal, err = strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("invalid value for %s: %w", formatKeyString(key), err)
}
default:
return fmt.Errorf("unsupported type %s in %s", field.valKind, formatKeyString(key))
}
if field.val.Kind() == reflect.Ptr {
if field.val.IsNil() {
field.val.Set(reflect.New(field.val.Type().Elem()))
}
field.val = field.val.Elem()
}
if field.val.Kind() == reflect.Map {
key = key[:len(key)-len(field.remainingPath)]
mapKeyStr := strings.Join(field.remainingPath, ".")
key = append(key, mapKeyStr)
if field.val.Type().Key().Kind() != reflect.String {
return fmt.Errorf("unsupported map key type %s in %s", field.val.Type().Key().Kind(), formatKeyString(key))
}
field.val.SetMapIndex(reflect.ValueOf(mapKeyStr), reflect.ValueOf(parsedVal))
} else {
field.val.Set(reflect.ValueOf(parsedVal))
}
}
return nil
}

View file

@ -15,28 +15,12 @@ bridge:
# By default, users who are in the same group on the remote network will be
# in the same Matrix room bridged to that group. If this is set to true,
# every user will get their own Matrix room instead.
# SETTING THIS IS IRREVERSIBLE AND POTENTIALLY DESTRUCTIVE IF PORTALS ALREADY EXIST.
split_portals: false
# Should the bridge resend `m.bridge` events to all portals on startup?
resend_bridge_info: false
# Should `m.bridge` events be sent without a state key?
# By default, the bridge uses a unique key that won't conflict with other bridges.
no_bridge_info_state_key: false
# Should bridge connection status be sent to the management room as `m.notice` events?
# These contain the same data that can be posted to an external HTTP server using homeserver -> status_endpoint.
# Allowed values: none, errors, all
bridge_status_notices: errors
# How long after an unknown error should the bridge attempt a full reconnect?
# Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value.
unknown_error_auto_reconnect: null
# Maximum number of times to do the auto-reconnect above.
# The counter is per login, but is never reset except on logout and restart.
unknown_error_max_auto_reconnects: 10
# Should leaving Matrix rooms be bridged as leaving groups on the remote network?
bridge_matrix_leave: false
# Should `m.notice` messages be bridged?
bridge_notices: false
# Should room tags only be synced when creating the portal? Tags mean things like favorite/pin and archive/low priority.
# Tags currently can't be synced back to the remote network, so a continuous sync means tagging from Matrix will be undone.
tag_only_on_create: true
@ -45,16 +29,6 @@ bridge:
# Should room mute status only be synced when creating the portal?
# Like tags, mutes can't currently be synced back to the remote network.
mute_only_on_create: true
# Should the bridge check the db to ensure that incoming events haven't been handled before
deduplicate_matrix_messages: false
# Should cross-room reply metadata be bridged?
# Most Matrix clients don't support this and servers may reject such messages too.
cross_room_replies: false
# If a state event fails to bridge, should the bridge revert any state changes made by that event?
revert_failed_state_changes: false
# In portals with no relay set, should Matrix users be kicked if they're
# not logged into an account that's in the remote chat?
kick_matrix_users: true
# What should be done to portal rooms when a user logs out or is logged out?
# Permitted values:
@ -219,6 +193,10 @@ appservice:
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
# This value doesn't affect the registration file.
async_transactions: false
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
msc4190: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
@ -244,9 +222,6 @@ matrix:
# The threshold as bytes after which the bridge should roundtrip uploads via the disk
# rather than keeping the whole file in memory.
upload_file_threshold: 5242880
# Should the bridge set additional custom profile info for ghosts?
# This can make a lot of requests, as there's no batch profile update endpoint.
ghost_extra_profile_info: false
# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors.
analytics:
@ -259,8 +234,10 @@ analytics:
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate" or null, a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled. Must be at least 16 characters.
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Whether to allow provisioning API requests to be authed using Matrix access tokens.
# This follows the same rules as double puppeting to determine which server to contact to check the token,
@ -268,9 +245,6 @@ provisioning:
allow_matrix_auth: true
# Enable debug API at /debug with provisioning authentication.
debug_endpoints: false
# Enable session transfers between bridges. Note that this only validates Matrix or shared secret
# auth before passing live network client credentials down in the response.
enable_session_transfers: false
# Some networks require publicly accessible media download links (e.g. for user avatars when using Discord webhooks).
# These settings control whether the bridge will provide such public media access.
@ -286,14 +260,6 @@ public_media:
expiry: 0
# Length of hash to use for public media URLs. Must be between 0 and 32.
hash_length: 32
# The path prefix for generated URLs. Note that this will NOT change the path where media is actually served.
# If you change this, you must configure your reverse proxy to rewrite the path accordingly.
path_prefix: /_mautrix/publicmedia
# Should the bridge store media metadata in the database in order to support encrypted media and generate shorter URLs?
# If false, the generated URLs will just have the MXC URI and a HMAC signature.
# The hash_length field will be used to decide the length of the generated URL.
# This also allows invalidating URLs by deleting the database entry.
use_database: false
# Settings for converting remote media to custom mxc:// URIs instead of reuploading.
# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
@ -375,21 +341,9 @@ encryption:
default: false
# Whether to require all messages to be encrypted and drop any unencrypted messages.
require: false
# Whether to use MSC3202/MSC4203 instead of /sync long polling for receiving encryption-related data.
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
# This option is not yet compatible with standard Matrix servers like Synapse and should not be used.
# Changing this option requires updating the appservice registration file.
appservice: false
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
# Changing this option requires updating the appservice registration file.
msc4190: false
# Whether to encrypt reactions and reply metadata as per MSC4392.
msc4392: false
# Should the bridge bot generate a recovery key and cross-signing keys and verify itself?
# Note that without the latest version of MSC4190, this will fail if you reset the bridge database.
# The generated recovery key will be saved in the kv_store table under `recovery_key`.
self_sign: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: true
@ -452,16 +406,6 @@ encryption:
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Prefix for environment variables. All variables with this prefix must map to valid config fields.
# Nesting in variable names is represented with a dot (.).
# If there are no dots in the name, two underscores (__) are replaced with a dot.
#
# e.g. if the prefix is set to `BRIDGE_`, then `BRIDGE_APPSERVICE__AS_TOKEN` will set appservice.as_token.
# `BRIDGE_appservice.as_token` would work as well, but can't be set in a shell as easily.
#
# If this is null, reading config fields from environment will be disabled.
env_config_prefix: null
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug

View file

@ -135,10 +135,7 @@ func (br *BridgeMain) CheckLegacyDB(
}
var dbVersion int
err = br.DB.QueryRow(ctx, "SELECT version FROM version").Scan(&dbVersion)
if err != nil {
log.Fatal().Err(err).Msg("Failed to get database version")
return
} else if dbVersion < expectedVersion {
if dbVersion < expectedVersion {
log.Fatal().
Int("expected_version", expectedVersion).
Int("version", dbVersion).
@ -211,46 +208,36 @@ func (br *BridgeMain) postMigrateDMPortal(ctx context.Context, portal *bridgev2.
}
func (br *BridgeMain) PostMigrate(ctx context.Context) error {
log := br.Log.With().Str("action", "post-migrate").Logger()
wasMigrated, err := br.DB.TableExists(ctx, "database_was_migrated")
if err != nil {
return fmt.Errorf("failed to check if database_was_migrated table exists: %w", err)
} else if !wasMigrated {
return nil
}
log.Info().Msg("Doing post-migration updates to Matrix rooms")
zerolog.Ctx(ctx).Info().Msg("Doing post-migration updates to Matrix rooms")
portals, err := br.Bridge.GetAllPortalsWithMXID(ctx)
if err != nil {
return fmt.Errorf("failed to get all portals: %w", err)
}
for _, portal := range portals {
log := log.With().
zerolog.Ctx(ctx).Debug().
Stringer("room_id", portal.MXID).
Object("portal_key", portal.PortalKey).
Str("room_type", string(portal.RoomType)).
Logger()
log.Debug().Msg("Migrating portal")
if br.PostMigratePortal != nil {
err = br.PostMigratePortal(ctx, portal)
Msg("Migrating portal")
switch portal.RoomType {
case database.RoomTypeDM:
err = br.postMigrateDMPortal(ctx, portal)
if err != nil {
log.Err(err).Msg("Failed to run post-migrate portal hook")
continue
}
} else {
switch portal.RoomType {
case database.RoomTypeDM:
err = br.postMigrateDMPortal(ctx, portal)
if err != nil {
return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err)
}
return fmt.Errorf("failed to update DM portal %s: %w", portal.MXID, err)
}
}
_, err = br.Matrix.Bot.SendStateEvent(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.ElementFunctionalMembersContent{
ServiceMembers: []id.UserID{br.Matrix.Bot.UserID},
})
if err != nil {
log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members")
zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to set service members")
}
}
@ -258,6 +245,6 @@ func (br *BridgeMain) PostMigrate(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to drop database_was_migrated table: %w", err)
}
log.Info().Msg("Post-migration updates complete")
zerolog.Ctx(ctx).Info().Msg("Post-migration updates complete")
return nil
}

View file

@ -26,7 +26,6 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/progver"
"gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag"
@ -63,18 +62,11 @@ type BridgeMain struct {
// git tag to see if the built version is the release or a dev build.
// You can either bump this right after a release or right before, as long as it matches on the release commit.
Version string
// SemCalVer defines whether this bridge uses a mix of semantic and calendar versioning,
// such that the Version field is YY.0M.patch, while git tags are major.YY0M.patch.
SemCalVer bool
// PostInit is a function that will be called after the bridge has been initialized but before it is started.
PostInit func()
PostStart func()
// PostMigratePortal is a function that will be called during a legacy
// migration for each portal.
PostMigratePortal func(context.Context, *bridgev2.Portal) error
// Connector is the network connector for the bridge.
Connector bridgev2.NetworkConnector
@ -90,7 +82,11 @@ type BridgeMain struct {
RegistrationPath string
SaveConfig bool
ver progver.ProgramVersion
baseVersion string
commit string
LinkifiedVersion string
VersionDesc string
BuildTime time.Time
AdditionalShortFlags string
AdditionalLongFlags string
@ -99,7 +95,14 @@ type BridgeMain struct {
}
type VersionJSONOutput struct {
progver.ProgramVersion
Name string
URL string
Version string
IsRelease bool
Commit string
FormattedVersion string
BuildTime time.Time
OS string
Arch string
@ -140,11 +143,18 @@ func (br *BridgeMain) PreInit() {
flag.PrintHelp()
os.Exit(0)
} else if *version {
fmt.Println(br.ver.VersionDescription)
fmt.Println(br.VersionDesc)
os.Exit(0)
} else if *versionJSON {
output := VersionJSONOutput{
ProgramVersion: br.ver,
URL: br.URL,
Name: br.Name,
Version: br.baseVersion,
IsRelease: br.Version == br.baseVersion,
Commit: br.commit,
FormattedVersion: br.Version,
BuildTime: br.BuildTime,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
@ -226,8 +236,8 @@ func (br *BridgeMain) Init() {
br.Log.Info().
Str("name", br.Name).
Str("version", br.ver.FormattedVersion).
Time("built_at", br.ver.BuildTime).
Str("version", br.Version).
Time("built_at", br.BuildTime).
Str("go_version", runtime.Version()).
Msg("Initializing bridge")
@ -241,7 +251,7 @@ func (br *BridgeMain) Init() {
br.Matrix.AS.DoublePuppetValue = br.Name
br.Bridge.Commands.(*commands.Processor).AddHandler(&commands.FullHandler{
Func: func(ce *commands.Event) {
ce.Reply(br.ver.MarkdownDescription())
ce.Reply("[%s](%s) %s (%s)", br.Name, br.URL, br.LinkifiedVersion, br.BuildTime.Format(time.RFC1123))
},
Name: "version",
Help: commands.HelpMeta{
@ -300,7 +310,7 @@ func (br *BridgeMain) validateConfig() error {
case br.Config.AppService.HSToken == "This value is generated when generating the registration":
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration? ")
case br.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable":
return errors.New("database.uri not configured")
return errors.New("appservice.database not configured")
case !br.Config.Bridge.Permissions.IsConfigured():
return errors.New("bridge.permissions not configured")
case !strings.Contains(br.Config.AppService.FormatUsername("1234567890"), "1234567890"):
@ -354,21 +364,13 @@ func (br *BridgeMain) LoadConfig() {
}
}
cfg.Bridge.Backfill = cfg.Backfill
if cfg.EnvConfigPrefix != "" {
err = UpdateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
os.Exit(10)
}
}
br.Config = &cfg
}
// Start starts the bridge after everything has been initialized.
// This is called by [Run] and does not need to be called manually.
func (br *BridgeMain) Start() {
ctx := br.Log.WithContext(context.Background())
err := br.Bridge.StartConnectors(ctx)
err := br.Bridge.StartConnectors()
if err != nil {
var dbUpgradeErr bridgev2.DBUpgradeError
if errors.As(err, &dbUpgradeErr) {
@ -377,15 +379,14 @@ func (br *BridgeMain) Start() {
br.Log.Fatal().Err(err).Msg("Failed to start bridge")
}
}
err = br.PostMigrate(ctx)
err = br.PostMigrate(br.Log.WithContext(context.Background()))
if err != nil {
br.Log.Fatal().Err(err).Msg("Failed to run post-migration updates")
}
err = br.Bridge.StartLogins(ctx)
err = br.Bridge.StartLogins()
if err != nil {
br.Log.Fatal().Err(err).Msg("Failed to start existing user logins")
}
br.Bridge.PostStart(ctx)
if br.PostStart != nil {
br.PostStart()
}
@ -414,7 +415,7 @@ func (br *BridgeMain) TriggerStop(exitCode int) {
// Stop cleanly stops the bridge. This is called by [Run] and does not need to be called manually.
func (br *BridgeMain) Stop() {
br.Bridge.StopWithTimeout(5 * time.Second)
br.Bridge.Stop()
}
// InitVersion formats the bridge version and build time nicely for things like
@ -439,12 +440,42 @@ func (br *BridgeMain) Stop() {
//
// (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`)
func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) {
br.ver = progver.ProgramVersion{
Name: br.Name,
URL: br.URL,
BaseVersion: br.Version,
SemCalVer: br.SemCalVer,
}.Init(tag, commit, rawBuildTime)
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.ver.FormattedVersion, mautrix.DefaultUserAgent)
br.Version = br.ver.FormattedVersion
br.baseVersion = br.Version
if len(tag) > 0 && tag[0] == 'v' {
tag = tag[1:]
}
if tag != br.Version {
suffix := ""
if !strings.HasSuffix(br.Version, "+dev") {
suffix = "+dev"
}
if len(commit) > 8 {
br.Version = fmt.Sprintf("%s%s.%s", br.Version, suffix, commit[:8])
} else {
br.Version = fmt.Sprintf("%s%s.unknown", br.Version, suffix)
}
}
br.LinkifiedVersion = fmt.Sprintf("v%s", br.Version)
if tag == br.Version {
br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, tag)
} else if len(commit) > 8 {
br.LinkifiedVersion = strings.Replace(br.LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], br.URL, commit), 1)
}
var buildTime time.Time
if rawBuildTime != "unknown" {
buildTime, _ = time.Parse(time.RFC3339, rawBuildTime)
}
var builtWith string
if buildTime.IsZero() {
rawBuildTime = "unknown"
builtWith = runtime.Version()
} else {
rawBuildTime = buildTime.Format(time.RFC1123)
builtWith = fmt.Sprintf("built at %s with %s", rawBuildTime, runtime.Version())
}
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.Version, mautrix.DefaultUserAgent)
br.VersionDesc = fmt.Sprintf("%s %s (%s)", br.Name, br.Version, builtWith)
br.commit = commit
br.BuildTime = buildTime
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -17,21 +17,17 @@ import (
"sync"
"time"
"github.com/gorilla/mux"
"github.com/rs/xid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/exstrings"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/requestlog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/provisionutil"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/federation"
"maunium.net/go/mautrix/id"
)
@ -42,7 +38,7 @@ type matrixAuthCacheEntry struct {
}
type ProvisioningAPI struct {
Router *http.ServeMux
Router *mux.Router
br *Connector
log zerolog.Logger
@ -56,11 +52,6 @@ type ProvisioningAPI struct {
matrixAuthCache map[string]matrixAuthCacheEntry
matrixAuthCacheLock sync.Mutex
// Set for a given login once credentials have been exported, once in this state the finish
// API is available which will call logout on the client in question.
sessionTransfers map[networkid.UserLoginID]struct{}
sessionTransfersLock sync.Mutex
// GetAuthFromRequest is a custom function for getting the auth token from
// the request if the Authorization header is not present.
GetAuthFromRequest func(r *http.Request) string
@ -85,84 +76,86 @@ const (
provisioningUserKey provisioningContextKey = iota
provisioningUserLoginKey
provisioningLoginProcessKey
ProvisioningKeyRequest
)
const ProvisioningKeyRequest = "fi.mau.provision.request"
func (prov *ProvisioningAPI) GetUser(r *http.Request) *bridgev2.User {
return r.Context().Value(provisioningUserKey).(*bridgev2.User)
}
func (prov *ProvisioningAPI) GetRouter() *http.ServeMux {
func (prov *ProvisioningAPI) GetRouter() *mux.Router {
return prov.Router
}
func (br *Connector) GetProvisioning() bridgev2.IProvisioningAPI {
type IProvisioningAPI interface {
GetRouter() *mux.Router
GetUser(r *http.Request) *bridgev2.User
}
func (br *Connector) GetProvisioning() IProvisioningAPI {
return br.Provisioning
}
func (prov *ProvisioningAPI) Init() {
prov.matrixAuthCache = make(map[string]matrixAuthCacheEntry)
prov.logins = make(map[string]*ProvLogin)
prov.sessionTransfers = make(map[networkid.UserLoginID]struct{})
prov.net = prov.br.Bridge.Network
prov.log = prov.br.Log.With().Str("component", "provisioning").Logger()
prov.fedClient = federation.NewClient("", nil, nil)
prov.fedClient = federation.NewClient("", nil)
prov.fedClient.HTTP.Timeout = 20 * time.Second
tp := prov.fedClient.HTTP.Transport.(*federation.ServerResolvingTransport)
tp.Dialer.Timeout = 10 * time.Second
tp.Transport.ResponseHeaderTimeout = 10 * time.Second
tp.Transport.TLSHandshakeTimeout = 10 * time.Second
prov.Router = http.NewServeMux()
prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami)
prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities)
prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows)
prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart)
prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep)
prov.Router.HandleFunc("POST /v3/logout/{loginID}", prov.PostLogout)
prov.Router.HandleFunc("GET /v3/logins", prov.GetLogins)
prov.Router.HandleFunc("GET /v3/contacts", prov.GetContactList)
prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers)
prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier)
prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM)
prov.Router.HandleFunc("POST /v3/create_group/{type}", prov.PostCreateGroup)
if prov.br.Config.Provisioning.EnableSessionTransfers {
prov.log.Debug().Msg("Enabling session transfer API")
prov.Router.HandleFunc("POST /v3/session_transfer/init", prov.PostInitSessionTransfer)
prov.Router.HandleFunc("POST /v3/session_transfer/finish", prov.PostFinishSessionTransfer)
}
prov.Router = prov.br.AS.Router.PathPrefix(prov.br.Config.Provisioning.Prefix).Subrouter()
prov.Router.Use(hlog.NewHandler(prov.log))
prov.Router.Use(hlog.RequestIDHandler("request_id", "Request-Id"))
prov.Router.Use(corsMiddleware)
prov.Router.Use(requestlog.AccessLogger(false))
prov.Router.Use(prov.AuthMiddleware)
prov.Router.Path("/v3/whoami").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetWhoami)
prov.Router.Path("/v3/login/flows").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetLoginFlows)
prov.Router.Path("/v3/login/start/{flowID}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginStart)
prov.Router.Path("/v3/login/step/{loginProcessID}/{stepID}/{stepType:user_input|cookies}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginSubmitInput)
prov.Router.Path("/v3/login/step/{loginProcessID}/{stepID}/{stepType:display_and_wait}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLoginWait)
prov.Router.Path("/v3/logout/{loginID}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostLogout)
prov.Router.Path("/v3/logins").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetLogins)
prov.Router.Path("/v3/contacts").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetContactList)
prov.Router.Path("/v3/search_users").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostSearchUsers)
prov.Router.Path("/v3/resolve_identifier/{identifier}").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(prov.GetResolveIdentifier)
prov.Router.Path("/v3/create_dm/{identifier}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateDM)
prov.Router.Path("/v3/create_group").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateGroup)
if prov.br.Config.Provisioning.DebugEndpoints {
prov.log.Debug().Msg("Enabling debug API at /debug")
debugRouter := http.NewServeMux()
debugRouter.HandleFunc("GET /pprof/cmdline", pprof.Cmdline)
debugRouter.HandleFunc("GET /pprof/profile", pprof.Profile)
debugRouter.HandleFunc("GET /pprof/symbol", pprof.Symbol)
debugRouter.HandleFunc("GET /pprof/trace", pprof.Trace)
debugRouter.HandleFunc("/pprof/", pprof.Index)
prov.br.AS.Router.Handle("/debug/", exhttp.ApplyMiddleware(
debugRouter,
exhttp.StripPrefix("/debug"),
hlog.NewHandler(prov.br.Log.With().Str("component", "debug api").Logger()),
requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}),
prov.DebugAuthMiddleware,
))
r := prov.br.AS.Router.PathPrefix("/debug").Subrouter()
r.Use(prov.DebugAuthMiddleware)
r.HandleFunc("/pprof/cmdline", pprof.Cmdline).Methods(http.MethodGet)
r.HandleFunc("/pprof/profile", pprof.Profile).Methods(http.MethodGet)
r.HandleFunc("/pprof/symbol", pprof.Symbol).Methods(http.MethodGet)
r.HandleFunc("/pprof/trace", pprof.Trace).Methods(http.MethodGet)
r.PathPrefix("/pprof/").HandlerFunc(pprof.Index)
}
}
errorBodies := exhttp.ErrorBodies{
NotFound: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Unrecognized endpoint")).MarshalJSON()),
MethodNotAllowed: exerrors.Must(ptr.Ptr(mautrix.MUnrecognized.WithMessage("Invalid method for endpoint")).MarshalJSON()),
}
prov.br.AS.Router.Handle("/_matrix/provision/", exhttp.ApplyMiddleware(
prov.Router,
exhttp.StripPrefix("/_matrix/provision"),
hlog.NewHandler(prov.log),
hlog.RequestIDHandler("request_id", "Request-Id"),
exhttp.CORSMiddleware,
requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}),
exhttp.HandleErrors(errorBodies),
prov.AuthMiddleware,
))
func corsMiddleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
handler.ServeHTTP(w, r)
})
}
func jsonResponse(w http.ResponseWriter, status int, response any) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func (prov *ProvisioningAPI) checkMatrixAuth(ctx context.Context, userID id.UserID, token string) error {
@ -206,21 +199,19 @@ func (prov *ProvisioningAPI) checkFederatedMatrixAuth(ctx context.Context, userI
}
}
func disabledAuth(w http.ResponseWriter, r *http.Request) {
mautrix.MForbidden.WithMessage("Provisioning API is disabled").Write(w)
}
func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler {
secret := prov.br.Config.Provisioning.SharedSecret
if len(secret) < 16 {
return http.HandlerFunc(disabledAuth)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if auth == "" {
mautrix.MMissingToken.WithMessage("Missing auth token").Write(w)
} else if !exstrings.ConstantTimeEqual(auth, secret) {
mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w)
jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{
Err: "Missing auth token",
ErrCode: mautrix.MMissingToken.ErrCode,
})
} else if auth != prov.br.Config.Provisioning.SharedSecret {
jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{
Err: "Invalid auth token",
ErrCode: mautrix.MUnknownToken.ErrCode,
})
} else {
h.ServeHTTP(w, r)
}
@ -228,24 +219,23 @@ func (prov *ProvisioningAPI) DebugAuthMiddleware(h http.Handler) http.Handler {
}
func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
secret := prov.br.Config.Provisioning.SharedSecret
if len(secret) < 16 {
return http.HandlerFunc(disabledAuth)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if auth == "" && prov.GetAuthFromRequest != nil {
auth = prov.GetAuthFromRequest(r)
}
if auth == "" {
mautrix.MMissingToken.WithMessage("Missing auth token").Write(w)
jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{
Err: "Missing auth token",
ErrCode: mautrix.MMissingToken.ErrCode,
})
return
}
userID := id.UserID(r.URL.Query().Get("user_id"))
if userID == "" && prov.GetUserIDFromRequest != nil {
userID = prov.GetUserIDFromRequest(r)
}
if !exstrings.ConstantTimeEqual(auth, secret) {
if auth != prov.br.Config.Provisioning.SharedSecret {
var err error
if strings.HasPrefix(auth, "openid:") {
err = prov.checkFederatedMatrixAuth(r.Context(), userID, strings.TrimPrefix(auth, "openid:"))
@ -255,25 +245,75 @@ func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
if err != nil {
zerolog.Ctx(r.Context()).Warn().Err(err).
Msg("Provisioning API request contained invalid auth")
mautrix.MUnknownToken.WithMessage("Invalid auth token").Write(w)
jsonResponse(w, http.StatusUnauthorized, &mautrix.RespError{
Err: "Invalid auth token",
ErrCode: mautrix.MUnknownToken.ErrCode,
})
return
}
}
user, err := prov.br.Bridge.GetUserByMXID(r.Context(), userID)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get user")
mautrix.MUnknown.WithMessage("Failed to get user").Write(w)
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to get user",
ErrCode: "M_UNKNOWN",
})
return
}
// TODO handle user being nil?
// TODO per-endpoint permissions?
if !user.Permissions.Login {
mautrix.MForbidden.WithMessage("User does not have login permissions").Write(w)
jsonResponse(w, http.StatusForbidden, &mautrix.RespError{
Err: "User does not have login permissions",
ErrCode: mautrix.MForbidden.ErrCode,
})
return
}
ctx := context.WithValue(r.Context(), ProvisioningKeyRequest, r)
ctx = context.WithValue(ctx, provisioningUserKey, user)
if loginID, ok := mux.Vars(r)["loginProcessID"]; ok {
prov.loginsLock.RLock()
login, ok := prov.logins[loginID]
prov.loginsLock.RUnlock()
if !ok {
zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found")
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
Err: "Login not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
return
}
login.Lock.Lock()
// This will only unlock after the handler runs
defer login.Lock.Unlock()
stepID := mux.Vars(r)["stepID"]
if login.NextStep.StepID != stepID {
zerolog.Ctx(r.Context()).Warn().
Str("request_step_id", stepID).
Str("expected_step_id", login.NextStep.StepID).
Msg("Step ID does not match")
jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{
Err: "Step ID does not match",
ErrCode: mautrix.MBadState.ErrCode,
})
return
}
stepType := mux.Vars(r)["stepType"]
if login.NextStep.Type != bridgev2.LoginStepType(stepType) {
zerolog.Ctx(r.Context()).Warn().
Str("request_step_type", stepType).
Str("expected_step_type", string(login.NextStep.Type)).
Msg("Step type does not match")
jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{
Err: "Step type does not match",
ErrCode: mautrix.MBadState.ErrCode,
})
return
}
ctx = context.WithValue(ctx, provisioningLoginProcessKey, login)
}
h.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -324,7 +364,7 @@ func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) {
prevState.UserID = ""
prevState.RemoteID = ""
prevState.RemoteName = ""
prevState.RemoteProfile = status.RemoteProfile{}
prevState.RemoteProfile = nil
resp.Logins[i] = RespWhoamiLogin{
StateEvent: prevState.StateEvent,
StateTS: prevState.Timestamp,
@ -338,7 +378,7 @@ func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) {
SpaceRoom: login.SpaceRoom,
}
}
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
jsonResponse(w, http.StatusOK, resp)
}
type RespLoginFlows struct {
@ -351,29 +391,21 @@ type RespSubmitLogin struct {
}
func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Request) {
exhttp.WriteJSONResponse(w, http.StatusOK, &RespLoginFlows{
jsonResponse(w, http.StatusOK, &RespLoginFlows{
Flows: prov.net.GetLoginFlows(),
})
}
func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Request) {
exhttp.WriteJSONResponse(w, http.StatusOK, &prov.net.GetCapabilities().Provisioning)
}
var ErrNilStep = errors.New("bridge returned nil step with no error")
var ErrTooManyLogins = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.TOO_MANY_LOGINS", Err: "Maximum number of logins exceeded"}
func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) {
overrideLogin, failed := prov.GetExplicitLoginForRequest(w, r)
if failed {
return
}
user := prov.GetUser(r)
if overrideLogin == nil && user.HasTooManyLogins() {
ErrTooManyLogins.AppendMessage(" (%d)", user.Permissions.MaxLogins).Write(w)
return
}
login, err := prov.net.CreateLogin(r.Context(), user, r.PathValue("flowID"))
login, err := prov.net.CreateLogin(
r.Context(),
prov.GetUser(r),
mux.Vars(r)["flowID"],
)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create login process")
RespondWithError(w, err, "Internal error creating login process")
@ -386,9 +418,6 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque
} else {
firstStep, err = login.Start(r.Context())
}
if err == nil && firstStep == nil {
err = ErrNilStep
}
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start login")
RespondWithError(w, err, "Internal error starting login")
@ -403,18 +432,10 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque
Override: overrideLogin,
}
prov.loginsLock.Unlock()
zerolog.Ctx(r.Context()).Info().
Any("first_step", firstStep).
Msg("Created login process")
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep})
jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep})
}
func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *ProvLogin, step *bridgev2.LoginStep) {
zerolog.Ctx(ctx).Info().
Str("step_id", step.StepID).
Str("user_login_id", string(step.CompleteParams.UserLoginID)).
Msg("Login completed successfully")
prov.deleteLogin(login, false)
if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID {
return
}
@ -428,67 +449,15 @@ func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *Prov
}, bridgev2.DeleteOpts{LogoutRemote: true})
}
func (prov *ProvisioningAPI) deleteLogin(login *ProvLogin, cancel bool) {
if cancel {
login.Process.Cancel()
}
prov.loginsLock.Lock()
delete(prov.logins, login.ID)
prov.loginsLock.Unlock()
}
func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) {
loginID := r.PathValue("loginProcessID")
prov.loginsLock.RLock()
login, ok := prov.logins[loginID]
prov.loginsLock.RUnlock()
if !ok {
zerolog.Ctx(r.Context()).Warn().Str("login_id", loginID).Msg("Login not found")
mautrix.MNotFound.WithMessage("Login not found").Write(w)
return
}
login.Lock.Lock()
// This will only unlock after the handler runs
defer login.Lock.Unlock()
stepID := r.PathValue("stepID")
if login.NextStep.StepID != stepID {
zerolog.Ctx(r.Context()).Warn().
Str("request_step_id", stepID).
Str("expected_step_id", login.NextStep.StepID).
Msg("Step ID does not match")
mautrix.MBadState.WithMessage("Step ID does not match").Write(w)
return
}
stepType := r.PathValue("stepType")
if login.NextStep.Type != bridgev2.LoginStepType(stepType) {
zerolog.Ctx(r.Context()).Warn().
Str("request_step_type", stepType).
Str("expected_step_type", string(login.NextStep.Type)).
Msg("Step type does not match")
mautrix.MBadState.WithMessage("Step type does not match").Write(w)
return
}
ctx := context.WithValue(r.Context(), provisioningLoginProcessKey, login)
r = r.WithContext(ctx)
switch bridgev2.LoginStepType(r.PathValue("stepType")) {
case bridgev2.LoginStepTypeUserInput, bridgev2.LoginStepTypeCookies:
prov.PostLoginSubmitInput(w, r)
case bridgev2.LoginStepTypeDisplayAndWait:
prov.PostLoginWait(w, r)
case bridgev2.LoginStepTypeComplete:
fallthrough
default:
// This is probably impossible because of the above check that the next step type matches the request.
mautrix.MUnrecognized.WithMessage("Invalid step type %q", r.PathValue("stepType")).Write(w)
}
}
func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http.Request) {
var params map[string]string
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body")
mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w)
jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{
Err: "Failed to decode request body",
ErrCode: mautrix.MNotJSON.ErrCode,
})
return
}
login := r.Context().Value(provisioningLoginProcessKey).(*ProvLogin)
@ -501,48 +470,39 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http
default:
panic("Impossible state")
}
if err == nil && nextStep == nil {
err = ErrNilStep
}
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to submit input")
RespondWithError(w, err, "Internal error submitting input")
prov.deleteLogin(login, true)
return
}
login.NextStep = nextStep
if nextStep.Type == bridgev2.LoginStepTypeComplete {
prov.handleCompleteStep(r.Context(), login, nextStep)
} else {
zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step")
}
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
}
func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Request) {
login := r.Context().Value(provisioningLoginProcessKey).(*ProvLogin)
nextStep, err := login.Process.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context())
if err == nil && nextStep == nil {
err = ErrNilStep
}
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to wait")
RespondWithError(w, err, "Internal error waiting for login")
prov.deleteLogin(login, true)
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to wait",
ErrCode: "M_UNKNOWN",
})
return
}
login.NextStep = nextStep
if nextStep.Type == bridgev2.LoginStepTypeComplete {
prov.handleCompleteStep(r.Context(), login, nextStep)
} else {
zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step")
}
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
jsonResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
}
func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request) {
user := prov.GetUser(r)
userLoginID := networkid.UserLoginID(r.PathValue("loginID"))
userLoginID := networkid.UserLoginID(mux.Vars(r)["loginID"])
if userLoginID == "all" {
for {
login := user.GetDefaultLogin()
@ -554,12 +514,15 @@ func (prov *ProvisioningAPI) PostLogout(w http.ResponseWriter, r *http.Request)
} else {
userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID)
if userLogin == nil || userLogin.UserMXID != user.MXID {
mautrix.MNotFound.WithMessage("Login not found").Write(w)
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
Err: "Login not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
return
}
userLogin.Logout(r.Context())
}
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
jsonResponse(w, http.StatusOK, json.RawMessage("{}"))
}
type RespGetLogins struct {
@ -568,7 +531,7 @@ type RespGetLogins struct {
func (prov *ProvisioningAPI) GetLogins(w http.ResponseWriter, r *http.Request) {
user := prov.GetUser(r)
exhttp.WriteJSONResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()})
jsonResponse(w, http.StatusOK, &RespGetLogins{LoginIDs: user.GetUserLoginIDs()})
}
func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r *http.Request) (*bridgev2.UserLogin, bool) {
@ -578,21 +541,15 @@ func (prov *ProvisioningAPI) GetExplicitLoginForRequest(w http.ResponseWriter, r
}
userLogin := prov.br.Bridge.GetCachedUserLoginByID(userLoginID)
if userLogin == nil || userLogin.UserMXID != prov.GetUser(r).MXID {
hlog.FromRequest(r).Warn().
Str("login_id", string(userLoginID)).
Msg("Tried to use non-existent login, returning 404")
mautrix.MNotFound.WithMessage("Login not found").Write(w)
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
Err: "Login not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
return nil, true
}
return userLogin, false
}
var ErrNotLoggedIn = mautrix.RespError{
Err: "Not logged in",
ErrCode: "FI.MAU.NOT_LOGGED_IN",
StatusCode: http.StatusBadRequest,
}
func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.Request) *bridgev2.UserLogin {
userLogin, failed := prov.GetExplicitLoginForRequest(w, r)
if userLogin != nil || failed {
@ -600,7 +557,10 @@ func (prov *ProvisioningAPI) GetLoginForRequest(w http.ResponseWriter, r *http.R
}
userLogin = prov.GetUser(r).GetDefaultLogin()
if userLogin == nil {
ErrNotLoggedIn.Write(w)
jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{
Err: "Not logged in",
ErrCode: "FI.MAU.NOT_LOGGED_IN",
})
return nil
}
return userLogin
@ -615,27 +575,135 @@ func RespondWithError(w http.ResponseWriter, err error, message string) {
if errors.As(err, &we) {
we.Write(w)
} else {
mautrix.MUnknown.WithMessage(message).Write(w)
mautrix.RespError{
Err: message,
ErrCode: "M_UNKNOWN",
StatusCode: http.StatusInternalServerError,
}.Write(w)
}
}
type RespResolveIdentifier struct {
ID networkid.UserID `json:"id"`
Name string `json:"name,omitempty"`
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
Identifiers []string `json:"identifiers,omitempty"`
MXID id.UserID `json:"mxid,omitempty"`
DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"`
}
func (prov *ProvisioningAPI) doResolveIdentifier(w http.ResponseWriter, r *http.Request, createChat bool) {
login := prov.GetLoginForRequest(w, r)
if login == nil {
return
}
resp, err := provisionutil.ResolveIdentifier(r.Context(), login, r.PathValue("identifier"), createChat)
if err != nil {
RespondWithError(w, err, "Internal error resolving identifier")
} else if resp == nil {
mautrix.MNotFound.WithMessage("Identifier not found").Write(w)
} else {
status := http.StatusOK
if resp.JustCreated {
status = http.StatusCreated
}
exhttp.WriteJSONResponse(w, status, resp)
api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
if !ok {
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
Err: "This bridge does not support resolving identifiers",
ErrCode: mautrix.MUnrecognized.ErrCode,
})
return
}
resp, err := api.ResolveIdentifier(r.Context(), mux.Vars(r)["identifier"], createChat)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier")
RespondWithError(w, err, "Internal error resolving identifier")
return
} else if resp == nil {
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
ErrCode: mautrix.MNotFound.ErrCode,
Err: "Identifier not found",
})
return
}
apiResp := &RespResolveIdentifier{
ID: resp.UserID,
}
status := http.StatusOK
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo)
}
apiResp.Name = resp.Ghost.Name
apiResp.AvatarURL = resp.Ghost.AvatarMXC
apiResp.Identifiers = resp.Ghost.Identifiers
apiResp.MXID = resp.Ghost.Intent.GetMXID()
} else if resp.UserInfo != nil && resp.UserInfo.Name != nil {
apiResp.Name = *resp.UserInfo.Name
}
if resp.Chat != nil {
if resp.Chat.Portal == nil {
resp.Chat.Portal, err = prov.br.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal")
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to get portal",
ErrCode: "M_UNKNOWN",
})
return
}
}
if createChat && resp.Chat.Portal.MXID == "" {
status = http.StatusCreated
err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room")
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to create portal room",
ErrCode: "M_UNKNOWN",
})
return
}
}
apiResp.DMRoomID = resp.Chat.Portal.MXID
}
jsonResponse(w, status, apiResp)
}
type RespGetContactList struct {
Contacts []*RespResolveIdentifier `json:"contacts"`
}
func (prov *ProvisioningAPI) processResolveIdentifiers(ctx context.Context, resp []*bridgev2.ResolveIdentifierResponse) (apiResp []*RespResolveIdentifier) {
apiResp = make([]*RespResolveIdentifier, len(resp))
for i, contact := range resp {
apiContact := &RespResolveIdentifier{
ID: contact.UserID,
}
apiResp[i] = apiContact
if contact.UserInfo != nil {
if contact.UserInfo.Name != nil {
apiContact.Name = *contact.UserInfo.Name
}
if contact.UserInfo.Identifiers != nil {
apiContact.Identifiers = contact.UserInfo.Identifiers
}
}
if contact.Ghost != nil {
if contact.Ghost.Name != "" {
apiContact.Name = contact.Ghost.Name
}
if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) {
apiContact.Identifiers = contact.Ghost.Identifiers
}
apiContact.AvatarURL = contact.Ghost.AvatarMXC
apiContact.MXID = contact.Ghost.Intent.GetMXID()
}
if contact.Chat != nil {
if contact.Chat.Portal == nil {
var err error
contact.Chat.Portal, err = prov.br.Bridge.GetPortalByKey(ctx, contact.Chat.PortalKey)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal")
}
}
if contact.Chat.Portal != nil {
apiContact.DMRoomID = contact.Chat.Portal.MXID
}
}
}
return
}
func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Request) {
@ -643,36 +711,65 @@ func (prov *ProvisioningAPI) GetContactList(w http.ResponseWriter, r *http.Reque
if login == nil {
return
}
resp, err := provisionutil.GetContactList(r.Context(), login)
if err != nil {
RespondWithError(w, err, "Internal error getting contact list")
api, ok := login.Client.(bridgev2.ContactListingNetworkAPI)
if !ok {
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
Err: "This bridge does not support listing contacts",
ErrCode: mautrix.MUnrecognized.ErrCode,
})
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
resp, err := api.GetContactList(r.Context())
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get contact list")
RespondWithError(w, err, "Internal error fetching contact list")
return
}
jsonResponse(w, http.StatusOK, &RespGetContactList{
Contacts: prov.processResolveIdentifiers(r.Context(), resp),
})
}
type ReqSearchUsers struct {
Query string `json:"query"`
}
type RespSearchUsers struct {
Results []*RespResolveIdentifier `json:"results"`
}
func (prov *ProvisioningAPI) PostSearchUsers(w http.ResponseWriter, r *http.Request) {
var req ReqSearchUsers
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body")
mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w)
jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{
Err: "Failed to decode request body",
ErrCode: mautrix.MNotJSON.ErrCode,
})
return
}
login := prov.GetLoginForRequest(w, r)
if login == nil {
return
}
resp, err := provisionutil.SearchUsers(r.Context(), login, req.Query)
if err != nil {
RespondWithError(w, err, "Internal error searching users")
api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI)
if !ok {
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
Err: "This bridge does not support searching for users",
ErrCode: mautrix.MUnrecognized.ErrCode,
})
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
resp, err := api.SearchUsers(r.Context(), req.Query)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get contact list")
RespondWithError(w, err, "Internal error fetching contact list")
return
}
jsonResponse(w, http.StatusOK, &RespSearchUsers{
Results: prov.processResolveIdentifiers(r.Context(), resp),
})
}
func (prov *ProvisioningAPI) GetResolveIdentifier(w http.ResponseWriter, r *http.Request) {
@ -684,114 +781,12 @@ func (prov *ProvisioningAPI) PostCreateDM(w http.ResponseWriter, r *http.Request
}
func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Request) {
var req bridgev2.GroupCreateParams
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body")
mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w)
return
}
req.Type = r.PathValue("type")
login := prov.GetLoginForRequest(w, r)
if login == nil {
return
}
resp, err := provisionutil.CreateGroup(r.Context(), login, &req)
if err != nil {
RespondWithError(w, err, "Internal error creating group")
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
}
type ReqExportCredentials struct {
RemoteID networkid.UserLoginID `json:"remote_id"`
}
type RespExportCredentials struct {
Credentials any `json:"credentials"`
}
func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *http.Request) {
prov.sessionTransfersLock.Lock()
defer prov.sessionTransfersLock.Unlock()
var req ReqExportCredentials
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body")
mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w)
return
}
user := prov.GetUser(r)
logins := user.GetUserLogins()
var loginToExport *bridgev2.UserLogin
for _, login := range logins {
if login.ID == req.RemoteID {
loginToExport = login
break
}
}
if loginToExport == nil {
mautrix.MNotFound.WithMessage("No matching user login found").Write(w)
return
}
client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI)
if !ok {
mautrix.MUnrecognized.WithMessage("This bridge does not support exporting credentials").Write(w)
return
}
if _, ok := prov.sessionTransfers[loginToExport.ID]; ok {
// Warn, but allow, double exports. This might happen if a client crashes handling creds,
// and should be safe to call multiple times.
zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials")
}
// Disconnect now so we don't use the same network session in two places at once
client.Disconnect()
exhttp.WriteJSONResponse(w, http.StatusOK, &RespExportCredentials{
Credentials: client.ExportCredentials(r.Context()),
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
Err: "Creating groups is not yet implemented",
ErrCode: mautrix.MUnrecognized.ErrCode,
})
}
func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) {
prov.sessionTransfersLock.Lock()
defer prov.sessionTransfersLock.Unlock()
var req ReqExportCredentials
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body")
mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w)
return
}
user := prov.GetUser(r)
logins := user.GetUserLogins()
var loginToExport *bridgev2.UserLogin
for _, login := range logins {
if login.ID == req.RemoteID {
loginToExport = login
break
}
}
if loginToExport == nil {
mautrix.MNotFound.WithMessage("No matching user login found").Write(w)
return
} else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok {
mautrix.MBadState.WithMessage("No matching credential export found").Write(w)
return
}
zerolog.Ctx(r.Context()).Info().
Str("remote_name", string(req.RemoteID)).
Msg("Logging out remote after finishing credential export")
loginToExport.Client.LogoutRemote(r.Context())
delete(prov.sessionTransfers, req.RemoteID)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}

View file

@ -361,25 +361,14 @@ paths:
$ref: '#/components/responses/InternalError'
501:
$ref: '#/components/responses/NotSupported'
/v3/create_group/{type}:
/v3/create_group:
post:
tags: [ snc ]
summary: Create a group chat on the remote network.
operationId: createGroup
parameters:
- $ref: "#/components/parameters/loginID"
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupCreateParams'
responses:
200:
description: Identifier resolved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CreatedGroup'
401:
$ref: '#/components/responses/Unauthorized'
404:
@ -400,7 +389,7 @@ components:
- username
- meow@example.com
loginID:
name: login_id
name: loginID
in: query
description: An optional explicit login ID to do the action through.
required: false
@ -583,74 +572,6 @@ components:
description: The Matrix room ID of the direct chat with the user.
examples:
- '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io'
GroupCreateParams:
type: object
description: |
Parameters for creating a group chat.
The /capabilities endpoint response must be checked to see which fields are actually allowed.
properties:
type:
type: string
description: The type of group to create.
examples:
- channel
username:
type: string
description: The public username for the created group.
participants:
type: array
description: The users to add to the group initially.
items:
type: string
parent:
type: object
name:
type: object
description: The `m.room.name` event content for the room.
properties:
name:
type: string
avatar:
type: object
description: The `m.room.avatar` event content for the room.
properties:
url:
type: string
format: mxc
topic:
type: object
description: The `m.room.topic` event content for the room.
properties:
topic:
type: string
disappear:
type: object
description: The `com.beeper.disappearing_timer` event content for the room.
properties:
type:
type: string
timer:
type: number
room_id:
type: string
format: matrix_room_id
description: |
An existing Matrix room ID to bridge to.
The other parameters must be already in sync with the room state when using this parameter.
CreatedGroup:
type: object
description: A successfully created group chat.
required: [id, mxid]
properties:
id:
type: string
description: The internal chat ID of the created group.
mxid:
type: string
format: matrix_room_id
description: The Matrix room ID of the portal.
examples:
- '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io'
LoginStep:
type: object
description: A step in a login process.
@ -714,7 +635,7 @@ components:
type:
type: string
description: The type of field.
enum: [ username, phone_number, email, password, 2fa_code, token, url, domain, select ]
enum: [ username, phone_number, email, password, 2fa_code, token, url, domain ]
id:
type: string
description: The internal ID of the field. This must be used as the key in the object when submitting the data back to the bridge.
@ -728,53 +649,10 @@ components:
description: A more detailed description of the field shown to the user.
examples:
- Include the country code with a +
default_value:
type: string
description: A default value that the client can pre-fill the field with.
pattern:
type: string
format: regex
description: A regular expression that the field value must match.
options:
type: array
description: For fields of type select, the valid options.
items:
type: string
attachments:
type: array
description: A list of media attachments to show the user alongside the form fields.
items:
type: object
description: A media attachment to show the user.
required: [ type, filename, content ]
properties:
type:
type: string
description: The type of media attachment, using the same media type identifiers as Matrix attachments. Only some are supported.
enum: [ m.image, m.audio ]
filename:
type: string
description: The filename for the media attachment.
content:
type: string
description: The raw file content for the attachment encoded in base64.
info:
type: object
description: Optional but recommended metadata for the attachment. Can generally be derived from the raw content if omitted.
properties:
mimetype:
type: string
description: The MIME type for the media content.
examples: [ image/png, audio/mpeg ]
w:
type: number
description: The width of the media in pixels. Only applicable for images and videos.
h:
type: number
description: The height of the media in pixels. Only applicable for images and videos.
size:
type: number
description: The size of the media content in number of bytes. Strongly recommended to include.
- description: Cookie login step
required: [ type, cookies ]
properties:
@ -793,20 +671,6 @@ components:
user_agent:
type: string
description: An optional user agent that the webview should use.
wait_for_url_pattern:
type: string
description: |
A regex pattern that the URL should match before the client closes the webview.
The client may submit the login if the user closes the webview after all cookies are collected
even if this URL is not reached, but it should only automatically close the webview after
both cookies and the URL match.
extract_js:
type: string
description: |
A JavaScript snippet that can extract some or all of the fields.
The snippet will evaluate to a promise that resolves when the relevant fields are found.
Fields that are not present in the promise result must be extracted another way.
fields:
type: array
description: The list of cookies or other stored data that must be extracted.

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -7,26 +7,18 @@
package matrix
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/gorilla/mux"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -43,10 +35,7 @@ func (br *Connector) initPublicMedia() error {
return fmt.Errorf("public media hash length is negative")
}
br.pubMediaSigKey = []byte(br.Config.PublicMedia.SigningKey)
br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}", br.serveDatabasePublicMedia)
br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{customID}/{filename}", br.serveDatabasePublicMedia)
br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia)
br.AS.Router.HandleFunc("GET /_mautrix/publicmedia/{server}/{mediaID}/{checksum}/{filename}", br.servePublicMedia)
br.AS.Router.HandleFunc("/_mautrix/publicmedia/{server}/{mediaID}/{checksum}", br.servePublicMedia).Methods(http.MethodGet)
return nil
}
@ -57,20 +46,6 @@ func (br *Connector) hashContentURI(uri id.ContentURI, expiry []byte) []byte {
return hasher.Sum(expiry)[:br.Config.PublicMedia.HashLength+len(expiry)]
}
func (br *Connector) hashDBPublicMedia(pm *database.PublicMedia) []byte {
hasher := hmac.New(sha256.New, br.pubMediaSigKey)
hasher.Write([]byte(pm.MXC.String()))
hasher.Write([]byte(pm.MimeType))
if pm.Keys != nil {
hasher.Write([]byte(pm.Keys.Version))
hasher.Write([]byte(pm.Keys.Key.Algorithm))
hasher.Write([]byte(pm.Keys.Key.Key))
hasher.Write([]byte(pm.Keys.InitVector))
hasher.Write([]byte(pm.Keys.Hashes.SHA256))
}
return hasher.Sum(nil)[:br.Config.PublicMedia.HashLength]
}
func (br *Connector) makePublicMediaChecksum(uri id.ContentURI) []byte {
var expiresAt []byte
if br.Config.PublicMedia.Expiry > 0 {
@ -101,15 +76,16 @@ var proxyHeadersToCopy = []string{
}
func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentURI := id.ContentURI{
Homeserver: r.PathValue("server"),
FileID: r.PathValue("mediaID"),
Homeserver: vars["server"],
FileID: vars["mediaID"],
}
if !contentURI.IsValid() {
http.Error(w, "invalid content URI", http.StatusBadRequest)
return
}
checksum, err := base64.RawURLEncoding.DecodeString(r.PathValue("checksum"))
checksum, err := base64.RawURLEncoding.DecodeString(vars["checksum"])
if err != nil || !hmac.Equal(checksum, br.makePublicMediaChecksum(contentURI)) {
http.Error(w, "invalid base64 in checksum", http.StatusBadRequest)
return
@ -120,47 +96,9 @@ func (br *Connector) servePublicMedia(w http.ResponseWriter, r *http.Request) {
http.Error(w, "checksum expired", http.StatusGone)
return
}
br.doProxyMedia(w, r, contentURI, nil, "")
}
func (br *Connector) serveDatabasePublicMedia(w http.ResponseWriter, r *http.Request) {
if !br.Config.PublicMedia.UseDatabase {
http.Error(w, "public media short links are disabled", http.StatusNotFound)
return
}
log := zerolog.Ctx(r.Context())
media, err := br.Bridge.DB.PublicMedia.Get(r.Context(), r.PathValue("customID"))
if err != nil {
log.Err(err).Msg("Failed to get public media from database")
http.Error(w, "failed to get media metadata", http.StatusInternalServerError)
return
} else if media == nil {
http.Error(w, "media ID not found", http.StatusNotFound)
return
} else if !media.Expiry.IsZero() && media.Expiry.Before(time.Now()) {
// This is not gone as it can still be refreshed in the DB
http.Error(w, "media expired", http.StatusNotFound)
return
} else if media.Keys != nil && media.Keys.PrepareForDecryption() != nil {
http.Error(w, "media keys are malformed", http.StatusInternalServerError)
return
}
br.doProxyMedia(w, r, media.MXC, media.Keys, media.MimeType)
}
var safeMimes = []string{
"text/css", "text/plain", "text/csv",
"application/json", "application/ld+json",
"image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif",
"video/mp4", "video/webm", "video/ogg", "video/quicktime",
"audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave",
"audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac",
}
func (br *Connector) doProxyMedia(w http.ResponseWriter, r *http.Request, contentURI id.ContentURI, encInfo *attachment.EncryptedFile, mimeType string) {
resp, err := br.Bot.Download(r.Context(), contentURI)
if err != nil {
zerolog.Ctx(r.Context()).Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy")
br.Log.Warn().Stringer("uri", contentURI).Err(err).Msg("Failed to download media to proxy")
http.Error(w, "failed to download media", http.StatusInternalServerError)
return
}
@ -168,41 +106,11 @@ func (br *Connector) doProxyMedia(w http.ResponseWriter, r *http.Request, conten
for _, hdr := range proxyHeadersToCopy {
w.Header()[hdr] = resp.Header[hdr]
}
stream := resp.Body
if encInfo != nil {
if mimeType == "" {
mimeType = "application/octet-stream"
}
contentDisposition := "attachment"
if slices.Contains(safeMimes, mimeType) {
contentDisposition = "inline"
}
dispositionArgs := map[string]string{}
if filename := r.PathValue("filename"); filename != "" {
dispositionArgs["filename"] = filename
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, dispositionArgs))
// Note: this won't check the Close result like it should, but it's probably not a big deal here
stream = encInfo.DecryptStream(stream)
} else if filename := r.PathValue("filename"); filename != "" {
contentDisposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if contentDisposition == "" {
contentDisposition = "attachment"
}
w.Header().Set("Content-Disposition", mime.FormatMediaType(contentDisposition, map[string]string{
"filename": filename,
}))
}
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, stream)
_, _ = io.Copy(w, resp.Body)
}
func (br *Connector) GetPublicMediaAddress(contentURI id.ContentURIString) string {
return br.getPublicMediaAddressWithFileName(contentURI, "")
}
func (br *Connector) getPublicMediaAddressWithFileName(contentURI id.ContentURIString, fileName string) string {
if br.pubMediaSigKey == nil {
return ""
}
@ -210,69 +118,11 @@ func (br *Connector) getPublicMediaAddressWithFileName(contentURI id.ContentURIS
if err != nil || !parsed.IsValid() {
return ""
}
fileName = url.PathEscape(strings.ReplaceAll(fileName, "/", "_"))
if fileName == ".." {
fileName = ""
}
parts := []string{
return fmt.Sprintf(
"%s/_mautrix/publicmedia/%s/%s/%s",
br.GetPublicAddress(),
strings.Trim(br.Config.PublicMedia.PathPrefix, "/"),
parsed.Homeserver,
parsed.FileID,
base64.RawURLEncoding.EncodeToString(br.makePublicMediaChecksum(parsed)),
fileName,
}
if fileName == "" {
parts = parts[:len(parts)-1]
}
return strings.Join(parts, "/")
}
func (br *Connector) GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error) {
if br.pubMediaSigKey == nil {
return "", bridgev2.ErrPublicMediaDisabled
}
if !br.Config.PublicMedia.UseDatabase {
if evt.File != nil {
return "", fmt.Errorf("can't generate address for encrypted file: %w", bridgev2.ErrPublicMediaDatabaseDisabled)
}
return br.getPublicMediaAddressWithFileName(evt.URL, evt.GetFileName()), nil
}
mxc := evt.URL
var keys *attachment.EncryptedFile
if evt.File != nil {
mxc = evt.File.URL
keys = &evt.File.EncryptedFile
}
parsedMXC, err := mxc.Parse()
if err != nil {
return "", fmt.Errorf("%w: failed to parse MXC: %w", bridgev2.ErrPublicMediaGenerateFailed, err)
}
pm := &database.PublicMedia{
MXC: parsedMXC,
Keys: keys,
MimeType: evt.GetInfo().MimeType,
}
if br.Config.PublicMedia.Expiry > 0 {
pm.Expiry = time.Now().Add(time.Duration(br.Config.PublicMedia.Expiry) * time.Second)
}
pm.PublicID = base64.RawURLEncoding.EncodeToString(br.hashDBPublicMedia(pm))
err = br.Bridge.DB.PublicMedia.Put(ctx, pm)
if err != nil {
return "", fmt.Errorf("%w: failed to store public media in database: %w", bridgev2.ErrPublicMediaGenerateFailed, err)
}
fileName := url.PathEscape(strings.ReplaceAll(evt.GetFileName(), "/", "_"))
if fileName == ".." {
fileName = ""
}
parts := []string{
br.GetPublicAddress(),
strings.Trim(br.Config.PublicMedia.PathPrefix, "/"),
pm.PublicID,
fileName,
}
if fileName == "" {
parts = parts[:len(parts)-1]
}
return strings.Join(parts, "/"), nil
)
}

View file

@ -57,7 +57,7 @@ func (br *Connector) startWebsocket(wg *sync.WaitGroup) {
addr = br.Config.Homeserver.Address
}
for {
err := br.AS.StartWebsocket(br.Bridge.BackgroundCtx, addr, onConnect)
err := br.AS.StartWebsocket(addr, onConnect)
if errors.Is(err, appservice.ErrWebsocketManualStop) {
return
} else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced {

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -10,31 +10,27 @@ import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"go.mau.fi/util/exhttp"
"github.com/gorilla/mux"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type MatrixCapabilities struct {
AutoJoinInvites bool
BatchSending bool
ArbitraryMemberChange bool
ExtraProfileMeta bool
AutoJoinInvites bool
BatchSending bool
}
type MatrixConnector interface {
Init(*Bridge)
Start(ctx context.Context) error
PreStop()
Stop()
GetCapabilities() *MatrixCapabilities
@ -54,85 +50,37 @@ type MatrixConnector interface {
GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error)
BatchSend(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBeeperBatchSend, extras []*MatrixSendExtra) (*mautrix.RespBeeperBatchSend, error)
GenerateDeterministicRoomID(portalKey networkid.PortalKey) id.RoomID
GenerateDeterministicEventID(roomID id.RoomID, portalKey networkid.PortalKey, messageID networkid.MessageID, partID networkid.PartID) id.EventID
GenerateReactionEventID(roomID id.RoomID, targetMessage *database.Message, sender networkid.UserID, emojiID networkid.EmojiID) id.EventID
ServerName() string
}
type MatrixConnectorWithArbitraryRoomState interface {
MatrixConnector
GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error)
}
type MatrixConnectorWithServer interface {
MatrixConnector
GetPublicAddress() string
GetRouter() *http.ServeMux
}
type IProvisioningAPI interface {
GetRouter() *http.ServeMux
GetUser(r *http.Request) *User
}
type MatrixConnectorWithProvisioning interface {
MatrixConnector
GetProvisioning() IProvisioningAPI
GetRouter() *mux.Router
}
type MatrixConnectorWithPublicMedia interface {
MatrixConnector
GetPublicMediaAddress(contentURI id.ContentURIString) string
GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error)
}
type MatrixConnectorWithNameDisambiguation interface {
MatrixConnector
IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error)
}
type MatrixConnectorWithBridgeIdentifier interface {
MatrixConnector
GetUniqueBridgeID() string
}
type MatrixConnectorWithURLPreviews interface {
MatrixConnector
GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error)
}
type MatrixConnectorWithPostRoomBridgeHandling interface {
MatrixConnector
HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error
}
type MatrixConnectorWithAnalytics interface {
MatrixConnector
TrackAnalytics(userID id.UserID, event string, properties map[string]any)
}
type DirectNotificationData struct {
Portal *Portal
Sender *Ghost
MessageID networkid.MessageID
Message string
FormattedNotification string
FormattedTitle string
}
type MatrixConnectorWithNotifications interface {
MatrixConnector
DisplayNotification(ctx context.Context, data *DirectNotificationData)
}
type MatrixConnectorWithHTTPSettings interface {
MatrixConnector
GetHTTPClientSettings() exhttp.ClientSettings
}
type MatrixSendExtra struct {
Timestamp time.Time
MessageMeta *database.Message
@ -176,13 +124,8 @@ func (ce CallbackError) Unwrap() error {
return ce.Wrapped
}
type EnsureJoinedParams struct {
Via []string
}
type MatrixAPI interface {
GetMXID() id.UserID
IsDoublePuppet() bool
SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *MatrixSendExtra) (*mautrix.RespSendEvent, error)
SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (*mautrix.RespSendEvent, error)
@ -200,26 +143,13 @@ type MatrixAPI interface {
CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error)
DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error
EnsureJoined(ctx context.Context, roomID id.RoomID, params ...EnsureJoinedParams) error
EnsureJoined(ctx context.Context, roomID id.RoomID) error
EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error
TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error
MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error
GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error)
}
type StreamOrderReadingMatrixAPI interface {
MatrixAPI
MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error
}
type MarkAsDMMatrixAPI interface {
MatrixAPI
MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error
}
type EphemeralSendingMatrixAPI interface {
MatrixAPI
BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error)
}

View file

@ -19,17 +19,17 @@ import (
"maunium.net/go/mautrix/id"
)
func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult {
func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender *User) {
log := zerolog.Ctx(ctx)
// These invites should already be rejected in QueueMatrixEvent
if !sender.Permissions.Commands {
log.Warn().Msg("Received bot invite from user without permission to send commands")
return EventHandlingResultIgnored
return
}
err := br.Bot.EnsureJoined(ctx, evt.RoomID)
if err != nil {
log.Err(err).Msg("Failed to accept invite to room")
return EventHandlingResultFailed
return
}
log.Debug().Msg("Accepted invite to room as bot")
members, err := br.Matrix.GetMembers(ctx, evt.RoomID)
@ -55,7 +55,6 @@ func (br *Bridge) handleBotInvite(ctx context.Context, evt *event.Event, sender
log.Err(err).Msg("Failed to send welcome message to room")
}
}
return EventHandlingResultSuccess
}
func sendNotice(ctx context.Context, evt *event.Event, intent MatrixAPI, message string, args ...any) {
@ -88,42 +87,12 @@ func sendErrorAndLeave(ctx context.Context, evt *event.Event, intent MatrixAPI,
rejectInvite(ctx, evt, intent, "")
}
func (portal *Portal) CleanupOrphanedDM(ctx context.Context, userMXID id.UserID) {
if portal.MXID == "" {
return
}
log := zerolog.Ctx(ctx)
existingPortalMembers, err := portal.Bridge.Matrix.GetMembers(ctx, portal.MXID)
if err != nil {
log.Err(err).
Stringer("old_portal_mxid", portal.MXID).
Msg("Failed to check existing portal members, deleting room")
} else if targetUserMember, ok := existingPortalMembers[userMXID]; !ok {
log.Debug().
Stringer("old_portal_mxid", portal.MXID).
Msg("Inviter has no member event in old portal, deleting room")
} else if targetUserMember.Membership.IsInviteOrJoin() {
return
} else {
log.Debug().
Stringer("old_portal_mxid", portal.MXID).
Str("membership", string(targetUserMember.Membership)).
Msg("Inviter is not in old portal, deleting room")
}
if err = portal.RemoveMXID(ctx); err != nil {
log.Err(err).Msg("Failed to delete old portal mxid")
} else if err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, true); err != nil {
log.Err(err).Msg("Failed to clean up old portal room")
}
}
func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) EventHandlingResult {
func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sender *User) {
ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey()))
validator, ok := br.Network.(IdentifierValidatingNetwork)
if ghostID == "" || (ok && !validator.ValidateUserID(ghostID)) {
rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "Malformed user ID")
return EventHandlingResultIgnored
return
}
log := zerolog.Ctx(ctx).With().
Str("invitee_network_id", string(ghostID)).
@ -133,22 +102,22 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen
logins := sender.GetUserLogins()
if len(logins) == 0 {
rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "You're not logged in")
return EventHandlingResultIgnored
return
}
_, ok = logins[0].Client.(IdentifierResolvingNetworkAPI)
if !ok {
rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "This bridge does not support starting chats")
return EventHandlingResultIgnored
return
}
invitedGhost, err := br.GetGhostByID(ctx, ghostID)
if err != nil {
log.Err(err).Msg("Failed to get invited ghost")
return EventHandlingResultFailed
return
}
err = invitedGhost.Intent.EnsureJoined(ctx, evt.RoomID)
if err != nil {
log.Err(err).Msg("Failed to accept invite to room")
return EventHandlingResultFailed
return
}
var resp *CreateChatResponse
var sourceLogin *UserLogin
@ -175,7 +144,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen
} else if err != nil {
log.Err(err).Msg("Failed to resolve identifier")
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to create chat")
return EventHandlingResultFailed
return
} else {
sourceLogin = login
break
@ -184,7 +153,7 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen
if resp == nil {
log.Warn().Msg("No login could resolve the identifier")
sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create chat via any login")
return EventHandlingResultFailed
return
}
portal := resp.Portal
if portal == nil {
@ -192,85 +161,61 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen
if err != nil {
log.Err(err).Msg("Failed to get portal by key")
sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create portal entry")
return EventHandlingResultFailed
return
}
}
portal.CleanupOrphanedDM(ctx, sender.MXID)
err = invitedGhost.Intent.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID())
if err != nil {
log.Err(err).Msg("Failed to ensure bot is invited to room")
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to invite bridge bot")
return EventHandlingResultFailed
return
}
err = br.Bot.EnsureJoined(ctx, evt.RoomID)
if err != nil {
log.Err(err).Msg("Failed to ensure bot is joined to room")
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to join with bridge bot")
return EventHandlingResultFailed
return
}
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
portalMXID := portal.MXID
if portalMXID != "" {
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portalMXID, portalMXID.URI(br.Matrix.ServerName()).MatrixToURL())
rejectInvite(ctx, evt, br.Bot, "")
return EventHandlingResultSuccess
didSetPortal := portal.setMXIDToExistingRoom(evt.RoomID)
if resp.PortalInfo != nil {
portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{})
}
err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent)
if err != nil {
log.Err(err).Msg("Failed to give permissions to bridge bot")
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to give permissions to bridge bot")
rejectInvite(ctx, evt, br.Bot, "")
return EventHandlingResultSuccess
}
overrideIntent := invitedGhost.Intent
if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID {
log.Debug().
Str("dm_redirected_to_id", string(resp.DMRedirectedTo)).
Msg("Created DM was redirected to another user ID")
_, err = invitedGhost.Intent.SendState(ctx, evt.RoomID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{
Parsed: &event.MemberEventContent{
Membership: event.MembershipLeave,
Reason: "Direct chat redirected to another internal user ID",
if didSetPortal {
// TODO this might become unnecessary if UpdateInfo starts taking care of it
_, err = br.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{
Parsed: &event.ElementFunctionalMembersContent{
ServiceMembers: []id.UserID{br.Bot.GetMXID()},
},
}, time.Time{})
if err != nil {
log.Err(err).Msg("Failed to make incorrect ghost leave new DM room")
log.Warn().Err(err).Msg("Failed to set service members in room")
}
if resp.DMRedirectedTo == SpecialValueDMRedirectedToBot {
overrideIntent = br.Bot
} else if otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo); err != nil {
log.Err(err).Msg("Failed to get ghost of real portal other user ID")
} else {
invitedGhost = otherUserGhost
overrideIntent = otherUserGhost.Intent
}
}
err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{
// We locked it before checking the mxid
RoomCreateAlreadyLocked: true,
FailIfMXIDSet: true,
ChatInfo: resp.PortalInfo,
ChatInfoSource: sourceLogin,
})
if err != nil {
log.Err(err).Msg("Failed to update Matrix room ID for new DM portal")
sendNotice(ctx, evt, overrideIntent, "Failed to finish configuring portal. The chat may or may not work")
return EventHandlingResultSuccess
}
message := "Private chat portal created"
mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling)
if ok {
err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID)
message := "Private chat portal created"
err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent)
hasWarning := false
if err != nil {
log.Err(err).Msg("Error in connector newly bridged room handler")
message += fmt.Sprintf("\n\nWarning: %s", err.Error())
log.Warn().Err(err).Msg("Failed to give power to bot in new DM")
message += "\n\nWarning: failed to promote bot"
hasWarning = true
}
mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling)
if ok {
err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID)
if err != nil {
if hasWarning {
message += fmt.Sprintf(", %s", err.Error())
} else {
message += fmt.Sprintf("\n\nWarning: %s", err.Error())
}
}
}
sendNotice(ctx, evt, invitedGhost.Intent, message)
} else {
// TODO ensure user is invited even if PortalInfo wasn't provided?
sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Matrix.ServerName()).MatrixToURL())
rejectInvite(ctx, evt, br.Bot, "")
}
sendNotice(ctx, evt, overrideIntent, message)
return EventHandlingResultSuccess
}
func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWithPower MatrixAPI) error {
@ -280,9 +225,6 @@ func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWith
}
userLevel := powers.GetUserLevel(userWithPower.GetMXID())
if powers.EnsureUserLevelAs(userWithPower.GetMXID(), br.Bot.GetMXID(), userLevel) {
if userLevel > powers.UsersDefault {
powers.SetUserLevel(userWithPower.GetMXID(), userLevel-1)
}
_, err = userWithPower.SendState(ctx, roomID, event.StatePowerLevels, "", &event.Content{
Parsed: powers,
}, time.Time{})
@ -292,3 +234,17 @@ func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWith
}
return nil
}
func (portal *Portal) setMXIDToExistingRoom(roomID id.RoomID) bool {
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
if portal.MXID != "" {
return false
}
portal.MXID = roomID
portal.updateLogger()
portal.Bridge.cacheLock.Lock()
portal.Bridge.portalsByMXID[portal.MXID] = portal
portal.Bridge.cacheLock.Unlock()
return true
}

View file

@ -12,15 +12,13 @@ import (
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type MessageStatusEventInfo struct {
RoomID id.RoomID
TransactionID string
SourceEventID id.EventID
NewEventID id.EventID
EventType event.Type
@ -28,8 +26,6 @@ type MessageStatusEventInfo struct {
Sender id.UserID
ThreadRoot id.EventID
StreamOrder int64
IsSourceEventDoublePuppeted bool
}
func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo {
@ -37,19 +33,13 @@ func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo {
if relatable, ok := evt.Content.Parsed.(event.Relatable); ok {
threadRoot = relatable.OptionalGetRelatesTo().GetThreadParent()
}
_, isDoublePuppeted := evt.Content.Raw[appservice.DoublePuppetKey]
return &MessageStatusEventInfo{
RoomID: evt.RoomID,
TransactionID: evt.Unsigned.TransactionID,
SourceEventID: evt.ID,
EventType: evt.Type,
MessageType: evt.Content.AsMessage().MsgType,
Sender: evt.Sender,
ThreadRoot: threadRoot,
IsSourceEventDoublePuppeted: isDoublePuppeted,
}
}
@ -184,10 +174,9 @@ func (ms *MessageStatus) ToMSSEvent(evt *MessageStatusEventInfo) *event.BeeperMe
Type: event.RelReference,
EventID: evt.SourceEventID,
},
TargetTxnID: evt.TransactionID,
Status: ms.Status,
Reason: ms.ErrorReason,
Message: ms.Message,
Status: ms.Status,
Reason: ms.ErrorReason,
Message: ms.Message,
}
if ms.InternalError != nil {
content.InternalError = ms.InternalError.Error()
@ -222,7 +211,7 @@ func (ms *MessageStatus) ToNoticeEvent(evt *MessageStatusEventInfo) *event.Messa
messagePrefix = "Handling your command panicked"
}
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
MsgType: event.MsgText,
Body: fmt.Sprintf("\u26a0\ufe0f %s: %s", messagePrefix, msg),
RelatesTo: &event.RelatesTo{},
Mentions: &event.Mentions{},

View file

@ -47,8 +47,8 @@ type PortalID string
// As a special case, Receiver MUST be set if the Bridge.Config.SplitPortals flag is set to true.
// The flag is intended for puppeting-only bridges which want multiple logins to create separate portals for each user.
type PortalKey struct {
ID PortalID `json:"portal_id"`
Receiver UserLoginID `json:"portal_receiver,omitempty"`
ID PortalID
Receiver UserLoginID
}
func (pk PortalKey) IsEmpty() bool {
@ -94,11 +94,6 @@ type MessageID string
// Transaction IDs must be unique across users in a room, but don't need to be unique across different rooms.
type TransactionID string
// RawTransactionID is a client-generated identifier for a message send operation on the remote network.
//
// Unlike TransactionID, RawTransactionID's are only used for sending and don't have any uniqueness requirements.
type RawTransactionID string
// PartID is the ID of a message part on the remote network (e.g. index of image in album).
//
// Part IDs are only unique within a message, not globally.

View file

@ -8,7 +8,6 @@ package bridgev2
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
@ -16,9 +15,7 @@ import (
"github.com/rs/zerolog"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
@ -79,28 +76,8 @@ type EventSender struct {
ForceDMUser bool
}
func (es EventSender) MarshalZerologObject(evt *zerolog.Event) {
evt.Str("user_id", string(es.Sender))
if string(es.SenderLogin) != string(es.Sender) {
evt.Str("sender_login", string(es.SenderLogin))
}
if es.IsFromMe {
evt.Bool("is_from_me", true)
}
if es.ForceDMUser {
evt.Bool("force_dm_user", true)
}
}
type ConvertedMessage struct {
ReplyTo *networkid.MessageOptionalPartID
// Optional additional info about the reply. This is only used when backfilling messages
// on Beeper, where replies may target messages that haven't been bridged yet.
// Standard Matrix servers can't backwards backfill, so these are never used.
ReplyToRoom networkid.PortalKey
ReplyToUser networkid.UserID
ReplyToLogin networkid.UserLoginID
ReplyTo *networkid.MessageOptionalPartID
ThreadRoot *networkid.MessageID
Parts []*ConvertedMessagePart
Disappear database.DisappearingSetting
@ -119,15 +96,11 @@ func MergeCaption(textPart, mediaPart *ConvertedMessagePart) *ConvertedMessagePa
mediaPart.Content.EnsureHasHTML()
mediaPart.Content.Body += "\n\n" + textPart.Content.Body
mediaPart.Content.FormattedBody += "<br><br>" + textPart.Content.FormattedBody
mediaPart.Content.Mentions = mediaPart.Content.Mentions.Merge(textPart.Content.Mentions)
mediaPart.Content.BeeperLinkPreviews = append(mediaPart.Content.BeeperLinkPreviews, textPart.Content.BeeperLinkPreviews...)
} else {
mediaPart.Content.FileName = mediaPart.Content.Body
mediaPart.Content.Body = textPart.Content.Body
mediaPart.Content.Format = textPart.Content.Format
mediaPart.Content.FormattedBody = textPart.Content.FormattedBody
mediaPart.Content.Mentions = textPart.Content.Mentions
mediaPart.Content.BeeperLinkPreviews = textPart.Content.BeeperLinkPreviews
}
if metaMerger, ok := mediaPart.DBMetadata.(database.MetaMerger); ok {
metaMerger.CopyFrom(textPart.DBMetadata)
@ -254,14 +227,9 @@ type NetworkConnector interface {
// This should generally not do any work, it should just return a LoginProcess that remembers
// the user and will execute the requested flow. The actual work should start when [LoginProcess.Start] is called.
CreateLogin(ctx context.Context, user *User, flowID string) (LoginProcess, error)
// GetBridgeInfoVersion returns version numbers for bridge info and room capabilities respectively.
// When the versions change, the bridge will automatically resend bridge info to all rooms.
GetBridgeInfoVersion() (info, capabilities int)
}
type StoppableNetwork interface {
NetworkConnector
// Stop is called when the bridge is stopping, after all network clients have been disconnected.
Stop()
}
@ -286,11 +254,6 @@ type IdentifierValidatingNetwork interface {
ValidateUserID(id networkid.UserID) bool
}
type TransactionIDGeneratingNetwork interface {
NetworkConnector
GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID
}
type PortalBridgeInfoFillingNetwork interface {
NetworkConnector
FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent)
@ -318,16 +281,6 @@ type MaxFileSizeingNetwork interface {
SetMaxFileSize(maxSize int64)
}
type NetworkResettingNetwork interface {
NetworkConnector
// ResetHTTPTransport should recreate the HTTP client used by the bridge.
// It should refetch settings from the Matrix connector using GetHTTPClientSettings if applicable.
ResetHTTPTransport()
// ResetNetworkConnections should forcefully disconnect and restart any persistent network connections.
// ResetHTTPTransport will usually be called before this, so resetting the transport is not necessary here.
ResetNetworkConnections()
}
type RemoteEchoHandler func(RemoteMessage, *database.Message) (bool, error)
type MatrixMessageResponse struct {
@ -344,12 +297,9 @@ type MatrixMessageResponse struct {
PostSave func(context.Context, *database.Message)
}
type OutgoingTimeoutConfig struct {
CheckInterval time.Duration
NoEchoTimeout time.Duration
NoEchoMessage string
NoAckTimeout time.Duration
NoAckMessage string
type FileRestriction struct {
MaxSize int64
MimeTypes []string
}
type NetworkGeneralCapabilities struct {
@ -359,16 +309,35 @@ type NetworkGeneralCapabilities struct {
// Should the bridge re-request user info on incoming messages even if the ghost already has info?
// By default, info is only requested for ghosts with no name, and other updating is left to events.
AggressiveUpdateInfo bool
// Should the bridge call HandleMatrixReadReceipt with fake data when receiving a new message?
// This should be enabled if the network requires each message to be marked as read independently,
// and doesn't automatically do it when sending a message.
ImplicitReadReceipts bool
// If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave])
// to handle asynchronous message responses, this field can be set to enable
// automatic timeout errors in case the asynchronous response never arrives.
OutgoingMessageTimeouts *OutgoingTimeoutConfig
// Capabilities related to the provisioning API.
Provisioning ProvisioningCapabilities
}
type NetworkRoomCapabilities struct {
FormattedText bool
UserMentions bool
RoomMentions bool
LocationMessages bool
Captions bool
MaxTextLength int
MaxCaptionLength int
Polls bool
Threads bool
Replies bool
Edits bool
EditMaxCount int
EditMaxAge time.Duration
Deletes bool
DeleteMaxAge time.Duration
DefaultFileRestriction *FileRestriction
Files map[event.MessageType]FileRestriction
ReadReceipts bool
Reactions bool
ReactionCount int
AllowedReactions []string
}
// NetworkAPI is an interface representing a remote network client for a single user login.
@ -403,7 +372,7 @@ type NetworkAPI interface {
// GetCapabilities returns the bridging capabilities in a given room.
// This can simply return a static list if the remote network has no per-chat capability differences,
// but all calls will include the portal, because some networks do have per-chat differences.
GetCapabilities(ctx context.Context, portal *Portal) *event.RoomFeatures
GetCapabilities(ctx context.Context, portal *Portal) *NetworkRoomCapabilities
// HandleMatrixMessage is called when a message is sent from Matrix in an existing portal room.
// This function should convert the message as appropriate, send it over to the remote network,
@ -413,30 +382,6 @@ type NetworkAPI interface {
HandleMatrixMessage(ctx context.Context, msg *MatrixMessage) (message *MatrixMessageResponse, err error)
}
type ConnectBackgroundParams struct {
// RawData is the raw data in the push that triggered the background connection.
RawData json.RawMessage
// ExtraData is the data returned by [PushParsingNetwork.ParsePushNotification].
// It's only present for native pushes. Relayed pushes will only have the raw data.
ExtraData any
}
// BackgroundSyncingNetworkAPI is an optional interface that network connectors can implement to support background resyncs.
type BackgroundSyncingNetworkAPI interface {
NetworkAPI
// ConnectBackground is called in place of Connect for background resyncs.
// The client should connect to the remote network, handle pending messages, and then disconnect.
// This call should block until the entire sync is complete and the client is disconnected.
ConnectBackground(ctx context.Context, params *ConnectBackgroundParams) error
}
// CredentialExportingNetworkAPI is an optional interface that networks connectors can implement to support export of
// the credentials associated with that login. Credential type is bridge specific.
type CredentialExportingNetworkAPI interface {
NetworkAPI
ExportCredentials(ctx context.Context) any
}
// FetchMessagesParams contains the parameters for a message history pagination request.
type FetchMessagesParams struct {
// The portal to fetch messages in. Always present.
@ -573,18 +518,11 @@ type FetchMessagesResponse struct {
// BackfillingNetworkAPI is an optional interface that network connectors can implement to support backfilling message history.
type BackfillingNetworkAPI interface {
NetworkAPI
// FetchMessages returns a batch of messages to backfill in a portal room.
// For details on the input and output, see the documentation of [FetchMessagesParams] and [FetchMessagesResponse].
FetchMessages(ctx context.Context, fetchParams FetchMessagesParams) (*FetchMessagesResponse, error)
}
// BackfillingNetworkAPIWithLimits is an optional interface that network connectors can implement to customize
// the limit for backwards backfilling tasks. It is recommended to implement this by reading the MaxBatchesOverride
// config field with network-specific keys for different room types.
type BackfillingNetworkAPIWithLimits interface {
BackfillingNetworkAPI
// GetBackfillMaxBatchCount is called before a backfill task is executed to determine the maximum number of batches
// that should be backfilled. Return values less than 0 are treated as unlimited.
GetBackfillMaxBatchCount(ctx context.Context, portal *Portal, task *database.BackfillTask) int
}
@ -638,16 +576,6 @@ type ReadReceiptHandlingNetworkAPI interface {
HandleMatrixReadReceipt(ctx context.Context, msg *MatrixReadReceipt) error
}
// ChatViewingNetworkAPI is an optional interface that network connectors can implement to handle viewing chat status.
type ChatViewingNetworkAPI interface {
NetworkAPI
// HandleMatrixViewingChat is called when the user opens a portal room.
// This will never be called by the standard appservice connector,
// as Matrix doesn't have any standard way of signaling chat open status.
// Clients are expected to call this every 5 seconds. There is no signal for closing a chat.
HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error
}
// TypingHandlingNetworkAPI is an optional interface that network connectors can implement to handle typing events.
type TypingHandlingNetworkAPI interface {
NetworkAPI
@ -702,35 +630,6 @@ type RoomTopicHandlingNetworkAPI interface {
HandleMatrixRoomTopic(ctx context.Context, msg *MatrixRoomTopic) (bool, error)
}
type DisappearTimerChangingNetworkAPI interface {
NetworkAPI
// HandleMatrixDisappearingTimer is called when the disappearing timer of a portal room is changed.
// This method should update the Disappear field of the Portal with the new timer and return true
// if the change was successful. If the change is not successful, then the field should not be updated.
HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error)
}
// DeleteChatHandlingNetworkAPI is an optional interface that network connectors
// can implement to delete a chat from the remote network.
type DeleteChatHandlingNetworkAPI interface {
NetworkAPI
// HandleMatrixDeleteChat is called when the user explicitly deletes a chat.
HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error
}
// MessageRequestAcceptingNetworkAPI is an optional interface that network connectors
// can implement to accept message requests from the remote network.
type MessageRequestAcceptingNetworkAPI interface {
NetworkAPI
// HandleMatrixAcceptMessageRequest is called when the user accepts a message request.
HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error
}
type BeeperAIStreamHandlingNetworkAPI interface {
NetworkAPI
HandleMatrixBeeperAIStream(ctx context.Context, msg *MatrixBeeperAIStream) error
}
type ResolveIdentifierResponse struct {
// Ghost is the ghost of the user that the identifier resolves to.
// This field should be set whenever possible. However, it is not required,
@ -750,27 +649,11 @@ type ResolveIdentifierResponse struct {
Chat *CreateChatResponse
}
var SpecialValueDMRedirectedToBot = networkid.UserID("__fi.mau.bridgev2.dm_redirected_to_bot::" + random.String(10))
type CreateChatResponse struct {
PortalKey networkid.PortalKey
// Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary.
Portal *Portal
PortalInfo *ChatInfo
// If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user,
// this field should have the user ID of said different user.
DMRedirectedTo networkid.UserID
FailedParticipants map[networkid.UserID]*CreateChatFailedParticipant
}
type CreateChatFailedParticipant struct {
Reason string `json:"reason"`
InviteEventType string `json:"invite_event_type,omitempty"`
InviteContent *event.Content `json:"invite_content,omitempty"`
UserMXID id.UserID `json:"user_mxid,omitempty"`
DMRoomMXID id.RoomID `json:"dm_room_mxid,omitempty"`
}
// IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats.
@ -805,83 +688,7 @@ type UserSearchingNetworkAPI interface {
type GroupCreatingNetworkAPI interface {
IdentifierResolvingNetworkAPI
CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error)
}
type PersonalFilteringCustomizingNetworkAPI interface {
NetworkAPI
CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom)
}
type ProvisioningCapabilities struct {
ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"`
GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"`
}
type ResolveIdentifierCapabilities struct {
// Can DMs be created after resolving an identifier?
CreateDM bool `json:"create_dm"`
// Can users be looked up by phone number?
LookupPhone bool `json:"lookup_phone"`
// Can users be looked up by email address?
LookupEmail bool `json:"lookup_email"`
// Can users be looked up by network-specific username?
LookupUsername bool `json:"lookup_username"`
// Can any phone number be contacted without having to validate it via lookup first?
AnyPhone bool `json:"any_phone"`
// Can a contact list be retrieved from the bridge?
ContactList bool `json:"contact_list"`
// Can users be searched by name on the remote network?
Search bool `json:"search"`
}
type GroupTypeCapabilities struct {
TypeDescription string `json:"type_description"`
Name GroupFieldCapability `json:"name"`
Username GroupFieldCapability `json:"username"`
Avatar GroupFieldCapability `json:"avatar"`
Topic GroupFieldCapability `json:"topic"`
Disappear GroupFieldCapability `json:"disappear"`
Participants GroupFieldCapability `json:"participants"`
Parent GroupFieldCapability `json:"parent"`
}
type GroupFieldCapability struct {
// Is setting this field allowed at all in the create request?
// Even if false, the network connector should attempt to set the metadata after group creation,
// as the allowed flag can't be enforced properly when creating a group for an existing Matrix room.
Allowed bool `json:"allowed"`
// Is setting this field mandatory for the creation to succeed?
Required bool `json:"required,omitempty"`
// The minimum/maximum length of the field, if applicable.
// For members, length means the number of members excluding the creator.
MinLength int `json:"min_length,omitempty"`
MaxLength int `json:"max_length,omitempty"`
// Only for the disappear field: allowed disappearing settings
DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"`
// This can be used to tell provisionutil not to call ValidateUserID on each participant.
// It only meant to allow hacks where ResolveIdentifier returns a fake ID that isn't actually valid for MXIDs.
SkipIdentifierValidation bool `json:"-"`
}
type GroupCreateParams struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
// Clients may also provide MXIDs here, but provisionutil will normalize them, so bridges only need to handle network IDs
Participants []networkid.UserID `json:"participants,omitempty"`
Parent *networkid.PortalKey `json:"parent,omitempty"`
Name *event.RoomNameEventContent `json:"name,omitempty"`
Avatar *event.RoomAvatarEventContent `json:"avatar,omitempty"`
Topic *event.TopicEventContent `json:"topic,omitempty"`
Disappear *event.BeeperDisappearingTimer `json:"disappear,omitempty"`
// An existing room ID to bridge to. If unset, a new room will be created.
RoomID id.RoomID `json:"room_id,omitempty"`
CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error)
}
type MembershipChangeType struct {
@ -921,15 +728,16 @@ type MatrixMembershipChange struct {
MatrixRoomMeta[*event.MemberEventContent]
Target GhostOrUserLogin
Type MembershipChangeType
}
type MatrixMembershipResult struct {
RedirectTo networkid.UserID
// Deprecated: Use Target instead
TargetGhost *Ghost
// Deprecated: Use Target instead
TargetUserLogin *UserLogin
}
type MembershipHandlingNetworkAPI interface {
NetworkAPI
HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (*MatrixMembershipResult, error)
HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (bool, error)
}
type SinglePowerLevelChange struct {
@ -1015,35 +823,17 @@ type APNsPushConfig struct {
}
type PushConfig struct {
Web *WebPushConfig `json:"web,omitempty"`
FCM *FCMPushConfig `json:"fcm,omitempty"`
APNs *APNsPushConfig `json:"apns,omitempty"`
// If Native is true, it means the network supports registering for pushes
// that are delivered directly to the app without the use of a push relay.
Native bool `json:"native,omitempty"`
Web *WebPushConfig `json:"web,omitempty"`
FCM *FCMPushConfig `json:"fcm,omitempty"`
APNs *APNsPushConfig `json:"apns,omitempty"`
Native bool `json:"native,omitempty"`
}
// PushableNetworkAPI is an optional interface that network connectors can implement
// to support waking up the wrapper app using push notifications.
type PushableNetworkAPI interface {
NetworkAPI
// RegisterPushNotifications is called when the wrapper app wants to register a push token with the remote network.
RegisterPushNotifications(ctx context.Context, pushType PushType, token string) error
// GetPushConfigs is used to find which types of push notifications the remote network can provide.
GetPushConfigs() *PushConfig
}
// PushParsingNetwork is an optional interface that network connectors can implement
// to support parsing native push notifications from networks.
type PushParsingNetwork interface {
NetworkConnector
// ParsePushNotification is called when a native push is received.
// It must return the corresponding user login ID to wake up, plus optionally data to pass to the wakeup call.
ParsePushNotification(ctx context.Context, data json.RawMessage) (networkid.UserLoginID, any, error)
}
type RemoteEventType int
func (ret RemoteEventType) String() string {
@ -1115,11 +905,6 @@ type RemoteEvent interface {
GetSender() EventSender
}
type RemoteEventWithContextMutation interface {
RemoteEvent
MutateContext(ctx context.Context) context.Context
}
type RemoteEventWithUncertainPortalReceiver interface {
RemoteEvent
PortalReceiverIsUncertain() bool
@ -1173,11 +958,6 @@ type RemoteChatDelete interface {
RemoteDeleteOnlyForMe
}
type RemoteChatDeleteWithChildren interface {
RemoteChatDelete
DeleteChildren() bool
}
type RemoteEventThatMayCreatePortal interface {
RemoteEvent
ShouldCreatePortal() bool
@ -1297,11 +1077,6 @@ type RemoteReadReceipt interface {
GetReadUpTo() time.Time
}
type RemoteReadReceiptWithStreamOrder interface {
RemoteReadReceipt
GetReadUpToStreamOrder() int64
}
type RemoteDeliveryReceipt interface {
RemoteEvent
GetReceiptTargets() []networkid.MessageID
@ -1337,7 +1112,6 @@ type OrigSender struct {
RequiresDisambiguation bool
DisambiguatedName string
FormattedName string
PerMessageProfile event.BeeperPerMessageProfile
event.MemberEventContent
}
@ -1352,16 +1126,12 @@ type MatrixEventBase[ContentType any] struct {
// The original sender user ID. Only present in case the event is being relayed (and Sender is not the same user).
OrigSender *OrigSender
InputTransactionID networkid.RawTransactionID
}
type MatrixMessage struct {
MatrixEventBase[*event.MessageEventContent]
ThreadRoot *database.Message
ReplyTo *database.Message
pendingSaves []*outgoingMessage
}
type MatrixEdit struct {
@ -1410,14 +1180,12 @@ type MatrixMessageRemove struct {
type MatrixRoomMeta[ContentType any] struct {
MatrixEventBase[ContentType]
PrevContent ContentType
IsStateRequest bool
PrevContent ContentType
}
type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent]
type MatrixRoomAvatar = MatrixRoomMeta[*event.RoomAvatarEventContent]
type MatrixRoomTopic = MatrixRoomMeta[*event.TopicEventContent]
type MatrixDisappearingTimer = MatrixRoomMeta[*event.BeeperDisappearingTimer]
type MatrixReadReceipt struct {
Portal *Portal
@ -1432,8 +1200,6 @@ type MatrixReadReceipt struct {
LastRead time.Time
// The receipt metadata.
Receipt event.ReadReceipt
// Whether the receipt is implicit, i.e. triggered by an incoming timeline event rather than an explicit receipt.
Implicit bool
}
type MatrixTyping struct {
@ -1442,14 +1208,6 @@ type MatrixTyping struct {
Type TypingType
}
type MatrixViewingChat struct {
// The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge.
Portal *Portal
}
type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent]
type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent]
type MatrixBeeperAIStream = MatrixEventBase[*event.BeeperAIStreamEventContent]
type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent]
type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent]
type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent]

File diff suppressed because it is too large Load diff

View file

@ -61,9 +61,6 @@ func (portal *Portal) doForwardBackfill(ctx context.Context, source *UserLogin,
return
} else if len(resp.Messages) == 0 {
log.Debug().Msg("No messages to backfill")
if resp.CompleteCallback != nil {
resp.CompleteCallback()
}
return
}
log.Debug().
@ -194,9 +191,6 @@ func (portal *Portal) doThreadBackfill(ctx context.Context, source *UserLogin, t
if err != nil {
log.Err(err).Msg("Failed to get last thread message")
return
} else if anchorMessage == nil {
log.Warn().Msg("No messages found in thread?")
return
}
resp := portal.fetchThreadBackfill(ctx, source, anchorMessage)
if resp != nil {
@ -326,13 +320,8 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin
if len(msg.Parts) == 0 {
return
}
intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage)
if !ok {
return
}
replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(
ctx, msg.ID, msg.ConvertedMessage, true,
)
intent := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage)
replyTo, threadRoot, prevThreadEvent := portal.getRelationMeta(ctx, msg.ID, msg.ReplyTo, msg.ThreadRoot, true)
if threadRoot != nil && out.PrevThreadEvents[*msg.ThreadRoot] != "" {
prevThreadEvent.MXID = out.PrevThreadEvents[*msg.ThreadRoot]
}
@ -341,21 +330,19 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin
var firstPart *database.Message
for i, part := range msg.Parts {
partIDs = append(partIDs, part.ID)
portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent)
part.Content.BeeperDisappearingTimer = msg.Disappear.ToEventContent()
portal.applyRelationMeta(part.Content, replyTo, threadRoot, prevThreadEvent)
evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID)
dbMessage := &database.Message{
ID: msg.ID,
PartID: part.ID,
MXID: evtID,
Room: portal.PortalKey,
SenderID: msg.Sender.Sender,
SenderMXID: intent.GetMXID(),
Timestamp: msg.Timestamp,
ThreadRoot: ptr.Val(msg.ThreadRoot),
ReplyTo: ptr.Val(msg.ReplyTo),
Metadata: part.DBMetadata,
IsDoublePuppeted: intent.IsDoublePuppet(),
ID: msg.ID,
PartID: part.ID,
MXID: evtID,
Room: portal.PortalKey,
SenderID: msg.Sender.Sender,
SenderMXID: intent.GetMXID(),
Timestamp: msg.Timestamp,
ThreadRoot: ptr.Val(msg.ThreadRoot),
ReplyTo: ptr.Val(msg.ReplyTo),
Metadata: part.DBMetadata,
}
if part.DontBridge {
dbMessage.SetFakeMXID()
@ -383,34 +370,26 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin
prevThreadEvent.MXID = evtID
out.PrevThreadEvents[*msg.ThreadRoot] = evtID
}
if msg.Disappear.Type != event.DisappearingTypeNone {
if msg.Disappear.Type == event.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() {
if msg.Disappear.Type != database.DisappearingTypeNone {
if msg.Disappear.Type == database.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() {
msg.Disappear.DisappearAt = msg.Timestamp.Add(msg.Disappear.Timer)
}
out.Disappear = append(out.Disappear, &database.DisappearingMessage{
RoomID: portal.MXID,
EventID: evtID,
Timestamp: msg.Timestamp,
DisappearingSetting: msg.Disappear,
})
}
}
slices.Sort(partIDs)
for _, reaction := range msg.Reactions {
if reaction == nil {
continue
}
reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove)
if !ok {
continue
}
reactionIntent := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReactionRemove)
if reaction.TargetPart == nil {
reaction.TargetPart = &partIDs[0]
}
if reaction.Timestamp.IsZero() {
reaction.Timestamp = msg.Timestamp.Add(10 * time.Millisecond)
}
//lint:ignore SA4006 it's a todo
targetPart, ok := partMap[*reaction.TargetPart]
if !ok {
// TODO warning log and/or skip reaction?
@ -530,11 +509,8 @@ func (portal *Portal) sendBatch(ctx context.Context, source *UserLogin, messages
func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin, messages []*BackfillMessage, markRead bool) {
var lastPart id.EventID
for _, msg := range messages {
intent, ok := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage)
if !ok {
continue
}
dbMessages, _ := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event {
intent := portal.GetIntentFor(ctx, msg.Sender, source, RemoteEventMessage)
dbMessages := portal.sendConvertedMessage(ctx, msg.ID, intent, msg.Sender.Sender, msg.ConvertedMessage, msg.Timestamp, msg.StreamOrder, func(z *zerolog.Event) *zerolog.Event {
return z.
Str("message_id", string(msg.ID)).
Any("sender_id", msg.Sender).
@ -543,10 +519,7 @@ func (portal *Portal) sendLegacyBackfill(ctx context.Context, source *UserLogin,
if len(dbMessages) > 0 {
lastPart = dbMessages[len(dbMessages)-1].MXID
for _, reaction := range msg.Reactions {
reactionIntent, ok := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction)
if !ok {
continue
}
reactionIntent := portal.GetIntentFor(ctx, reaction.Sender, source, RemoteEventReaction)
targetPart := dbMessages[0]
if reaction.TargetPart != nil {
targetPartIdx := slices.IndexFunc(dbMessages, func(dbMsg *database.Message) bool {

View file

@ -29,30 +29,26 @@ func (portal *PortalInternals) UpdateLogger() {
(*Portal)(portal).updateLogger()
}
func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) EventHandlingResult {
return (*Portal)(portal).queueEvent(ctx, evt)
func (portal *PortalInternals) QueueEvent(ctx context.Context, evt portalEvent) {
(*Portal)(portal).queueEvent(ctx, evt)
}
func (portal *PortalInternals) EventLoop() {
(*Portal)(portal).eventLoop()
}
func (portal *PortalInternals) HandleSingleEventWithDelayLogging(idx int, rawEvt any) (outerRes EventHandlingResult) {
return (*Portal)(portal).handleSingleEventWithDelayLogging(idx, rawEvt)
func (portal *PortalInternals) HandleSingleEventAsync(idx int, rawEvt any) {
(*Portal)(portal).handleSingleEventAsync(idx, rawEvt)
}
func (portal *PortalInternals) GetEventCtxWithLog(rawEvt any, idx int) context.Context {
return (*Portal)(portal).getEventCtxWithLog(rawEvt, idx)
}
func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(EventHandlingResult)) {
func (portal *PortalInternals) HandleSingleEvent(ctx context.Context, rawEvt any, doneCallback func()) {
(*Portal)(portal).handleSingleEvent(ctx, rawEvt, doneCallback)
}
func (portal *PortalInternals) UnwrapBeeperSendState(ctx context.Context, evt *event.Event) error {
return (*Portal)(portal).unwrapBeeperSendState(ctx, evt)
}
func (portal *PortalInternals) SendSuccessStatus(ctx context.Context, evt *event.Event, streamOrder int64, newEventID id.EventID) {
(*Portal)(portal).sendSuccessStatus(ctx, evt, streamOrder, newEventID)
}
@ -65,24 +61,20 @@ func (portal *PortalInternals) CheckConfusableName(ctx context.Context, userID i
return (*Portal)(portal).checkConfusableName(ctx, userID, name)
}
func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event, isStateRequest bool) EventHandlingResult {
return (*Portal)(portal).handleMatrixEvent(ctx, sender, evt, isStateRequest)
func (portal *PortalInternals) HandleMatrixEvent(ctx context.Context, sender *User, evt *event.Event) {
(*Portal)(portal).handleMatrixEvent(ctx, sender, evt)
}
func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixReceipts(ctx, evt)
func (portal *PortalInternals) HandleMatrixReceipts(ctx context.Context, evt *event.Event) {
(*Portal)(portal).handleMatrixReceipts(ctx, evt)
}
func (portal *PortalInternals) HandleMatrixReadReceipt(ctx context.Context, user *User, eventID id.EventID, receipt event.ReadReceipt) {
(*Portal)(portal).handleMatrixReadReceipt(ctx, user, eventID, receipt)
}
func (portal *PortalInternals) CallReadReceiptHandler(ctx context.Context, login *UserLogin, rrClient ReadReceiptHandlingNetworkAPI, evt *MatrixReadReceipt, userPortal *database.UserPortal) {
(*Portal)(portal).callReadReceiptHandler(ctx, login, rrClient, evt, userPortal)
}
func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixTyping(ctx, evt)
func (portal *PortalInternals) HandleMatrixTyping(ctx context.Context, evt *event.Event) {
(*Portal)(portal).handleMatrixTyping(ctx, evt)
}
func (portal *PortalInternals) SendTypings(ctx context.Context, userIDs []id.UserID, typing bool) {
@ -93,83 +85,55 @@ func (portal *PortalInternals) PeriodicTypingUpdater() {
(*Portal)(portal).periodicTypingUpdater()
}
func (portal *PortalInternals) CheckMessageContentCaps(caps *event.RoomFeatures, content *event.MessageEventContent) error {
return (*Portal)(portal).checkMessageContentCaps(caps, content)
func (portal *PortalInternals) CheckMessageContentCaps(ctx context.Context, caps *NetworkRoomCapabilities, content *event.MessageEventContent, evt *event.Event) bool {
return (*Portal)(portal).checkMessageContentCaps(ctx, caps, content, evt)
}
func (portal *PortalInternals) ParseInputTransactionID(origSender *OrigSender, evt *event.Event) networkid.RawTransactionID {
return (*Portal)(portal).parseInputTransactionID(origSender, evt)
func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) {
(*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt)
}
func (portal *PortalInternals) HandleMatrixMessage(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixMessage(ctx, sender, origSender, evt)
func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *NetworkRoomCapabilities) {
(*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps)
}
func (portal *PortalInternals) PendingMessageTimeoutLoop(ctx context.Context, cfg *OutgoingTimeoutConfig) {
(*Portal)(portal).pendingMessageTimeoutLoop(ctx, cfg)
}
func (portal *PortalInternals) CheckPendingMessages(ctx context.Context, cfg *OutgoingTimeoutConfig) {
(*Portal)(portal).checkPendingMessages(ctx, cfg)
}
func (portal *PortalInternals) HandleMatrixEdit(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, content *event.MessageEventContent, caps *event.RoomFeatures) EventHandlingResult {
return (*Portal)(portal).handleMatrixEdit(ctx, sender, origSender, evt, content, caps)
}
func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixReaction(ctx, sender, evt)
func (portal *PortalInternals) HandleMatrixReaction(ctx context.Context, sender *UserLogin, evt *event.Event) {
(*Portal)(portal).handleMatrixReaction(ctx, sender, evt)
}
func (portal *PortalInternals) GetTargetUser(ctx context.Context, userID id.UserID) (GhostOrUserLogin, error) {
return (*Portal)(portal).getTargetUser(ctx, userID)
}
func (portal *PortalInternals) HandleMatrixDeleteChat(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixDeleteChat(ctx, sender, origSender, evt)
func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) {
(*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt)
}
func (portal *PortalInternals) HandleMatrixMembership(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult {
return (*Portal)(portal).handleMatrixMembership(ctx, sender, origSender, evt, isStateRequest)
func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) {
(*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt)
}
func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event, isStateRequest bool) EventHandlingResult {
return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt, isStateRequest)
func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) {
(*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt)
}
func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixTombstone(ctx, evt)
func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) {
(*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt)
}
func (portal *PortalInternals) UpdateInfoAfterTombstone(ctx context.Context, senderUser *User) {
(*Portal)(portal).updateInfoAfterTombstone(ctx, senderUser)
}
func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult {
return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt)
}
func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *UserLogin, evtType RemoteEventType, evt RemoteEvent) (res EventHandlingResult) {
return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt)
}
func (portal *PortalInternals) EnsureFunctionalMember(ctx context.Context, ghost *Ghost) {
(*Portal)(portal).ensureFunctionalMember(ctx, ghost)
}
func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) {
func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID) {
return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType)
}
func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsgID networkid.MessageID, currentMsg *ConvertedMessage, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) {
return (*Portal)(portal).getRelationMeta(ctx, currentMsgID, currentMsg, isBatchSend)
func (portal *PortalInternals) GetRelationMeta(ctx context.Context, currentMsg networkid.MessageID, replyToPtr *networkid.MessageOptionalPartID, threadRootPtr *networkid.MessageID, isBatchSend bool) (replyTo, threadRoot, prevThreadEvent *database.Message) {
return (*Portal)(portal).getRelationMeta(ctx, currentMsg, replyToPtr, threadRootPtr, isBatchSend)
}
func (portal *PortalInternals) ApplyRelationMeta(ctx context.Context, content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) {
(*Portal)(portal).applyRelationMeta(ctx, content, replyTo, threadRoot, prevThreadEvent)
func (portal *PortalInternals) ApplyRelationMeta(content *event.MessageEventContent, replyTo, threadRoot, prevThreadEvent *database.Message) {
(*Portal)(portal).applyRelationMeta(content, replyTo, threadRoot, prevThreadEvent)
}
func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) ([]*database.Message, EventHandlingResult) {
func (portal *PortalInternals) SendConvertedMessage(ctx context.Context, id networkid.MessageID, intent MatrixAPI, senderID networkid.UserID, converted *ConvertedMessage, ts time.Time, streamOrder int64, logContext func(*zerolog.Event) *zerolog.Event) []*database.Message {
return (*Portal)(portal).sendConvertedMessage(ctx, id, intent, senderID, converted, ts, streamOrder, logContext)
}
@ -177,24 +141,24 @@ func (portal *PortalInternals) CheckPendingMessage(ctx context.Context, evt Remo
return (*Portal)(portal).checkPendingMessage(ctx, evt)
}
func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) (handleRes EventHandlingResult, continueHandling bool) {
func (portal *PortalInternals) HandleRemoteUpsert(ctx context.Context, source *UserLogin, evt RemoteMessageUpsert, existing []*database.Message) bool {
return (*Portal)(portal).handleRemoteUpsert(ctx, source, evt, existing)
}
func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) (res EventHandlingResult) {
return (*Portal)(portal).handleRemoteMessage(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteMessage(ctx context.Context, source *UserLogin, evt RemoteMessage) {
(*Portal)(portal).handleRemoteMessage(ctx, source, evt)
}
func (portal *PortalInternals) SendRemoteErrorNotice(ctx context.Context, intent MatrixAPI, err error, ts time.Time, evtTypeName string) {
(*Portal)(portal).sendRemoteErrorNotice(ctx, intent, err, ts, evtTypeName)
}
func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) EventHandlingResult {
return (*Portal)(portal).handleRemoteEdit(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteEdit(ctx context.Context, source *UserLogin, evt RemoteEdit) {
(*Portal)(portal).handleRemoteEdit(ctx, source, evt)
}
func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) EventHandlingResult {
return (*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder)
func (portal *PortalInternals) SendConvertedEdit(ctx context.Context, targetID networkid.MessageID, senderID networkid.UserID, converted *ConvertedEdit, intent MatrixAPI, ts time.Time, streamOrder int64) {
(*Portal)(portal).sendConvertedEdit(ctx, targetID, senderID, converted, intent, ts, streamOrder)
}
func (portal *PortalInternals) GetTargetMessagePart(ctx context.Context, evt RemoteEventWithTargetMessage) (*database.Message, error) {
@ -205,84 +169,76 @@ func (portal *PortalInternals) GetTargetReaction(ctx context.Context, evt Remote
return (*Portal)(portal).getTargetReaction(ctx, evt)
}
func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) EventHandlingResult {
return (*Portal)(portal).handleRemoteReactionSync(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteReactionSync(ctx context.Context, source *UserLogin, evt RemoteReactionSync) {
(*Portal)(portal).handleRemoteReactionSync(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) EventHandlingResult {
return (*Portal)(portal).handleRemoteReaction(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteReaction(ctx context.Context, source *UserLogin, evt RemoteReaction) {
(*Portal)(portal).handleRemoteReaction(ctx, source, evt)
}
func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) EventHandlingResult {
return (*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext)
func (portal *PortalInternals) SendConvertedReaction(ctx context.Context, senderID networkid.UserID, intent MatrixAPI, targetMessage *database.Message, emojiID networkid.EmojiID, emoji string, ts time.Time, dbMetadata any, extraContent map[string]any, logContext func(*zerolog.Event) *zerolog.Event) {
(*Portal)(portal).sendConvertedReaction(ctx, senderID, intent, targetMessage, emojiID, emoji, ts, dbMetadata, extraContent, logContext)
}
func (portal *PortalInternals) GetIntentForMXID(ctx context.Context, userID id.UserID) (MatrixAPI, error) {
return (*Portal)(portal).getIntentForMXID(ctx, userID)
}
func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) EventHandlingResult {
return (*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteReactionRemove(ctx context.Context, source *UserLogin, evt RemoteReactionRemove) {
(*Portal)(portal).handleRemoteReactionRemove(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) EventHandlingResult {
return (*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteMessageRemove(ctx context.Context, source *UserLogin, evt RemoteMessageRemove) {
(*Portal)(portal).handleRemoteMessageRemove(ctx, source, evt)
}
func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) EventHandlingResult {
return (*Portal)(portal).redactMessageParts(ctx, parts, intent, ts)
func (portal *PortalInternals) RedactMessageParts(ctx context.Context, parts []*database.Message, intent MatrixAPI, ts time.Time) {
(*Portal)(portal).redactMessageParts(ctx, parts, intent, ts)
}
func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) EventHandlingResult {
return (*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteReadReceipt(ctx context.Context, source *UserLogin, evt RemoteReadReceipt) {
(*Portal)(portal).handleRemoteReadReceipt(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) EventHandlingResult {
return (*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteMarkUnread(ctx context.Context, source *UserLogin, evt RemoteMarkUnread) {
(*Portal)(portal).handleRemoteMarkUnread(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) EventHandlingResult {
return (*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteDeliveryReceipt(ctx context.Context, source *UserLogin, evt RemoteDeliveryReceipt) {
(*Portal)(portal).handleRemoteDeliveryReceipt(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) EventHandlingResult {
return (*Portal)(portal).handleRemoteTyping(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteTyping(ctx context.Context, source *UserLogin, evt RemoteTyping) {
(*Portal)(portal).handleRemoteTyping(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) EventHandlingResult {
return (*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteChatInfoChange(ctx context.Context, source *UserLogin, evt RemoteChatInfoChange) {
(*Portal)(portal).handleRemoteChatInfoChange(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) EventHandlingResult {
return (*Portal)(portal).handleRemoteChatResync(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteChatResync(ctx context.Context, source *UserLogin, evt RemoteChatResync) {
(*Portal)(portal).handleRemoteChatResync(ctx, source, evt)
}
func (portal *PortalInternals) FindOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) {
return (*Portal)(portal).findOtherLogins(ctx, source)
func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) {
(*Portal)(portal).handleRemoteChatDelete(ctx, source, evt)
}
func (portal *PortalInternals) HandleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult {
return (*Portal)(portal).handleRemoteChatDelete(ctx, source, evt)
func (portal *PortalInternals) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) {
(*Portal)(portal).handleRemoteBackfill(ctx, source, backfill)
}
func (portal *PortalInternals) HandleRemoteBackfill(ctx context.Context, source *UserLogin, backfill RemoteBackfill) (res EventHandlingResult) {
return (*Portal)(portal).handleRemoteBackfill(ctx, source, backfill)
func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time) bool {
return (*Portal)(portal).updateName(ctx, name, sender, ts)
}
func (portal *PortalInternals) UpdateName(ctx context.Context, name string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool {
return (*Portal)(portal).updateName(ctx, name, sender, ts, excludeFromTimeline)
func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time) bool {
return (*Portal)(portal).updateTopic(ctx, topic, sender, ts)
}
func (portal *PortalInternals) UpdateTopic(ctx context.Context, topic string, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool {
return (*Portal)(portal).updateTopic(ctx, topic, sender, ts, excludeFromTimeline)
}
func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time, excludeFromTimeline bool) bool {
return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts, excludeFromTimeline)
}
func (portal *PortalInternals) GetBridgeInfoStateKey() string {
return (*Portal)(portal).getBridgeInfoStateKey()
func (portal *PortalInternals) UpdateAvatar(ctx context.Context, avatar *Avatar, sender MatrixAPI, ts time.Time) bool {
return (*Portal)(portal).updateAvatar(ctx, avatar, sender, ts)
}
func (portal *PortalInternals) GetBridgeInfo() (string, event.BridgeEventContent) {
@ -293,12 +249,8 @@ func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sen
return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts)
}
func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool, extra map[string]any) bool {
return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline, extra)
}
func (portal *PortalInternals) RevertRoomMeta(ctx context.Context, evt *event.Event) {
(*Portal)(portal).revertRoomMeta(ctx, evt)
func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any) bool {
return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content)
}
func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) {
@ -309,10 +261,6 @@ func (portal *PortalInternals) UpdateOtherUser(ctx context.Context, members *Cha
return (*Portal)(portal).updateOtherUser(ctx, members)
}
func (portal *PortalInternals) RoomIsPublic(ctx context.Context) bool {
return (*Portal)(portal).roomIsPublic(ctx)
}
func (portal *PortalInternals) SyncParticipants(ctx context.Context, members *ChatMemberList, source *UserLogin, sender MatrixAPI, ts time.Time) error {
return (*Portal)(portal).syncParticipants(ctx, members, source, sender, ts)
}
@ -333,10 +281,6 @@ func (portal *PortalInternals) CreateMatrixRoomInLoop(ctx context.Context, sourc
return (*Portal)(portal).createMatrixRoomInLoop(ctx, source, info, backfillBundle)
}
func (portal *PortalInternals) AddToUserSpaces(ctx context.Context) {
(*Portal)(portal).addToUserSpaces(ctx)
}
func (portal *PortalInternals) RemoveInPortalCache(ctx context.Context) {
(*Portal)(portal).removeInPortalCache(ctx)
}
@ -400,3 +344,7 @@ func (portal *PortalInternals) AddToParentSpaceAndSave(ctx context.Context, save
func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error {
return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove)
}
func (portal *PortalInternals) SetMXIDToExistingRoom(roomID id.RoomID) bool {
return (*Portal)(portal).setMXIDToExistingRoom(roomID)
}

View file

@ -32,40 +32,21 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
if source == target {
return ReIDResultError, nil, fmt.Errorf("illegal re-ID call: source and target are the same")
}
log := zerolog.Ctx(ctx).With().
Str("action", "re-id portal").
Stringer("source_portal_key", source).
Stringer("target_portal_key", target).
Logger()
ctx = log.WithContext(ctx)
log := zerolog.Ctx(ctx)
log.Debug().Msg("Re-ID'ing portal")
defer func() {
log.Debug().Msg("Finished handling portal re-ID")
}()
acquireCacheLock := func() {
if !br.cacheLock.TryLock() {
log.Debug().Msg("Waiting for global cache lock")
br.cacheLock.Lock()
log.Debug().Msg("Acquired global cache lock after waiting")
} else {
log.Trace().Msg("Acquired global cache lock without waiting")
}
}
log.Debug().Msg("Re-ID'ing portal")
sourcePortal, err := br.GetExistingPortalByKey(ctx, source)
br.cacheLock.Lock()
defer br.cacheLock.Unlock()
sourcePortal, err := br.UnlockedGetPortalByKey(ctx, source, true)
if err != nil {
return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err)
} else if sourcePortal == nil {
log.Debug().Msg("Source portal not found, re-ID is no-op")
return ReIDResultNoOp, nil, nil
}
if !sourcePortal.roomCreateLock.TryLock() {
if cancelCreate := sourcePortal.cancelRoomCreate.Swap(nil); cancelCreate != nil {
(*cancelCreate)()
}
log.Debug().Msg("Waiting for source portal room creation lock")
sourcePortal.roomCreateLock.Lock()
log.Debug().Msg("Acquired source portal room creation lock after waiting")
}
sourcePortal.roomCreateLock.Lock()
defer sourcePortal.roomCreateLock.Unlock()
if sourcePortal.MXID == "" {
log.Info().Msg("Source portal doesn't have Matrix room, deleting row")
@ -78,37 +59,22 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("source_portal_mxid", sourcePortal.MXID)
})
acquireCacheLock()
targetPortal, err := br.UnlockedGetPortalByKey(ctx, target, true)
if err != nil {
br.cacheLock.Unlock()
return ReIDResultError, nil, fmt.Errorf("failed to get target portal: %w", err)
}
if targetPortal == nil {
log.Info().Msg("Target portal doesn't exist, re-ID'ing source portal")
err = sourcePortal.unlockedReID(ctx, target)
br.cacheLock.Unlock()
if err != nil {
return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal: %w", err)
}
return ReIDResultSourceReIDd, sourcePortal, nil
}
br.cacheLock.Unlock()
if !targetPortal.roomCreateLock.TryLock() {
if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil {
(*cancelCreate)()
}
log.Debug().Msg("Waiting for target portal room creation lock")
targetPortal.roomCreateLock.Lock()
log.Debug().Msg("Acquired target portal room creation lock after waiting")
}
targetPortal.roomCreateLock.Lock()
defer targetPortal.roomCreateLock.Unlock()
if targetPortal.MXID == "" {
log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal")
acquireCacheLock()
defer br.cacheLock.Unlock()
err = targetPortal.unlockedDelete(ctx)
if err != nil {
return ReIDResultError, nil, fmt.Errorf("failed to delete target portal: %w", err)
@ -123,9 +89,6 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
return c.Stringer("target_portal_mxid", targetPortal.MXID)
})
log.Info().Msg("Both target and source portals have Matrix rooms, tombstoning source portal")
sourcePortal.removeInPortalCache(ctx)
acquireCacheLock()
defer br.cacheLock.Unlock()
err = sourcePortal.unlockedDelete(ctx)
if err != nil {
return ReIDResultError, nil, fmt.Errorf("failed to delete source portal row: %w", err)
@ -133,7 +96,7 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
go func() {
_, err := br.Bot.SendState(ctx, sourcePortal.MXID, event.StateTombstone, "", &event.Content{
Parsed: &event.TombstoneEventContent{
Body: "This room has been merged",
Body: fmt.Sprintf("This room has been merged"),
ReplacementRoom: targetPortal.MXID,
},
}, time.Now())

View file

@ -1,149 +0,0 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package provisionutil
import (
"context"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type RespCreateGroup struct {
ID networkid.PortalID `json:"id"`
MXID id.RoomID `json:"mxid"`
Portal *bridgev2.Portal `json:"-"`
FailedParticipants map[networkid.UserID]*bridgev2.CreateChatFailedParticipant `json:"failed_participants,omitempty"`
}
func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) {
api, ok := login.Client.(bridgev2.GroupCreatingNetworkAPI)
if !ok {
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups"))
}
zerolog.Ctx(ctx).Debug().
Any("create_params", params).
Msg("Creating group chat on remote network")
caps := login.Bridge.Network.GetCapabilities()
typeSpec, validType := caps.Provisioning.GroupCreation[params.Type]
if !validType {
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("Unrecognized group type %s", params.Type))
}
if len(params.Participants) < typeSpec.Participants.MinLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength))
} else if typeSpec.Participants.MaxLength > 0 && len(params.Participants) > typeSpec.Participants.MaxLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at most %d members", typeSpec.Participants.MaxLength))
}
userIDValidatingNetwork, uidValOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork)
for i, participant := range params.Participants {
parsedParticipant, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(participant))
if ok {
participant = parsedParticipant
params.Participants[i] = participant
}
if !typeSpec.Participants.SkipIdentifierValidation {
if uidValOK && !userIDValidatingNetwork.ValidateUserID(participant) {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("User ID %q is not valid on this network", participant))
}
}
if api.IsThisUser(ctx, participant) {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("You can't include yourself in the participants list", participant))
}
}
if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required"))
} else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength))
} else if typeSpec.Name.MaxLength > 0 && nameLen > typeSpec.Name.MaxLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength))
}
if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Avatar is required"))
}
if (params.Topic == nil || params.Topic.Topic == "") && typeSpec.Topic.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required"))
} else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength))
} else if typeSpec.Topic.MaxLength > 0 && topicLen > typeSpec.Topic.MaxLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength))
}
if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Disappearing timer is required"))
} else if !typeSpec.Disappear.DisappearSettings.Supports(params.Disappear) {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Unsupported value for disappearing timer"))
}
if params.Username == "" && typeSpec.Username.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required"))
} else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength))
} else if typeSpec.Username.MaxLength > 0 && len(params.Username) > typeSpec.Username.MaxLength {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength))
}
if params.Parent == nil && typeSpec.Parent.Required {
return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Parent is required"))
}
resp, err := api.CreateGroup(ctx, params)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create group")
return nil, err
}
if resp.PortalKey.IsEmpty() {
return nil, ErrNoPortalKey
}
zerolog.Ctx(ctx).Debug().
Object("portal_key", resp.PortalKey).
Msg("Successfully created group on remote network")
if resp.Portal == nil {
resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal")
return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal"))
}
}
if resp.Portal.MXID == "" {
err = resp.Portal.CreateMatrixRoom(ctx, login, resp.PortalInfo)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room")
return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room"))
}
}
for key, fp := range resp.FailedParticipants {
if fp.InviteEventType == "" {
fp.InviteEventType = event.EventMessage.Type
}
if fp.UserMXID == "" {
ghost, err := login.Bridge.GetGhostByID(ctx, key)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for failed participant")
} else if ghost != nil {
fp.UserMXID = ghost.Intent.GetMXID()
}
}
if fp.DMRoomMXID == "" {
portal, err := login.Bridge.GetDMPortal(ctx, login.ID, key)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get DM portal for failed participant")
} else if portal != nil {
fp.DMRoomMXID = portal.MXID
}
}
}
return &RespCreateGroup{
ID: resp.Portal.ID,
MXID: resp.Portal.MXID,
Portal: resp.Portal,
FailedParticipants: resp.FailedParticipants,
}, nil
}

Some files were not shown because too many files have changed in this diff Show more