Compare commits

..

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

432 changed files with 12074 additions and 49878 deletions

View file

@ -10,6 +10,3 @@ insert_final_newline = true
[*.{yaml,yml}]
indent_style = space
[provisioning.yaml]
indent_size = 2

View file

@ -2,20 +2,17 @@ name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
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.22"
cache: true
- name: Install libolm
@ -24,25 +21,27 @@ 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
uses: pre-commit/action@v3.0.1
- name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm)
go-version: ["1.21", "1.22"]
name: Build (${{ matrix.go-version == '1.22' && '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,29 +60,30 @@ 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.21", "1.22"]
name: Build (${{ matrix.go-version == '1.22' && '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
go build -tags=goolm -v ./...
run: go build -tags=goolm -v ./...
- name: Test
run: go test -tags=goolm -json -v ./... 2>&1 | gotestfmt

View file

@ -1,29 +0,0 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
.idea/
.vscode/
*.db*
*.db
*.log

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.4.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:
@ -17,13 +17,8 @@ repos:
- "maunium.net/go/mautrix"
- "-w"
- id: go-vet-repo-mod
- id: go-mod-tidy
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
rev: v0.3.1
hooks:
- id: prevent-literal-http-methods
- id: zerolog-ban-global-log
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View file

@ -1,602 +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
a single device.
* *(crypto)* Added helper for getting cached device list with cross-signing
status.
* *(crypto/verificationhelper)* Added interface for persisting the state of
in-progress verifications.
* *(client)* Added `GetMutualRooms` wrapper for [MSC2666].
* *(client)* Switched `JoinRoom` to use the `via` query param instead of
`server_name` as per [MSC4156].
* *(bridgev2/commands)* Fixed `pm` command not actually starting the chat.
* *(bridgev2/interface)* Added separate network API interface for starting
chats with a Matrix ghost user. This allows treating internal user IDs
differently than arbitrary user-input strings.
* *(bridgev2/crypto)* Added support for [MSC4190]
(thanks to [@onestacked] in [#288]).
[MSC2666]: https://github.com/matrix-org/matrix-spec-proposals/pull/2666
[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)
* *(hicli)* Moved package into gomuks repo.
* *(bridgev2/commands)* Fixed cookie unescaping in login commands.
* *(bridgev2/portal)* Added special `DefaultChatName` constant to explicitly
reset portal names to the default (based on members).
* *(bridgev2/config)* Added options to disable room tag bridging.
* *(bridgev2/database)* Fixed reaction queries not including portal receiver.
* *(appservice)* Updated [MSC2409] stable registration field name from
`push_ephemeral` to `receive_ephemeral`. Homeserver admins must update
existing registrations manually.
* *(format)* Added support for `img` tags.
* *(format/mdext)* Added goldmark extensions for Matrix math and custom emojis.
* *(event/reply)* Removed support for generating reply fallbacks ([MSC2781]).
* *(pushrules)* Added support for `sender_notification_permission` condition
kind (used for `@room` mentions).
* *(crypto)* Added support for `json.RawMessage` in `EncryptMegolmEvent`.
* *(mediaproxy)* Added `GetMediaResponseCallback` and `GetMediaResponseFile`
to write proxied data directly to http response or temp file instead of
having to use an `io.Reader`.
* *(mediaproxy)* Dropped support for legacy media download endpoints.
* *(mediaproxy,bridgev2)* Made interface pass through query parameters.
[MSC2781]: https://github.com/matrix-org/matrix-spec-proposals/pull/2781
## v0.21.1 (2024-10-16)
* *(bridgev2)* Added more features and fixed bugs.
* *(hicli)* Added more features and fixed bugs.
* *(appservice)* Removed TLS support. A reverse proxy should be used if TLS
is needed.
* *(format/mdext)* Added goldmark extension to fix indented paragraphs when
disabling indented code block parser.
* *(event)* Added `Has` method for `Mentions`.
* *(event)* Added basic support for the unstable version of polls.
## v0.21.0 (2024-09-16)
* **Breaking change *(client)*** Dropped support for unauthenticated media.
Matrix v1.11 support is now required from the homeserver, although it's not
enforced using `/versions` as some servers don't advertise it.
* *(bridgev2)* Added more features and fixed bugs.
* *(appservice,crypto)* Added support for using MSC3202 for appservice
encryption.
* *(crypto/olm)* Made everything into an interface to allow side-by-side
testing of libolm and goolm, as well as potentially support vodozemac
in the future.
* *(client)* Fixed requests being retried even after context is canceled.
* *(client)* Added option to move `/sync` request logs to trace level.
* *(error)* Added `Write` and `WithMessage` helpers to `RespError` to make it
easier to use on servers.
* *(event)* Fixed `org.matrix.msc1767.audio` field allowing omitting the
duration and waveform.
* *(id)* Changed `MatrixURI` methods to not panic if the receiver is nil.
* *(federation)* Added limit to response size when fetching `.well-known` files.
## v0.20.0 (2024-08-16)
* Bumped minimum Go version to 1.22.
* *(bridgev2)* Added more features and fixed bugs.
* *(event)* Added types for [MSC4144]: Per-message profiles.
* *(federation)* Added implementation of server name resolution and a basic
client for making federation requests.
* *(crypto/ssss)* Changed recovery key/passphrase verify functions to take the
key ID as a parameter to ensure it's correctly set even if the key metadata
wasn't fetched via `GetKeyData`.
* *(format/mdext)* Added goldmark extensions for single-character bold, italic
and strikethrough parsing (as in `*foo*` -> **foo**, `_foo_` -> _foo_ and
`~foo~` -> ~~foo~~)
* *(format)* Changed `RenderMarkdown` et al to always include `m.mentions` in
returned content. The mention list is filled with matrix.to URLs from the
input by default.
[MSC4144]: https://github.com/matrix-org/matrix-spec-proposals/pull/4144
## v0.19.0 (2024-07-16)
* Renamed `master` branch to `main`.
* *(bridgev2)* Added more features.
* *(crypto)* Fixed bug with copying `m.relates_to` from wire content to
decrypted content.
* *(mediaproxy)* Added module for implementing simple media repos that proxy
requests elsewhere.
* *(client)* Changed `Members()` to automatically parse event content for all
returned events.
* *(bridge)* Added `/register` call if `/versions` fails with `M_FORBIDDEN`.
* *(crypto)* Fixed `DecryptMegolmEvent` sometimes calling database without
transaction by using the non-context version of `ResolveTrust`.
* *(crypto/attachment)* Implemented `io.Seeker` in `EncryptStream` to allow
using it in retriable HTTP requests.
* *(event)* Added helper method to add user ID to a `Mentions` object.
* *(event)* Fixed default power level for invites
(thanks to [@rudis] in [#250]).
* *(client)* Fixed incorrect warning log in `State()` when state store returns
no error (thanks to [@rudis] in [#249]).
* *(crypto/verificationhelper)* Fixed deadlock when ignoring unknown
cancellation events (thanks to [@rudis] in [#247]).
[@rudis]: https://github.com/rudis
[#250]: https://github.com/mautrix/go/pull/250
[#249]: https://github.com/mautrix/go/pull/249
[#247]: https://github.com/mautrix/go/pull/247
### beta.1 (2024-06-16)
* *(bridgev2)* Added experimental high-level bridge framework.
* *(hicli)* Added experimental high-level client framework.
* **Slightly breaking changes**
* *(crypto)* Added room ID and first known index parameters to
`SessionReceived` callback.
* *(crypto)* Changed `ImportRoomKeyFromBackup` to return the imported
session.
* *(client)* Added `error` parameter to `ResponseHook`.
* *(client)* Changed `Download` to return entire response instead of just an
`io.Reader`.
* *(crypto)* Changed initial olm device sharing to save keys before sharing to
ensure keys aren't accidentally regenerated in case the request fails.
* *(crypto)* Changed `EncryptMegolmEvent` and `ShareGroupSession` to return
more errors instead of only logging and ignoring them.
* *(crypto)* Added option to completely disable megolm ratchet tracking.
* The tracking is meant for bots and bridges which may want to delete old
keys, but for normal clients it's just unnecessary overhead.
* *(crypto)* Changed Megolm session storage methods in `Store` to not take
sender key as parameter.
* This causes a breaking change to the layout of the `MemoryStore` struct.
Using MemoryStore in production is not recommended.
* *(crypto)* Changed `DecryptMegolmEvent` to copy `m.relates_to` in the raw
content too instead of only in the parsed struct.
* *(crypto)* Exported function to parse megolm message index from raw
ciphertext bytes.
* *(crypto/sqlstore)* Fixed schema of `crypto_secrets` table to include
account ID.
* *(crypto/verificationhelper)* Fixed more bugs.
* *(client)* Added `UpdateRequestOnRetry` hook which is called immediately
before retrying a normal HTTP request.
* *(client)* Added support for MSC3916 media download endpoint.
* Support is automatically detected from spec versions. The `SpecVersions`
property can either be filled manually, or `Versions` can be called to
automatically populate the field with the response.
* *(event)* Added constants for known room versions.
## v0.18.1 (2024-04-16)
* *(format)* Added a `context.Context` field to HTMLParser's Context struct.
* *(bridge)* Added support for handling join rules, knocks, invites and bans
(thanks to [@maltee1] in [#193] and [#204]).
* *(crypto)* Changed forwarded room key handling to only accept keys with a
lower first known index than the existing session if there is one.
* *(crypto)* Changed key backup restore to assume own device list is up to date
to avoid re-requesting device list for every deleted device that has signed
key backup.
* *(crypto)* Fixed memory cache not being invalidated when storing own
cross-signing keys
[@maltee1]: https://github.com/maltee1
[#193]: https://github.com/mautrix/go/pull/193
[#204]: https://github.com/mautrix/go/pull/204
## v0.18.0 (2024-03-16)
* **Breaking change *(client, bridge, appservice)*** Dropped support for

View file

@ -1,12 +1,11 @@
# 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)
Matrix room: [`#maunium:maunium.net`](https://matrix.to/#/#maunium:maunium.net)
This project is based on [matrix-org/gomatrix](https://github.com/matrix-org/gomatrix).
The original project is licensed under [Apache 2.0](https://github.com/matrix-org/gomatrix/blob/master/LICENSE).
@ -14,11 +13,7 @@ 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)
* 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
* Wrapper functions for the Synapse admin API
* End-to-end encryption support (incl. interactive SAS verification)
* Structs for parsing event content
* Helpers for parsing and generating Matrix HTML
* Helpers for handling push rules

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"
@ -31,7 +32,7 @@ import (
// EventChannelSize is the size for the Events channel in Appservice instances.
var EventChannelSize = 64
var OTKChannelSize = 64
var OTKChannelSize = 4
// Create creates a blank appservice instance.
func Create() *AppService {
@ -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,
@ -55,17 +56,15 @@ func Create() *AppService {
DeviceLists: make(chan *mautrix.DeviceLists, EventChannelSize),
QueryHandler: &QueryHandlerStub{},
SpecVersions: &mautrix.RespVersions{},
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 +112,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 +126,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,13 +158,12 @@ type AppService struct {
QueryHandler QueryHandler
StateStore StateStore
Router *http.ServeMux
UserAgent string
server *http.Server
HTTPClient *http.Client
botClient *mautrix.Client
botIntent *IntentAPI
SpecVersions *mautrix.RespVersions
Router *mux.Router
UserAgent string
server *http.Server
HTTPClient *http.Client
botClient *mautrix.Client
botIntent *IntentAPI
DefaultHTTPRetries int
@ -178,6 +176,7 @@ type AppService struct {
intentsLock sync.RWMutex
ws *websocket.Conn
wsWriteLock sync.Mutex
StopWebsocket func(error)
websocketHandlers map[string]WebsocketHandler
websocketHandlersLock sync.RWMutex
@ -194,7 +193,6 @@ type AppService struct {
}
const DoublePuppetKey = "fi.mau.double_puppet_source"
const DoublePuppetTSKey = "fi.mau.double_puppet_ts"
func getDefaultProcessID() string {
pid := syscall.Getpid()
@ -222,6 +220,9 @@ type HostConfig struct {
Hostname string `yaml:"hostname"`
// Port is required when Hostname is an IP address, optional for unix sockets
Port uint16 `yaml:"port"`
TLSKey string `yaml:"tls_key,omitempty"`
TLSCert string `yaml:"tls_cert,omitempty"`
}
// Address gets the whole address of the Appservice.
@ -334,7 +335,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,10 +361,9 @@ 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"
@ -60,8 +59,13 @@ func (as *AppService) listenUnix() error {
}
func (as *AppService) listenTCP() error {
as.Log.Info().Str("address", as.server.Addr).Msg("Starting HTTP listener")
return as.server.ListenAndServe()
if len(as.Host.TLSCert) == 0 || len(as.Host.TLSKey) == 0 {
as.Log.Info().Str("address", as.server.Addr).Msg("Starting HTTP listener")
return as.server.ListenAndServe()
} else {
as.Log.Info().Str("address", as.server.Addr).Msg("Starting HTTP listener with TLS")
return as.server.ListenAndServeTLS(as.Host.TLSCert, as.Host.TLSKey)
}
}
func (as *AppService) Stop() {
@ -79,9 +83,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 +106,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 +132,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 +141,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)
}
}
@ -186,22 +211,15 @@ func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, def
for _, evt := range evts {
evt.Mautrix.ReceivedAt = time.Now()
if defaultTypeClass != event.UnknownEventType {
if defaultTypeClass == event.EphemeralEventType {
evt.Mautrix.EventSource = event.SourceEphemeral
} else if defaultTypeClass == event.ToDeviceEventType {
evt.Mautrix.EventSource = event.SourceToDevice
}
evt.Type.Class = defaultTypeClass
} else if evt.StateKey != nil {
evt.Mautrix.EventSource = event.SourceTimeline & event.SourceJoin
evt.Type.Class = event.StateEventType
} else {
evt.Mautrix.EventSource = event.SourceTimeline
evt.Type.Class = event.MessageEventType
}
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 +256,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 +275,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 +294,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 +306,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 {
@ -119,21 +112,9 @@ func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, ext
if !errors.Is(err, mautrix.MForbidden) || bot == nil {
return fmt.Errorf("failed to ensure joined: %w", err)
}
var inviteErr error
if intent.IsCustomPuppet {
_, inviteErr = bot.SendStateEvent(ctx, roomID, event.StateMember, intent.UserID.String(), &event.Content{
Raw: map[string]any{
"fi.mau.will_auto_accept": true,
},
Parsed: &event.MemberEventContent{
Membership: event.MembershipInvite,
},
})
} else {
_, inviteErr = bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{
UserID: intent.UserID,
})
}
_, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{
UserID: intent.UserID,
})
if inviteErr != nil {
return fmt.Errorf("failed to invite in ensure joined: %w", inviteErr)
}
@ -149,110 +130,75 @@ 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() {
func (intent *IntentAPI) AddDoublePuppetValue(into interface{}) interface{} {
if !intent.IsCustomPuppet || intent.as.DoublePuppetValue == "" {
return into
}
// Only use ts deduplication feature with appservice double puppeting
if !intent.SetAppServiceUserID {
ts = 0
}
switch val := into.(type) {
case *map[string]any:
case *map[string]interface{}:
if *val == nil {
valNonPtr := make(map[string]any)
valNonPtr := make(map[string]interface{})
*val = valNonPtr
}
(*val)[DoublePuppetKey] = intent.as.DoublePuppetValue
if ts != 0 {
(*val)[DoublePuppetTSKey] = ts
}
return val
case map[string]any:
case map[string]interface{}:
val[DoublePuppetKey] = intent.as.DoublePuppetValue
if ts != 0 {
val[DoublePuppetTSKey] = ts
}
return val
case *event.Content:
if val.Raw == nil {
val.Raw = make(map[string]any)
val.Raw = make(map[string]interface{})
}
val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue
if ts != 0 {
val.Raw[DoublePuppetTSKey] = ts
}
return val
case event.Content:
if val.Raw == nil {
val.Raw = make(map[string]any)
val.Raw = make(map[string]interface{})
}
val.Raw[DoublePuppetKey] = intent.as.DoublePuppetValue
if ts != 0 {
val.Raw[DoublePuppetTSKey] = ts
}
return val
default:
content := &event.Content{
Raw: map[string]any{
return &event.Content{
Raw: map[string]interface{}{
DoublePuppetKey: intent.as.DoublePuppetValue,
},
Parsed: val,
}
if ts != 0 {
content.Raw[DoublePuppetTSKey] = ts
}
return content
}
}
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.AddDoublePuppetValue(contentJSON)
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 {
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON)
}
func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...)
}
// 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})
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 +257,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 +326,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 +335,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 +349,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
@ -477,20 +400,6 @@ func (intent *IntentAPI) SetRoomTopic(ctx context.Context, roomID id.RoomID, top
})
}
func (intent *IntentAPI) UploadMedia(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespMediaUpload, error) {
if err := intent.EnsureRegistered(ctx); err != nil {
return nil, err
}
return intent.Client.UploadMedia(ctx, data)
}
func (intent *IntentAPI) UploadAsync(ctx context.Context, data mautrix.ReqUploadMedia) (*mautrix.RespCreateMXC, error) {
if err := intent.EnsureRegistered(ctx); err != nil {
return nil, err
}
return intent.Client.UploadAsync(ctx, data)
}
func (intent *IntentAPI) SetDisplayName(ctx context.Context, displayName string) error {
if err := intent.EnsureRegistered(ctx); err != nil {
return err
@ -516,11 +425,11 @@ 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 {
_ = resp.Body.Close()
body, _ := intent.Client.Download(ctx, avatarURL)
if body != nil {
_ = body.Close()
}
}
return intent.Client.SetAvatarURL(ctx, avatarURL)

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

@ -27,9 +27,7 @@ type Registration struct {
Protocols []string `yaml:"protocols,omitempty" json:"protocols,omitempty"`
SoruEphemeralEvents bool `yaml:"de.sorunome.msc2409.push_ephemeral,omitempty" json:"de.sorunome.msc2409.push_ephemeral,omitempty"`
EphemeralEvents bool `yaml:"receive_ephemeral,omitempty" json:"receive_ephemeral,omitempty"`
MSC3202 bool `yaml:"org.matrix.msc3202,omitempty" json:"org.matrix.msc3202,omitempty"`
MSC4190 bool `yaml:"io.element.msc4190,omitempty" json:"io.element.msc4190,omitempty"`
EphemeralEvents bool `yaml:"push_ephemeral,omitempty" json:"push_ephemeral,omitempty"`
}
// CreateRegistration creates a Registration with random appservice and homeserver tokens.

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")
}

View file

@ -61,11 +61,6 @@ func (as *AppService) WebsocketHTTPProxy(cmd WebsocketCommand) (bool, interface{
if err != nil {
return false, fmt.Errorf("failed to create fake HTTP request: %w", err)
}
httpReq.RequestURI = req.Path
if req.Query != "" {
httpReq.RequestURI += "?" + req.Query
}
httpReq.RemoteAddr = "websocket"
httpReq.Header = req.Headers
var resp HTTPProxyResponse

897
bridge/bridge.go Normal file
View file

@ -0,0 +1,897 @@
// 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 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) ensureConnection(ctx context.Context) {
for {
versions, err := br.Bot.Versions(ctx)
if err != nil {
br.ZLog.Err(err).Msg("Failed to connect to homeserver, retrying in 10 seconds...")
time.Sleep(10 * time.Second)
} else {
br.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 {
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("/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("You can use --ignore-foreign-tables to ignore this error")
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
}
}

View file

@ -1,38 +1,36 @@
// Copyright (c) 2024 Tulir Asokan
// 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 matrix
package commands
import (
"strconv"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/id"
)
var CommandDiscardMegolmSession = &commands.FullHandler{
Func: func(ce *commands.Event) {
matrix := ce.Bridge.Matrix.(*Connector)
if matrix.Crypto == nil {
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 {
matrix.Crypto.ResetSession(ce.Ctx, ce.RoomID)
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: commands.HelpMeta{
Section: commands.HelpSectionAdmin,
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Discard the Megolm session in the room",
},
RequiresAdmin: true,
}
func fnSetPowerLevel(ce *commands.Event) {
func fnSetPowerLevel(ce *Event) {
var level int
var userID id.UserID
var err error
@ -42,7 +40,7 @@ func fnSetPowerLevel(ce *commands.Event) {
ce.Reply("Invalid power level \"%s\"", ce.Args[0])
return
}
userID = ce.User.MXID
userID = ce.User.GetMXID()
} else if len(ce.Args) == 2 {
userID = id.UserID(ce.Args[0])
_, _, err := userID.Parse()
@ -59,18 +57,18 @@ func fnSetPowerLevel(ce *commands.Event) {
ce.Reply("**Usage:** `set-pl [user] <level>`")
return
}
_, err = ce.Bot.(*ASIntent).Matrix.SetPowerLevel(ce.Ctx, ce.RoomID, userID, level)
_, 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 = &commands.FullHandler{
var CommandSetPowerLevel = &FullHandler{
Func: fnSetPowerLevel,
Name: "set-pl",
Aliases: []string{"set-power-level"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAdmin,
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Change the power level in a portal room.",
Args: "[_user ID_] <_power level_>",
},

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).Msgf("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).Msgf("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).Msgf("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).Msgf("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)
}
}

View file

@ -13,7 +13,7 @@ import (
)
type HelpfulHandler interface {
CommandHandler
Handler
GetHelp() HelpMeta
ShowInHelp(*Event) bool
}
@ -29,7 +29,6 @@ var (
HelpSectionGeneral = HelpSection{"General", 0}
HelpSectionAuth = HelpSection{"Authentication", 10}
HelpSectionChats = HelpSection{"Starting and managing chats", 20}
HelpSectionAdmin = HelpSection{"Administration", 50}
)
@ -102,14 +101,14 @@ func FormatHelp(ce *Event) string {
output.Grow(10240)
var prefixMsg string
if ce.RoomID == ce.User.ManagementRoom {
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.CommandPrefix)
_, _ = 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')
@ -128,14 +127,3 @@ func FormatHelp(ce *Event) string {
}
return output.String()
}
var CommandHelp = &FullHandler{
Func: func(ce *Event) {
ce.Reply(FormatHelp(ce))
},
Name: "help",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Show this help message.",
},
}

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)
}
}

View file

@ -6,7 +6,7 @@
//go:build cgo && !nocrypto
package matrix
package bridge
import (
"context"
@ -14,17 +14,14 @@ import (
"fmt"
"os"
"runtime/debug"
"strings"
"sync"
"time"
"github.com/lib/pq"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/event"
@ -32,18 +29,14 @@ import (
"maunium.net/go/mautrix/sqlstatestore"
)
func init() {
crypto.PostgresArrayWrapper = pq.Array
}
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
bridge *Bridge
client *mautrix.Client
mach *crypto.OlmMachine
store *SQLCryptoStore
@ -56,36 +49,35 @@ type CryptoHelper struct {
cancelPeriodicDeleteLoop func()
}
func NewCryptoHelper(c *Connector) Crypto {
if !c.Config.Encryption.Allow {
c.Log.Debug().Msg("Bridge built with end-to-bridge encryption, but disabled in config")
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 := c.Log.With().Str("component", "crypto").Logger()
log := bridge.ZLog.With().Str("component", "crypto").Logger()
return &CryptoHelper{
bridge: c,
bridge: bridge,
log: &log,
}
}
func (helper *CryptoHelper) Init(ctx context.Context) error {
if len(helper.bridge.Config.Encryption.PickleKey) == 0 {
if len(helper.bridge.CryptoPickleKey) == 0 {
panic("CryptoPickleKey not set")
}
helper.log.Debug().Msg("Initializing end-to-bridge encryption...")
helper.store = NewSQLCryptoStore(
helper.bridge.Bridge.DB.Database,
dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()),
string(helper.bridge.Bridge.ID),
helper.bridge.DB,
dbutil.ZeroLogger(helper.bridge.ZLog.With().Str("db_section", "crypto").Logger()),
helper.bridge.AS.BotMXID(),
fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain),
helper.bridge.Config.Encryption.PickleKey,
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 {
return bridgev2.DBUpgradeError{Section: "crypto", Err: err}
helper.bridge.LogDBUpgradeErrorAndExit("crypto", err)
}
var isExistingDevice bool
@ -97,11 +89,11 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
helper.log.Debug().
Str("device_id", helper.client.DeviceID.String()).
Msg("Logged in as bridge bot")
helper.mach = crypto.NewOlmMachine(helper.client, helper.log, helper.store, helper.bridge.StateStore)
helper.mach.DisableSharedGroupSessionTracking = true
stateStore := &cryptoStateStore{helper.bridge}
helper.mach = crypto.NewOlmMachine(helper.client, helper.log, helper.store, stateStore)
helper.mach.AllowKeyShare = helper.allowKeyShare
encryptionConfig := helper.bridge.Config.Encryption
encryptionConfig := helper.bridge.Config.Bridge.GetEncryptionConfig()
helper.mach.SendKeysMinTrust = encryptionConfig.VerificationLevels.Receive
helper.mach.PlaintextMentions = encryptionConfig.PlaintextMentions
@ -136,19 +128,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 +136,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()
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).Stringer("room_id", roomID).Msg("Failed to get encryption event")
_, err = helper.store.DB.Exec(ctx, `
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).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
@ -232,15 +176,15 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
Int("max_messages", maxMessages).
Interface("content", &evt).
Msg("Resynced encryption event")
_, err = helper.store.DB.Exec(ctx, `
_, 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).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")
}
}
}
@ -248,31 +192,22 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
}
func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
cfg := helper.bridge.Config.Encryption
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.ResolveTrustContext(ctx, 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")
return &crypto.KeyShareRejectNoResponse
} else if portal == nil {
} 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, err := helper.bridge.Bridge.GetExistingUserByMXID(ctx, device.UserID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get user to handle key request")
return &crypto.KeyShareRejectNoResponse
} else if user == nil {
zerolog.Ctx(ctx).Debug().Msg("Couldn't find user to handle key request")
return &crypto.KeyShareRejectNoResponse
} else if !user.Permissions.Admin {
zerolog.Ctx(ctx).Debug().Msg("Rejecting key request: user is not admin")
// TODO is in room check?
return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "Key sharing for non-admins is not yet implemented"}
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
@ -286,39 +221,27 @@ 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 {
helper.log.Debug().Msg("Creating bot device with MSC4190")
err = client.CreateDeviceMSC4190(ctx, deviceID, initialDeviceDisplayName)
if err != nil {
return nil, deviceID != "", fmt.Errorf("failed to create device for bridge bot: %w", err)
}
helper.store.DeviceID = client.DeviceID
return client, deviceID != "", nil
}
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: initialDeviceDisplayName,
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)
@ -327,7 +250,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,15 +263,14 @@ 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() {
if helper.bridge.Config.Encryption.Appservice {
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)
@ -439,7 +361,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 +476,36 @@ 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},
},
}
}
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)
}

View file

@ -6,7 +6,7 @@
//go:build cgo && !nocrypto
package matrix
package bridge
import (
"context"
@ -30,9 +30,9 @@ type SQLCryptoStore struct {
var _ crypto.Store = (*SQLCryptoStore)(nil)
func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, accountID string, userID id.UserID, ghostIDFormat, pickleKey string) *SQLCryptoStore {
func NewSQLCryptoStore(db *dbutil.Database, log dbutil.DatabaseLogger, userID id.UserID, ghostIDFormat, pickleKey string) *SQLCryptoStore {
return &SQLCryptoStore{
SQLCryptoStore: crypto.NewSQLCryptoStore(db, log, accountID, "", []byte(pickleKey)),
SQLCryptoStore: crypto.NewSQLCryptoStore(db, log, "", "", []byte(pickleKey)),
UserID: userID,
GhostIDFormat: ghostIDFormat,
}
@ -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

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
}

704
bridge/matrix.go Normal file
View file

@ -0,0 +1,704 @@
// 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)
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
}
mhp, ok := portal.(MembershipHandlingPortal)
if !ok {
return
}
if content.Membership == event.MembershipLeave {
if evt.Unsigned.PrevContent != nil {
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
prevContent, ok := evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent)
if ok && prevContent.Membership != "join" {
return
}
}
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)
}
}

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)
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2024 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
@ -6,17 +6,17 @@
//go:build !cgo || nocrypto
package matrix
package bridge
import (
"errors"
)
func NewCryptoHelper(c *Connector) Crypto {
if c.Config.Encryption.Allow {
c.Log.Warn().Msg("Bridge built without end-to-bridge encryption, but encryption is enabled in config")
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 {
c.Log.Debug().Msg("Bridge built without end-to-bridge encryption")
bridge.ZLog.Debug().Msg("Bridge built without end-to-bridge encryption")
}
return nil
}

View file

@ -12,17 +12,15 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"reflect"
"time"
"github.com/tidwall/sjson"
"go.mau.fi/util/jsontime"
"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,67 +52,6 @@ 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 {
if a != "" {
return a
}
return b
}
func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile {
other.Phone = coalesce(rp.Phone, other.Phone)
other.Email = coalesce(rp.Email, other.Email)
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)
}
type BridgeState struct {
StateEvent BridgeStateEvent `json:"state_event"`
Timestamp jsontime.Unix `json:"timestamp"`
@ -124,12 +61,9 @@ 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"`
Reason string `json:"reason,omitempty"`
Info map[string]interface{} `json:"info,omitempty"`
@ -141,15 +75,25 @@ type GlobalBridgeState struct {
}
type BridgeStateFiller interface {
GetMXID() id.UserID
GetRemoteID() string
GetRemoteName() string
}
type CustomBridgeStateFiller interface {
BridgeStateFiller
FillBridgeState(BridgeState) BridgeState
}
// Deprecated: use BridgeStateFiller instead
type StandaloneCustomBridgeStateFiller = BridgeStateFiller
func (pong BridgeState) Fill(user BridgeStateFiller) BridgeState {
if user != nil {
pong = user.FillBridgeState(pong)
pong.UserID = user.GetMXID()
pong.RemoteID = user.GetRemoteID()
pong.RemoteName = user.GetRemoteName()
if custom, ok := user.(CustomBridgeStateFiller); ok {
pong = custom.FillBridgeState(pong)
}
}
pong.Timestamp = jsontime.UnixNow()
@ -207,9 +151,6 @@ func (pong *BridgeState) SendHTTP(ctx context.Context, url, token string) error
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 &&
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,

View file

@ -1,19 +1,14 @@
// 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/.
package matrix
package bridge
import (
"context"
"errors"
"fmt"
"os"
"sync"
"time"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/appservice"
)
@ -21,21 +16,24 @@ const defaultReconnectBackoff = 2 * time.Second
const maxReconnectBackoff = 2 * time.Minute
const reconnectBackoffReset = 5 * time.Minute
func (br *Connector) startWebsocket(wg *sync.WaitGroup) {
log := br.Log.With().Str("action", "appservice websocket").Logger()
func (br *Bridge) startWebsocket(wg *sync.WaitGroup) {
log := br.ZLog.With().Str("action", "appservice websocket").Logger()
var wgOnce sync.Once
onConnect := func() {
if br.hasSentAnyStates {
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()
for _, state := range br.Bridge.GetCurrentBridgeStates() {
err := br.SendBridgeStatus(ctx, &state)
if err != nil {
log.Err(err).Msg("Failed to resend latest bridge state after websocket reconnect")
} else {
log.Debug().Any("bridge_state", state).Msg("Resent bridge state after websocket reconnect")
}
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")
}
}()
}
@ -57,21 +55,17 @@ 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 {
log.Warn().Msg("Appservice websocket closed by another instance of the bridge, shutting down...")
if br.OnWebsocketReplaced != nil {
br.OnWebsocketReplaced()
} else {
os.Exit(1)
}
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 {
if br.Stopping {
return
}
now := time.Now().UnixNano()
@ -92,7 +86,7 @@ func (br *Connector) startWebsocket(wg *sync.WaitGroup) {
log.Debug().Msg("Reconnect backoff was short-circuited")
case <-time.After(reconnectBackoff):
}
if br.stopping {
if br.Stopping {
return
}
}
@ -102,30 +96,30 @@ type wsPingData struct {
Timestamp int64 `json:"timestamp"`
}
func (br *Connector) PingServer() (start, serverTs, end time.Time) {
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.Log.Debug().Msg("Received server ping request, but no websocket connected. Trying to short-circuit backoff sleep")
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.Log.Warn().Msg("Failed to ping websocket: not connected and no backoff?")
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.Log.Warn().Msg("Failed to ping websocket: didn't connect after 15 seconds of waiting")
br.ZLog.Warn().Msg("Failed to ping websocket: didn't connect after 15 seconds of waiting")
return
}
}
}
start = time.Now()
var resp wsPingData
br.Log.Debug().Msg("Pinging appservice websocket")
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{
@ -134,11 +128,11 @@ func (br *Connector) PingServer() (start, serverTs, end time.Time) {
}, &resp)
end = time.Now()
if err != nil {
br.Log.Warn().Err(err).Dur("duration", end.Sub(start)).Msg("Websocket ping returned error")
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.Log.Debug().
br.ZLog.Debug().
Dur("duration", end.Sub(start)).
Dur("req_duration", serverTs.Sub(start)).
Dur("resp_duration", end.Sub(serverTs)).
@ -147,14 +141,14 @@ func (br *Connector) PingServer() (start, serverTs, end time.Time) {
return
}
func (br *Connector) websocketServerPinger() {
func (br *Bridge) websocketServerPinger() {
interval := time.Duration(br.Config.Homeserver.WSPingInterval) * time.Second
clock := time.NewTicker(interval)
defer func() {
br.Log.Info().Msg("Stopping websocket pinger")
br.ZLog.Info().Msg("Stopping websocket pinger")
clock.Stop()
}()
br.Log.Info().Dur("interval_duration", interval).Msg("Starting websocket pinger")
br.ZLog.Info().Dur("interval_duration", interval).Msg("Starting websocket pinger")
for {
select {
case <-clock.C:
@ -162,7 +156,7 @@ func (br *Connector) websocketServerPinger() {
case <-br.wsStopPinger:
return
}
if br.stopping {
if br.Stopping {
return
}
}

View file

@ -1,248 +0,0 @@
// 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/.
package bridgev2
import (
"context"
"fmt"
"runtime/debug"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2/database"
)
const BackfillMinBackoffAfterRoomCreate = 1 * time.Minute
const BackfillQueueErrorBackoff = 1 * time.Minute
const BackfillQueueMaxEmptyBackoff = 10 * time.Minute
func (br *Bridge) WakeupBackfillQueue() {
select {
case br.wakeupBackfillQueue <- struct{}{}:
default:
}
}
func (br *Bridge) RunBackfillQueue() {
if !br.Config.Backfill.Queue.Enabled || !br.Config.Backfill.Enabled {
return
}
log := br.Log.With().Str("component", "backfill queue").Logger()
if !br.Matrix.GetCapabilities().BatchSending {
log.Warn().Msg("Backfill queue is enabled in config, but Matrix server doesn't support batch sending")
return
}
ctx, cancel := context.WithCancel(log.WithContext(context.Background()))
br.stopBackfillQueue.Clear()
stopChan := br.stopBackfillQueue.GetChan()
go func() {
<-stopChan
cancel()
}()
batchDelay := time.Duration(br.Config.Backfill.Queue.BatchDelay) * time.Second
log.Info().Stringer("batch_delay", batchDelay).Msg("Backfill queue starting")
noTasksFoundCount := 0
for {
nextDelay := batchDelay
if noTasksFoundCount > 0 {
extraDelay := batchDelay * time.Duration(noTasksFoundCount)
nextDelay += min(BackfillQueueMaxEmptyBackoff, extraDelay)
}
timer := time.NewTimer(nextDelay)
select {
case <-br.wakeupBackfillQueue:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
noTasksFoundCount = 0
case <-stopChan:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
log.Info().Msg("Stopping backfill queue")
return
case <-timer.C:
}
backfillTask, err := br.DB.BackfillTask.GetNext(ctx)
if err != nil {
log.Err(err).Msg("Failed to get next backfill queue entry")
time.Sleep(BackfillQueueErrorBackoff)
continue
} else if backfillTask != nil {
br.DoBackfillTask(ctx, backfillTask)
noTasksFoundCount = 0
}
}
}
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)).
Logger()
defer func() {
err := recover()
if err != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack())
if realErr, ok := err.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
}
logEvt.Msg("Panic in backfill queue")
}
}()
ctx = log.WithContext(ctx)
err := br.DB.BackfillTask.MarkDispatched(ctx, task)
if err != nil {
log.Err(err).Msg("Failed to mark backfill task as dispatched")
time.Sleep(BackfillQueueErrorBackoff)
return
}
completed, err := br.actuallyDoBackfillTask(ctx, task)
if err != nil {
log.Err(err).Msg("Failed to do backfill task")
time.Sleep(BackfillQueueErrorBackoff)
return
} else if completed {
log.Info().
Int("batch_count", task.BatchCount).
Bool("is_done", task.IsDone).
Msg("Backfill task completed successfully")
} else {
log.Info().
Int("batch_count", task.BatchCount).
Bool("is_done", task.IsDone).
Msg("Backfill task canceled")
}
err = br.DB.BackfillTask.Update(ctx, task)
if err != nil {
log.Err(err).Msg("Failed to update backfill task")
time.Sleep(BackfillQueueErrorBackoff)
}
}
func (portal *Portal) deleteBackfillQueueTaskIfRoomDoesNotExist(ctx context.Context) bool {
// Acquire the room create lock to ensure that task deletion doesn't race with room creation
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
if portal.MXID == "" {
zerolog.Ctx(ctx).Debug().Msg("Portal for backfill task doesn't exist, deleting entry")
err := portal.Bridge.DB.BackfillTask.Delete(ctx, portal.PortalKey)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete backfill task after portal wasn't found")
}
return true
}
return false
}
func (br *Bridge) actuallyDoBackfillTask(ctx context.Context, task *database.BackfillTask) (bool, error) {
log := zerolog.Ctx(ctx)
portal, err := br.GetExistingPortalByKey(ctx, task.PortalKey)
if err != nil {
return false, fmt.Errorf("failed to get portal for backfill task: %w", err)
} else if portal == nil {
log.Warn().Msg("Portal not found for backfill task")
err = br.DB.BackfillTask.Delete(ctx, task.PortalKey)
if err != nil {
log.Err(err).Msg("Failed to delete backfill task after portal wasn't found")
time.Sleep(BackfillQueueErrorBackoff)
}
return false, nil
} else if portal.MXID == "" {
portal.deleteBackfillQueueTaskIfRoomDoesNotExist(ctx)
return false, nil
}
login, err := br.GetExistingUserLoginByID(ctx, task.UserLoginID)
if err != nil {
return false, fmt.Errorf("failed to get user login for backfill task: %w", err)
} else if login == nil || !login.Client.IsLoggedIn() {
if login == nil {
log.Warn().Msg("User login not found for backfill task")
} else {
log.Warn().Msg("User login not logged in for backfill task")
}
logins, err := br.GetUserLoginsInPortal(ctx, portal.PortalKey)
if err != nil {
return false, fmt.Errorf("failed to get user portals for backfill task: %w", err)
} else if len(logins) == 0 {
log.Debug().Msg("No user logins found for backfill task")
task.NextDispatchMinTS = database.BackfillNextDispatchNever
if login == nil {
task.UserLoginID = ""
}
return false, nil
}
if login == nil {
task.UserLoginID = ""
}
foundLogin := false
for _, login = range logins {
if login.Client.IsLoggedIn() {
foundLogin = true
task.UserLoginID = login.ID
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Str("overridden_login_id", string(login.ID))
})
log.Debug().Msg("Found user login for backfill task")
break
}
}
if !foundLogin {
log.Debug().Msg("No logged in user logins found for backfill task")
task.NextDispatchMinTS = database.BackfillNextDispatchNever
return false, nil
}
}
if task.BatchCount < 0 {
var msgCount int
msgCount, err = br.DB.Message.CountMessagesInPortal(ctx, task.PortalKey)
if err != nil {
return false, fmt.Errorf("failed to count messages in portal: %w", err)
}
task.BatchCount = msgCount / br.Config.Backfill.Queue.BatchSize
log.Debug().
Int("message_count", msgCount).
Int("batch_count", task.BatchCount).
Msg("Calculated existing batch count")
}
maxBatches := br.Config.Backfill.Queue.MaxBatches
api, ok := login.Client.(BackfillingNetworkAPI)
if !ok {
return false, fmt.Errorf("network API does not support backfilling")
}
limiterAPI, ok := api.(BackfillingNetworkAPIWithLimits)
if ok {
maxBatches = limiterAPI.GetBackfillMaxBatchCount(ctx, portal, task)
}
if maxBatches < 0 || maxBatches > task.BatchCount {
err = portal.DoBackwardsBackfill(ctx, login, task)
if err != nil {
return false, fmt.Errorf("failed to backfill: %w", err)
}
task.BatchCount++
} else {
log.Debug().
Int("max_batches", maxBatches).
Int("batch_count", task.BatchCount).
Msg("Not actually backfilling: max batches reached")
}
task.IsDone = task.IsDone || (maxBatches > 0 && task.BatchCount >= maxBatches)
batchDelay := time.Duration(br.Config.Backfill.Queue.BatchDelay) * time.Second
task.CompletedAt = time.Now()
task.NextDispatchMinTS = task.CompletedAt.Add(batchDelay)
return true, nil
}

View file

@ -1,458 +0,0 @@
// 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/.
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/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"
)
type CommandProcessor interface {
Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *User, message string, replyTo id.EventID)
}
type Bridge struct {
ID networkid.BridgeID
DB *database.Database
Log zerolog.Logger
Matrix MatrixConnector
Bot MatrixAPI
Network NetworkConnector
Commands CommandProcessor
Config *bridgeconfig.BridgeConfig
DisappearLoop *DisappearLoop
usersByMXID map[id.UserID]*User
userLoginsByID map[networkid.UserLoginID]*UserLogin
portalsByKey map[networkid.PortalKey]*Portal
portalsByMXID map[id.RoomID]*Portal
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
}
func NewBridge(
bridgeID networkid.BridgeID,
db *dbutil.Database,
log zerolog.Logger,
cfg *bridgeconfig.BridgeConfig,
matrix MatrixConnector,
network NetworkConnector,
newCommandProcessor func(*Bridge) CommandProcessor,
) *Bridge {
br := &Bridge{
ID: bridgeID,
DB: database.New(bridgeID, network.GetDBMetaTypes(), db),
Log: log,
Matrix: matrix,
Network: network,
Config: cfg,
usersByMXID: make(map[id.UserID]*User),
userLoginsByID: make(map[networkid.UserLoginID]*UserLogin),
portalsByKey: make(map[networkid.PortalKey]*Portal),
portalsByMXID: make(map[id.RoomID]*Portal),
ghostsByID: make(map[networkid.UserID]*Ghost),
wakeupBackfillQueue: make(chan struct{}),
stopBackfillQueue: exsync.NewEvent(),
}
if br.Config == nil {
br.Config = &bridgeconfig.BridgeConfig{CommandPrefix: "!bridge"}
}
br.Commands = newCommandProcessor(br)
br.Matrix.Init(br)
br.Bot = br.Matrix.BotIntent()
br.Network.Init(br)
br.DisappearLoop = &DisappearLoop{br: br}
return br
}
type DBUpgradeError struct {
Err error
Section string
}
func (e DBUpgradeError) Error() string {
return e.Err.Error()
}
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)
if err != nil {
return err
}
err = br.StartLogins(ctx)
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 {
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)
}
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()
}
}
br.Log.Info().Msg("Starting Matrix connector")
err := br.Matrix.Start(ctx)
if err != nil {
return fmt.Errorf("failed to start Matrix connector: %w", err)
}
br.Log.Info().Msg("Starting network connector")
err = br.Network.Start(ctx)
if err != nil {
return fmt.Errorf("failed to start network connector: %w", err)
}
if br.Network.GetCapabilities().DisappearingMessages && !br.Background {
go br.DisappearLoop.Start()
}
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) {
log := zerolog.Ctx(ctx).With().Str("action", "resend bridge info").Logger()
portals, err := br.GetAllPortalsWithMXID(ctx)
if err != nil {
log.Err(err).Msg("Failed to get portals")
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")
}
}
}
}
log.Info().
Bool("capabilities", resendCaps).
Bool("info", resendInfo).
Msg("Resent bridge info to all portals")
}
func (br *Bridge) MigrateToSplitPortals(ctx context.Context) (bool, func()) {
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
}
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.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")
}
}
func (br *Bridge) StartLogins(ctx context.Context) error {
userIDs, err := br.DB.UserLogin.GetAllUserIDsWithLogins(ctx)
if err != nil {
return fmt.Errorf("failed to get users with logins: %w", err)
}
startedAny := false
for _, userID := range userIDs {
br.Log.Info().Stringer("user_id", userID).Msg("Loading user")
var user *User
user, err = br.GetUserByMXID(ctx, userID)
if err != nil {
br.Log.Err(err).Stringer("user_id", userID).Msg("Failed to load user")
} else {
for _, login := range user.GetUserLogins() {
startedAny = true
br.Log.Info().Str("id", string(login.ID)).Msg("Starting user login")
login.Client.Connect(login.Log.WithContext(ctx))
}
}
}
if !startedAny {
br.Log.Info().Msg("No user logins found")
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured})
}
if !br.Background {
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()
}
br.Matrix.Stop()
if br.cancelBackgroundCtx != nil {
br.cancelBackgroundCtx()
}
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")
}
}
br.Log.Info().Msg("Shutdown complete")
}

View file

@ -1,140 +0,0 @@
// 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/.
package bridgeconfig
import (
"fmt"
"regexp"
"strings"
"text/template"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/random"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
type AppserviceConfig struct {
Address string `yaml:"address"`
PublicAddress string `yaml:"public_address"`
Hostname string `yaml:"hostname"`
Port uint16 `yaml:"port"`
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"`
UsernameTemplate string `yaml:"username_template"`
usernameTemplate *template.Template `yaml:"-"`
}
func (asc *AppserviceConfig) FormatUsername(username string) string {
if asc.usernameTemplate == nil {
asc.usernameTemplate = exerrors.Must(template.New("username").Parse(asc.UsernameTemplate))
}
var buf strings.Builder
_ = asc.usernameTemplate.Execute(&buf, username)
return buf.String()
}
func (config *Config) MakeUserIDRegex(matcher string) *regexp.Regexp {
usernamePlaceholder := strings.ToLower(random.String(16))
usernameTemplate := fmt.Sprintf("@%s:%s",
config.AppService.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)
}
// 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
}
func (ec *EncryptionConfig) applyUnstableFlags(registration *appservice.Registration) {
registration.MSC4190 = ec.MSC4190
registration.MSC3202 = ec.Appservice
}
// GenerateRegistration generates a registration file for the homeserver.
func (config *Config) GenerateRegistration() *appservice.Registration {
registration := appservice.CreateRegistration()
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$",
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 *Config) 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()
config.Encryption.applyUnstableFlags(as.Registration)
return as
}
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(node *yaml.Node) error {
var sbuc serializableBUC
err := node.Decode(&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
}

View file

@ -1,45 +0,0 @@
// 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/.
package bridgeconfig
type BackfillConfig struct {
Enabled bool `yaml:"enabled"`
MaxInitialMessages int `yaml:"max_initial_messages"`
MaxCatchupMessages int `yaml:"max_catchup_messages"`
UnreadHoursThreshold int `yaml:"unread_hours_threshold"`
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 {
MaxInitialMessages int `yaml:"max_initial_messages"`
}
type BackfillQueueConfig struct {
Enabled bool `yaml:"enabled"`
BatchSize int `yaml:"batch_size"`
BatchDelay int `yaml:"batch_delay"`
MaxBatches int `yaml:"max_batches"`
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
}
}
return bqc.MaxBatches
}

View file

@ -1,139 +0,0 @@
// 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/.
package bridgeconfig
import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/zeroconfig"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/mediaproxy"
)
type Config struct {
Network yaml.Node `yaml:"network"`
Bridge BridgeConfig `yaml:"bridge"`
Database dbutil.Config `yaml:"database"`
Homeserver HomeserverConfig `yaml:"homeserver"`
AppService AppserviceConfig `yaml:"appservice"`
Matrix MatrixConfig `yaml:"matrix"`
Analytics AnalyticsConfig `yaml:"analytics"`
Provisioning ProvisioningConfig `yaml:"provisioning"`
PublicMedia PublicMediaConfig `yaml:"public_media"`
DirectMedia DirectMediaConfig `yaml:"direct_media"`
Backfill BackfillConfig `yaml:"backfill"`
DoublePuppet DoublePuppetConfig `yaml:"double_puppet"`
Encryption EncryptionConfig `yaml:"encryption"`
Logging zeroconfig.Config `yaml:"logging"`
EnvConfigPrefix string `yaml:"env_config_prefix"`
ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"`
}
type CleanupAction string
const (
CleanupActionNull CleanupAction = ""
CleanupActionNothing CleanupAction = "nothing"
CleanupActionKick CleanupAction = "kick"
CleanupActionUnbridge CleanupAction = "unbridge"
CleanupActionDelete CleanupAction = "delete"
)
type CleanupOnLogout struct {
Private CleanupAction `yaml:"private"`
Relayed CleanupAction `yaml:"relayed"`
SharedNoUsers CleanupAction `yaml:"shared_no_users"`
SharedHasUsers CleanupAction `yaml:"shared_has_users"`
}
type CleanupOnLogouts struct {
Enabled bool `yaml:"enabled"`
Manual CleanupOnLogout `yaml:"manual"`
BadCredentials CleanupOnLogout `yaml:"bad_credentials"`
}
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"`
}
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"`
}
type AnalyticsConfig struct {
Token string `yaml:"token"`
URL string `yaml:"url"`
UserID string `yaml:"user_id"`
}
type ProvisioningConfig struct {
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
EnableSessionTransfers bool `yaml:"enable_session_transfers"`
}
type DirectMediaConfig struct {
Enabled bool `yaml:"enabled"`
MediaIDPrefix string `yaml:"media_id_prefix"`
mediaproxy.BasicConfig `yaml:",inline"`
}
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"`
}
type DoublePuppetConfig struct {
Servers map[string]string `yaml:"servers"`
AllowDiscovery bool `yaml:"allow_discovery"`
Secrets map[string]string `yaml:"secrets"`
}
type ManagementRoomTexts struct {
Welcome string `yaml:"welcome"`
WelcomeConnected string `yaml:"welcome_connected"`
WelcomeUnconnected string `yaml:"welcome_unconnected"`
AdditionalHelp string `yaml:"additional_help"`
}

View file

@ -1,51 +0,0 @@
// 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/.
package bridgeconfig
import (
"maunium.net/go/mautrix/id"
)
type EncryptionConfig struct {
Allow bool `yaml:"allow"`
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"`
PickleKey string `yaml:"pickle_key"`
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"`
}

View file

@ -1,38 +0,0 @@
// 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/.
package bridgeconfig
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"`
}

View file

@ -1,174 +0,0 @@
// 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/.
package bridgeconfig
import (
"fmt"
"net/url"
"os"
"strings"
up "go.mau.fi/util/configupgrade"
)
var HackyMigrateLegacyNetworkConfig func(up.Helper)
func CopyToOtherLocation(helper up.Helper, fieldType up.YAMLType, source, dest []string) {
val, ok := helper.Get(fieldType, source...)
if ok {
helper.Set(fieldType, val, dest...)
}
}
func CopyMapToOtherLocation(helper up.Helper, source, dest []string) {
val := helper.GetNode(source...)
if val != nil && val.Map != nil {
helper.SetMap(val.Map, dest...)
}
}
func doMigrateLegacy(helper up.Helper, python bool) {
if HackyMigrateLegacyNetworkConfig == nil {
_, _ = fmt.Fprintln(os.Stderr, "Legacy bridge config detected, but hacky network config migrator is not set")
os.Exit(1)
}
_, _ = fmt.Fprintln(os.Stderr, "Migrating legacy bridge config")
helper.Copy(up.Str, "homeserver", "address")
helper.Copy(up.Str, "homeserver", "domain")
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")
helper.Copy(up.Str, "appservice", "id")
if python {
CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_username"}, []string{"appservice", "bot", "username"})
CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_displayname"}, []string{"appservice", "bot", "displayname"})
CopyToOtherLocation(helper, up.Str, []string{"appservice", "bot_avatar"}, []string{"appservice", "bot", "avatar"})
} else {
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")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
if oldPM, ok := helper.Get(up.Str, "bridge", "private_chat_portal_meta"); ok && (oldPM == "default" || oldPM == "always") {
helper.Set(up.Bool, "true", "bridge", "private_chat_portal_meta")
} else {
helper.Set(up.Bool, "false", "bridge", "private_chat_portal_meta")
}
helper.Copy(up.Bool, "bridge", "relay", "enabled")
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
helper.Copy(up.Map, "bridge", "permissions")
if python {
legacyDB, ok := helper.Get(up.Str, "appservice", "database")
if ok {
if strings.HasPrefix(legacyDB, "postgres") {
parsedDB, err := url.Parse(legacyDB)
if err != nil {
panic(err)
}
q := parsedDB.Query()
if parsedDB.Host == "" && !q.Has("host") {
q.Set("host", "/var/run/postgresql")
} else if !q.Has("sslmode") {
q.Set("sslmode", "disable")
}
parsedDB.RawQuery = q.Encode()
helper.Set(up.Str, parsedDB.String(), "database", "uri")
helper.Set(up.Str, "postgres", "database", "type")
} else {
dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///")
helper.Set(up.Str, fmt.Sprintf("file:%s?_txlock=immediate", dbPath), "database", "uri")
helper.Set(up.Str, "sqlite3-fk-wal", "database", "type")
}
}
if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok {
helper.Set(up.Int, legacyDBMinSize, "database", "max_idle_conns")
}
if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok {
helper.Set(up.Int, legacyDBMaxSize, "database", "max_open_conns")
}
} else {
if dbType, ok := helper.Get(up.Str, "appservice", "database", "type"); ok && dbType == "sqlite3" {
helper.Set(up.Str, "sqlite3-fk-wal", "database", "type")
} else {
CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "type"}, []string{"database", "type"})
}
CopyToOtherLocation(helper, up.Str, []string{"appservice", "database", "uri"}, []string{"database", "uri"})
CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_open_conns"}, []string{"database", "max_open_conns"})
CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_idle_conns"}, []string{"database", "max_idle_conns"})
CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_idle_time"}, []string{"database", "max_conn_idle_time"})
CopyToOtherLocation(helper, up.Int, []string{"appservice", "database", "max_conn_lifetime"}, []string{"database", "max_conn_lifetime"})
}
if python {
if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") {
helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "appservice", "username_template")
}
} else {
CopyToOtherLocation(helper, up.Str, []string{"bridge", "username_template"}, []string{"appservice", "username_template"})
}
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_status_events"}, []string{"matrix", "message_status_events"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "delivery_receipts"}, []string{"matrix", "delivery_receipts"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "message_error_notices"}, []string{"matrix", "message_error_notices"})
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", "shared_secret"}, []string{"provisioning", "shared_secret"})
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"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "double_puppet_allow_discovery"}, []string{"double_puppet", "allow_discovery"})
CopyMapToOtherLocation(helper, []string{"bridge", "double_puppet_server_map"}, []string{"double_puppet", "servers"})
CopyMapToOtherLocation(helper, []string{"bridge", "login_shared_secret_map"}, []string{"double_puppet", "secrets"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow"}, []string{"encryption", "allow"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "default"}, []string{"encryption", "default"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "require"}, []string{"encryption", "require"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "appservice"}, []string{"encryption", "appservice"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "allow_key_sharing"}, []string{"encryption", "allow_key_sharing"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outbound_on_ack"}, []string{"encryption", "delete_keys", "delete_outbound_on_ack"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "dont_store_outbound"}, []string{"encryption", "delete_keys", "dont_store_outbound"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "ratchet_on_decrypt"}, []string{"encryption", "delete_keys", "ratchet_on_decrypt"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt"}, []string{"encryption", "delete_keys", "delete_fully_used_on_decrypt"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_prev_on_new_session"}, []string{"encryption", "delete_keys", "delete_prev_on_new_session"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_on_device_delete"}, []string{"encryption", "delete_keys", "delete_on_device_delete"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "periodically_delete_expired"}, []string{"encryption", "delete_keys", "periodically_delete_expired"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "delete_keys", "delete_outdated_inbound"}, []string{"encryption", "delete_keys", "delete_outdated_inbound"})
CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "receive"}, []string{"encryption", "verification_levels", "receive"})
CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "send"}, []string{"encryption", "verification_levels", "send"})
CopyToOtherLocation(helper, up.Str, []string{"bridge", "encryption", "verification_levels", "share"}, []string{"encryption", "verification_levels", "share"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "enable_custom"}, []string{"encryption", "rotation", "enable_custom"})
CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "milliseconds"}, []string{"encryption", "rotation", "milliseconds"})
CopyToOtherLocation(helper, up.Int, []string{"bridge", "encryption", "rotation", "messages"}, []string{"encryption", "rotation", "messages"})
CopyToOtherLocation(helper, up.Bool, []string{"bridge", "encryption", "rotation", "disable_device_change_key_rotation"}, []string{"encryption", "rotation", "disable_device_change_key_rotation"})
if helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "print_level") != nil || helper.GetNode("logging", "file_name_format") != nil) {
_, _ = fmt.Fprintln(os.Stderr, "Migrating maulogger configs is not supported")
} else if (helper.GetNode("logging", "writers") == nil && (helper.GetNode("logging", "handlers") != nil)) || python {
_, _ = fmt.Fprintln(os.Stderr, "Migrating Python log configs is not supported")
} else {
helper.Copy(up.Map, "logging")
}
HackyMigrateLegacyNetworkConfig(helper)
}

View file

@ -1,124 +0,0 @@
// 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"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/id"
)
type Permissions struct {
SendEvents bool `yaml:"send_events"`
Commands bool `yaml:"commands"`
Login bool `yaml:"login"`
DoublePuppet bool `yaml:"double_puppet"`
Admin bool `yaml:"admin"`
ManageRelay bool `yaml:"manage_relay"`
MaxLogins int `yaml:"max_logins"`
}
type PermissionConfig map[string]*Permissions
func boolToInt(val bool) int {
if val {
return 1
}
return 0
}
func (pc PermissionConfig) IsConfigured() bool {
_, hasWildcard := pc["*"]
_, hasExampleDomain := pc["example.com"]
_, hasExampleUser := pc["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
return len(pc) > exampleLen
}
func (pc PermissionConfig) Get(userID id.UserID) Permissions {
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
}
}
var (
PermissionLevelBlock = Permissions{}
PermissionLevelRelay = Permissions{SendEvents: true}
PermissionLevelCommands = Permissions{SendEvents: true, Commands: true, ManageRelay: true}
PermissionLevelUser = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true}
PermissionLevelAdmin = Permissions{SendEvents: true, Commands: true, ManageRelay: true, Login: true, DoublePuppet: true, Admin: true}
)
var namesToLevels = map[string]Permissions{
"block": PermissionLevelBlock,
"relay": PermissionLevelRelay,
"commands": PermissionLevelCommands,
"user": PermissionLevelUser,
"admin": PermissionLevelAdmin,
}
var levelsToNames = map[Permissions]string{
PermissionLevelBlock: "block",
PermissionLevelRelay: "relay",
PermissionLevelCommands: "commands",
PermissionLevelUser: "user",
PermissionLevelAdmin: "admin",
}
type umPerm Permissions
func (p *Permissions) UnmarshalYAML(perm *yaml.Node) error {
switch perm.Tag {
case "!!str":
var ok bool
*p, ok = namesToLevels[strings.ToLower(perm.Value)]
if !ok {
return fmt.Errorf("invalid permissions level %s", perm.Value)
}
return nil
case "!!map":
err := perm.Decode((*umPerm)(p))
return err
case "!!int":
val, err := strconv.Atoi(perm.Value)
if err != nil {
return fmt.Errorf("invalid permissions level %s", perm.Value)
}
_, _ = fmt.Fprintln(os.Stderr, "Warning: config contains deprecated integer permission values")
// Integer values are deprecated, so they're hardcoded
if val < 5 {
*p = PermissionLevelBlock
} else if val < 10 {
*p = PermissionLevelRelay
} else if val < 100 {
*p = PermissionLevelUser
} else {
*p = PermissionLevelAdmin
}
return nil
default:
return fmt.Errorf("invalid permissions type %s", perm.Tag)
}
}
func (p *Permissions) MarshalYAML() (any, error) {
if level, ok := levelsToNames[*p]; ok {
return level, nil
}
return umPerm(*p), nil
}

View file

@ -1,109 +0,0 @@
// 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/.
package bridgeconfig
import (
"fmt"
"strings"
"text/template"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
)
type RelayConfig struct {
Enabled bool `yaml:"enabled"`
AdminOnly bool `yaml:"admin_only"`
DefaultRelays []networkid.UserLoginID `yaml:"default_relays"`
MessageFormats map[event.MessageType]string `yaml:"message_formats"`
DisplaynameFormat string `yaml:"displayname_format"`
messageTemplates *template.Template `yaml:"-"`
nameTemplate *template.Template `yaml:"-"`
}
type umRelayConfig RelayConfig
func (rc *RelayConfig) UnmarshalYAML(node *yaml.Node) error {
err := node.Decode((*umRelayConfig)(rc))
if err != nil {
return err
}
rc.messageTemplates = template.New("messageTemplates")
for key, template := range rc.MessageFormats {
_, err = rc.messageTemplates.New(string(key)).Parse(template)
if err != nil {
return err
}
}
rc.nameTemplate, err = template.New("nameTemplate").Parse(rc.DisplaynameFormat)
if err != nil {
return err
}
return nil
}
type formatData struct {
Sender any
Content *event.MessageEventContent
Caption string
Message string
FileName string
}
func isMedia(msgType event.MessageType) bool {
switch msgType {
case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
return true
default:
return false
}
}
func (rc *RelayConfig) FormatMessage(content *event.MessageEventContent, sender any) (*event.MessageEventContent, error) {
_, isSupported := rc.MessageFormats[content.MsgType]
if !isSupported {
return nil, fmt.Errorf("unsupported msgtype for relaying")
}
contentCopy := *content
content = &contentCopy
content.EnsureHasHTML()
fd := &formatData{
Sender: sender,
Content: content,
Message: content.FormattedBody,
}
fd.Message = content.FormattedBody
if content.FileName != "" {
fd.FileName = content.FileName
if content.FileName != content.Body {
fd.Caption = fd.Message
}
} else if isMedia(content.MsgType) {
content.FileName = content.Body
fd.FileName = content.Body
}
var output strings.Builder
err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), fd)
if err != nil {
return nil, err
}
content.FormattedBody = output.String()
content.Body = format.HTMLToText(content.FormattedBody)
return content, nil
}
func (rc *RelayConfig) FormatName(sender any) string {
var buf strings.Builder
_ = rc.nameTemplate.Execute(&buf, sender)
return strings.TrimSpace(buf.String())
}

View file

@ -1,227 +0,0 @@
// 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/.
package bridgeconfig
import (
"fmt"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/federation"
)
func doUpgrade(helper up.Helper) {
if _, isLegacyConfig := helper.Get(up.Str, "appservice", "database", "uri"); isLegacyConfig {
doMigrateLegacy(helper, false)
return
} else if _, isLegacyPython := helper.Get(up.Str, "appservice", "database"); isLegacyPython {
doMigrateLegacy(helper, true)
return
}
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
helper.Copy(up.Bool, "bridge", "private_chat_portal_meta")
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")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_no_users")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "shared_has_users")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "private")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "relayed")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_no_users")
helper.Copy(up.Str, "bridge", "cleanup_on_logout", "bad_credentials", "shared_has_users")
helper.Copy(up.Bool, "bridge", "relay", "enabled")
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
helper.Copy(up.List, "bridge", "relay", "default_relays")
helper.Copy(up.Map, "bridge", "relay", "message_formats")
helper.Copy(up.Str, "bridge", "relay", "displayname_format")
helper.Copy(up.Map, "bridge", "permissions")
if dbType, ok := helper.Get(up.Str, "database", "type"); ok && dbType == "sqlite3" {
fmt.Println("Warning: invalid database type sqlite3 in config. Autocorrecting to sqlite3-fk-wal")
helper.Set(up.Str, "sqlite3-fk-wal", "database", "type")
} else {
helper.Copy(up.Str, "database", "type")
}
helper.Copy(up.Str, "database", "uri")
helper.Copy(up.Int, "database", "max_open_conns")
helper.Copy(up.Int, "database", "max_idle_conns")
helper.Copy(up.Str|up.Null, "database", "max_conn_idle_time")
helper.Copy(up.Str|up.Null, "database", "max_conn_lifetime")
helper.Copy(up.Str, "homeserver", "address")
helper.Copy(up.Str, "homeserver", "domain")
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", "public_address")
helper.Copy(up.Str|up.Null, "appservice", "hostname")
helper.Copy(up.Int|up.Null, "appservice", "port")
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")
helper.Copy(up.Str, "appservice", "username_template")
helper.Copy(up.Bool, "matrix", "message_status_events")
helper.Copy(up.Bool, "matrix", "delivery_receipts")
helper.Copy(up.Bool, "matrix", "message_error_notices")
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")
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")
} else {
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")
helper.Copy(up.Str, "direct_media", "server_name")
helper.Copy(up.Str|up.Null, "direct_media", "well_known_response")
helper.Copy(up.Bool, "direct_media", "allow_proxy")
if serverKey, ok := helper.Get(up.Str, "direct_media", "server_key"); !ok || serverKey == "generate" {
serverKey = federation.GenerateSigningKey().SynapseString()
helper.Set(up.Str, serverKey, "direct_media", "server_key")
} else {
helper.Copy(up.Str, "direct_media", "server_key")
}
helper.Copy(up.Bool, "public_media", "enabled")
if signingKey, ok := helper.Get(up.Str, "public_media", "signing_key"); !ok || signingKey == "generate" {
helper.Set(up.Str, random.String(64), "public_media", "signing_key")
} else {
helper.Copy(up.Str, "public_media", "signing_key")
}
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")
helper.Copy(up.Int, "backfill", "max_catchup_messages")
helper.Copy(up.Int, "backfill", "unread_hours_threshold")
helper.Copy(up.Int, "backfill", "threads", "max_initial_messages")
helper.Copy(up.Bool, "backfill", "queue", "enabled")
helper.Copy(up.Int, "backfill", "queue", "batch_size")
helper.Copy(up.Int, "backfill", "queue", "batch_delay")
helper.Copy(up.Int, "backfill", "queue", "max_batches")
helper.Copy(up.Map, "backfill", "queue", "max_batches_override")
helper.Copy(up.Map, "double_puppet", "servers")
helper.Copy(up.Bool, "double_puppet", "allow_discovery")
helper.Copy(up.Map, "double_puppet", "secrets")
helper.Copy(up.Bool, "encryption", "allow")
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")
} else {
helper.Copy(up.Str, "encryption", "pickle_key")
}
helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "encryption", "delete_keys", "ratchet_on_decrypt")
helper.Copy(up.Bool, "encryption", "delete_keys", "delete_fully_used_on_decrypt")
helper.Copy(up.Bool, "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Bool, "encryption", "delete_keys", "delete_outdated_inbound")
helper.Copy(up.Str, "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "encryption", "verification_levels", "send")
helper.Copy(up.Str, "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "encryption", "rotation", "milliseconds")
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")
}
var SpacedBlocks = [][]string{
{"bridge"},
{"bridge", "bridge_matrix_leave"},
{"bridge", "cleanup_on_logout"},
{"bridge", "relay"},
{"bridge", "permissions"},
{"database"},
{"homeserver"},
{"homeserver", "software"},
{"homeserver", "websocket"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "id"},
{"appservice", "ephemeral_events"},
{"appservice", "as_token"},
{"appservice", "username_template"},
{"matrix"},
{"analytics"},
{"provisioning"},
{"public_media"},
{"direct_media"},
{"backfill"},
{"double_puppet"},
{"encryption"},
{"env_config_prefix"},
{"logging"},
}
// Upgrader is a config upgrader that copies the default fields in the homeserver, appservice and logging blocks.
var Upgrader up.SpacedUpgrader = &up.StructUpgrader{
SimpleUpgrader: up.SimpleUpgrader(doUpgrade),
Blocks: SpacedBlocks,
}

View file

@ -1,333 +0,0 @@
// 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/.
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"
)
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
}
func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
state = state.Fill(nil)
for {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := br.Matrix.SendBridgeStatus(ctx, &state); err != nil {
br.Log.Warn().Err(err).Msg("Failed to update global bridge state")
cancel()
time.Sleep(5 * time.Second)
continue
} else {
br.Log.Debug().Any("bridge_state", state).Msg("Sent new global bridge state")
cancel()
break
}
}
}
func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue {
bsq := &BridgeStateQueue{
ch: make(chan status.BridgeState, 10),
stopChan: make(chan struct{}),
bridge: br,
login: login,
}
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")
}
}()
}
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)
err := bsq.bridge.Matrix.SendBridgeStatus(ctx, &state)
cancel()
if err != nil {
bsq.login.Log.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.prevSent = &state
bsq.login.Log.Debug().
Any("bridge_state", state).
Msg("Sent new bridge state")
return
}
}
}
func (bsq *BridgeStateQueue) Send(state status.BridgeState) {
if bsq == nil {
return
}
state = state.Fill(bsq.login)
bsq.prevUnsent = &state
if len(bsq.ch) >= 8 {
bsq.login.Log.Warn().Msg("Bridge state queue is nearly full, discarding an item")
select {
case <-bsq.ch:
default:
}
}
select {
case bsq.ch <- state:
default:
bsq.login.Log.Error().Msg("Bridge state queue is full, dropped new state")
}
}
func (bsq *BridgeStateQueue) GetPrev() status.BridgeState {
if bsq != nil && bsq.prevSent != nil {
return *bsq.prevSent
}
return status.BridgeState{}
}
func (bsq *BridgeStateQueue) GetPrevUnsent() status.BridgeState {
if bsq != nil && bsq.prevSent != nil {
return *bsq.prevUnsent
}
return status.BridgeState{}
}
func (bsq *BridgeStateQueue) SetPrev(prev status.BridgeState) {
if bsq != nil {
bsq.prevSent = &prev
}
}

View file

@ -1,97 +0,0 @@
// 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/.
package commands
import (
"maunium.net/go/mautrix/bridgev2"
)
var CommandDeletePortal = &FullHandler{
Func: func(ce *Event) {
// TODO clean up child portals?
err := ce.Portal.Delete(ce.Ctx)
if err != nil {
ce.Reply("Failed to delete portal: %v", err)
return
}
err = ce.Bot.DeleteRoom(ce.Ctx, ce.Portal.MXID, false)
if err != nil {
ce.Reply("Failed to clean up room: %v", err)
}
ce.MessageStatus.DisableMSS = true
},
Name: "delete-portal",
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Delete the current portal room",
},
RequiresAdmin: true,
RequiresPortal: true,
}
var CommandDeleteAllPortals = &FullHandler{
Func: func(ce *Event) {
portals, err := ce.Bridge.GetAllPortals(ce.Ctx)
if err != nil {
ce.Reply("Failed to get portals: %v", err)
return
}
bridgev2.DeleteManyPortals(ce.Ctx, portals, func(portal *bridgev2.Portal, delete bool, err error) {
if !delete {
ce.Reply("Failed to delete portal %s: %v", portal.MXID, err)
} else {
ce.Reply("Failed to clean up room %s: %v", portal.MXID, err)
}
})
},
Name: "delete-all-portals",
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Delete all portals the bridge knows about",
},
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

@ -1,125 +0,0 @@
// 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/.
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{
Func: func(ce *Event) {
if len(ce.Args) < 3 {
ce.Reply("Usage: `$cmdprefix debug-register-push <login ID> <push type> <push token>`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins())
return
}
pushType := bridgev2.PushTypeFromString(ce.Args[1])
if pushType == bridgev2.PushTypeUnknown {
ce.Reply("Unknown push type `%s`. Allowed types: `web`, `apns`, `fcm`", ce.Args[1])
return
}
login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if login == nil || login.UserMXID != ce.User.MXID {
ce.Reply("Login `%s` not found", ce.Args[0])
return
}
pushable, ok := login.Client.(bridgev2.PushableNetworkAPI)
if !ok {
ce.Reply("This network connector does not support push registration")
return
}
pushToken := strings.Join(ce.Args[2:], " ")
if pushToken == "null" {
pushToken = ""
}
err := pushable.RegisterPushNotifications(ce.Ctx, pushType, pushToken)
if err != nil {
ce.Reply("Failed to register pusher: %v", err)
return
}
if pushToken == "" {
ce.Reply("Pusher de-registered successfully")
} else {
ce.Reply("Pusher registered successfully")
}
},
Name: "debug-register-push",
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Register a pusher",
Args: "<_login ID_> <_push type_> <_push token_>",
},
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

@ -1,100 +0,0 @@
// 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"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix"
"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 bridgev2.MatrixAPI
Bridge *bridgev2.Bridge
Portal *bridgev2.Portal
Processor *Processor
Handler MinimalCommandHandler
RoomID id.RoomID
OrigRoomID id.RoomID
EventID id.EventID
User *bridgev2.User
Command string
Args []string
RawArgs string
ReplyTo id.EventID
Ctx context.Context
Log *zerolog.Logger
MessageStatus *bridgev2.MessageStatus
}
// Reply sends a reply to command as notice, with optional string formatting and automatic $cmdprefix replacement.
func (ce *Event) Reply(msg string, args ...any) {
msg = strings.ReplaceAll(msg, "$cmdprefix ", ce.Bridge.Config.CommandPrefix+" ")
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.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil)
if err != nil {
ce.Log.Err(err).Msg("Failed to reply to command")
}
}
// React sends a reaction to the command.
func (ce *Event) React(key string) {
_, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventReaction, &event.Content{
Parsed: &event.ReactionEventContent{
RelatesTo: event.RelatesTo{
Type: event.RelAnnotation,
EventID: ce.EventID,
Key: key,
},
},
}, nil)
if err != nil {
ce.Log.Err(err).Msg("Failed to react to command")
}
}
// Redact redacts the command.
func (ce *Event) Redact(req ...mautrix.ReqRedact) {
_, err := ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventRedaction, &event.Content{
Parsed: &event.RedactionEventContent{
Redacts: ce.EventID,
},
}, nil)
if err != nil {
ce.Log.Err(err).Msg("Failed to redact command")
}
}
// 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")
}
}

View file

@ -1,118 +0,0 @@
// 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/.
package commands
import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
)
type MinimalCommandHandler interface {
Run(*Event)
}
type MinimalCommandHandlerFunc func(*Event)
func (mhf MinimalCommandHandlerFunc) Run(ce *Event) {
mhf(ce)
}
type CommandState struct {
Next MinimalCommandHandler
Action string
Meta any
Cancel func()
}
type CommandHandler interface {
MinimalCommandHandler
GetName() string
}
type AliasedCommandHandler interface {
CommandHandler
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)
Name string
Aliases []string
Help HelpMeta
RequiresAdmin bool
RequiresPortal bool
RequiresLogin bool
RequiresEventLevel event.Type
RequiresLoginPermission bool
NetworkAPI ImplementationChecker[bridgev2.NetworkAPI]
NetworkConnector ImplementationChecker[bridgev2.NetworkConnector]
}
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) 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)
}
func (fh *FullHandler) userHasRoomPermission(ce *Event) bool {
levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID)
if err != nil {
ce.Log.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.MXID) >= levels.GetEventLevel(fh.RequiresEventLevel)
}
func (fh *FullHandler) Run(ce *Event) {
if fh.RequiresAdmin && !ce.User.Permissions.Admin {
ce.Reply("That command is limited to bridge administrators.")
} else if fh.RequiresLoginPermission && !ce.User.Permissions.Login {
ce.Reply("You do not have permissions to log into this bridge.")
} else if fh.RequiresEventLevel.Type != "" && !ce.User.Permissions.Admin && !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.GetDefaultLogin() == nil {
ce.Reply("That command requires you to be logged in.")
} else {
fh.Func(ce)
}
}

View file

@ -1,616 +0,0 @@
// 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/.
package commands
import (
"context"
"encoding/json"
"fmt"
"html"
"net/url"
"regexp"
"slices"
"strings"
"github.com/skip2/go-qrcode"
"go.mau.fi/util/curl"
"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"
)
var CommandLogin = &FullHandler{
Func: fnLogin,
Name: "login",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Log into the bridge",
Args: "[_flow ID_]",
},
RequiresLoginPermission: true,
}
var CommandRelogin = &FullHandler{
Func: fnLogin,
Name: "relogin",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Re-authenticate an existing login",
Args: "<_login ID_> [_flow ID_]",
},
RequiresLoginPermission: true,
}
func formatFlowsReply(flows []bridgev2.LoginFlow) string {
var buf strings.Builder
for _, flow := range flows {
_, _ = fmt.Fprintf(&buf, "* `%s` - %s\n", flow.ID, flow.Description)
}
return buf.String()
}
func fnLogin(ce *Event) {
var reauth *bridgev2.UserLogin
if ce.Command == "relogin" {
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix relogin <login ID> [_flow ID_]`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins())
return
}
reauth = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if reauth == nil {
ce.Reply("Login `%s` not found", ce.Args[0])
return
}
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 {
inputFlowID := strings.ToLower(ce.Args[0])
ce.Args = ce.Args[1:]
for _, flow := range flows {
if flow.ID == inputFlowID {
chosenFlowID = flow.ID
break
}
}
if chosenFlowID == "" {
ce.Reply("Invalid login flow `%s`. Available options:\n\n%s", inputFlowID, formatFlowsReply(flows))
return
}
} else if len(flows) == 1 {
chosenFlowID = flows[0].ID
} else {
if reauth != nil {
ce.Reply("Please specify a login flow, e.g. `relogin %s %s`.\n\n%s", reauth.ID, flows[0].ID, formatFlowsReply(flows))
} else {
ce.Reply("Please specify a login flow, e.g. `login %s`.\n\n%s", flows[0].ID, formatFlowsReply(flows))
}
return
}
login, err := ce.Bridge.Network.CreateLogin(ce.Ctx, ce.User, chosenFlowID)
if err != nil {
ce.Reply("Failed to prepare login process: %v", err)
return
}
overridable, ok := login.(bridgev2.LoginProcessWithOverride)
var nextStep *bridgev2.LoginStep
if ok && reauth != nil {
nextStep, err = overridable.StartWithOverride(ce.Ctx, reauth)
} else {
nextStep, err = login.Start(ce.Ctx)
}
if err != nil {
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 {
doLoginStep(ce, login, nextStep, reauth)
}
}
func checkLoginCommandDirectParams(ce *Event, login bridgev2.LoginProcess, nextStep *bridgev2.LoginStep) *bridgev2.LoginStep {
if len(ce.Args) == 0 {
return nextStep
}
var ok bool
defer func() {
if !ok {
login.Cancel()
}
}()
var err error
switch nextStep.Type {
case bridgev2.LoginStepTypeDisplayAndWait:
ce.Reply("Invalid extra parameters for display and wait login step")
return nil
case bridgev2.LoginStepTypeUserInput:
if len(ce.Args) != len(nextStep.UserInputParams.Fields) {
ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.UserInputParams.Fields), len(ce.Args))
return nil
}
input := make(map[string]string)
var shouldRedact bool
for i, param := range nextStep.UserInputParams.Fields {
param.FillDefaultValidate()
input[param.ID], err = param.Validate(ce.Args[i])
if err != nil {
ce.Reply("Invalid value for %s: %v", param.Name, err)
return nil
}
if param.Type == bridgev2.LoginInputFieldTypePassword || param.Type == bridgev2.LoginInputFieldTypeToken {
shouldRedact = true
}
}
if shouldRedact {
ce.Redact()
}
nextStep, err = login.(bridgev2.LoginProcessUserInput).SubmitUserInput(ce.Ctx, input)
case bridgev2.LoginStepTypeCookies:
if len(ce.Args) != len(nextStep.CookiesParams.Fields) {
ce.Reply("Invalid number of extra parameters (expected 0 or %d, got %d)", len(nextStep.CookiesParams.Fields), len(ce.Args))
return nil
}
input := make(map[string]string)
for i, param := range nextStep.CookiesParams.Fields {
val := maybeURLDecodeCookie(ce.Args[i], &param)
if match, _ := regexp.MatchString(param.Pattern, val); !match {
ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", param.ID, val, param.Pattern)
return nil
}
input[param.ID] = val
}
ce.Redact()
nextStep, err = login.(bridgev2.LoginProcessCookies).SubmitCookies(ce.Ctx, input)
}
if err != nil {
ce.Reply("Failed to submit input: %v", err)
return nil
}
ok = true
return nextStep
}
type userInputLoginCommandState struct {
Login bridgev2.LoginProcessUserInput
Data map[string]string
RemainingFields []bridgev2.LoginInputDataField
Override *bridgev2.UserLogin
}
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)
}
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",
Meta: uilcs,
Cancel: uilcs.Login.Cancel,
})
}
func (uilcs *userInputLoginCommandState) submitNext(ce *Event) {
field := uilcs.RemainingFields[0]
field.FillDefaultValidate()
if field.Type == bridgev2.LoginInputFieldTypePassword || field.Type == bridgev2.LoginInputFieldTypeToken {
ce.Redact()
}
var err error
uilcs.Data[field.ID], err = field.Validate(ce.RawArgs)
if err != nil {
ce.Reply("Invalid value: %v", err)
return
} else if len(uilcs.RemainingFields) > 1 {
uilcs.RemainingFields = uilcs.RemainingFields[1:]
uilcs.promptNext(ce)
return
}
StoreCommandState(ce.User, nil)
if nextStep, err := uilcs.Login.SubmitUserInput(ce.Ctx, uilcs.Data); err != nil {
ce.Reply("Failed to submit input: %v", err)
} else {
doLoginStep(ce, uilcs.Login, nextStep, uilcs.Override)
}
}
const qrSizePx = 512
func sendQR(ce *Event, qr string, prevEventID *id.EventID) error {
qrData, err := qrcode.Encode(qr, qrcode.Low, qrSizePx)
if err != nil {
return fmt.Errorf("failed to encode QR code: %w", err)
}
qrMXC, qrFile, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, qrData, "qr.png", "image/png")
if err != nil {
return fmt.Errorf("failed to upload image: %w", err)
}
content := &event.MessageEventContent{
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)
}
newEventID, err := ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
if err != nil {
return err
}
if *prevEventID == "" {
*prevEventID = newEventID.EventID
}
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 (
contextKeyPrevEventID contextKey = iota
)
func doLoginDisplayAndWait(ce *Event, login bridgev2.LoginProcessDisplayAndWait, step *bridgev2.LoginStep, override *bridgev2.UserLogin) {
prevEvent, ok := ce.Ctx.Value(contextKeyPrevEventID).(*id.EventID)
if !ok {
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)
if err != nil {
ce.Reply("Failed to send QR code: %v", err)
login.Cancel()
return
}
case bridgev2.LoginDisplayTypeEmoji:
ce.ReplyAdvanced(step.DisplayAndWaitParams.Data, false, false)
case bridgev2.LoginDisplayTypeCode:
ce.ReplyAdvanced(fmt.Sprintf("<code>%s</code>", html.EscapeString(step.DisplayAndWaitParams.Data)), false, true)
case bridgev2.LoginDisplayTypeNothing:
// Do nothing
default:
ce.Reply("Unsupported display type %q", step.DisplayAndWaitParams.Type)
login.Cancel()
return
}
nextStep, err := login.Wait(cancelCtx)
// 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{
Parsed: &event.RedactionEventContent{
Redacts: *prevEvent,
},
}, nil)
*prevEvent = ""
}
if err != nil {
ce.Reply("Login failed: %v", err)
return
}
doLoginStep(ce, login, nextStep, override)
}
type cookieLoginCommandState struct {
Login bridgev2.LoginProcessCookies
Data *bridgev2.LoginCookiesParams
Override *bridgev2.UserLogin
}
func (clcs *cookieLoginCommandState) prompt(ce *Event) {
ce.Reply("Login URL: <%s>", clcs.Data.URL)
StoreCommandState(ce.User, &CommandState{
Next: MinimalCommandHandlerFunc(clcs.submit),
Action: "Login",
Meta: clcs,
Cancel: clcs.Login.Cancel,
})
}
func (clcs *cookieLoginCommandState) submit(ce *Event) {
ce.Redact()
cookiesInput := make(map[string]string)
if strings.HasPrefix(strings.TrimSpace(ce.RawArgs), "curl") {
parsed, err := curl.Parse(ce.RawArgs)
if err != nil {
ce.Reply("Failed to parse curl: %v", err)
return
}
reqCookies := make(map[string]string)
for _, cookie := range parsed.Cookies() {
reqCookies[cookie.Name], err = url.PathUnescape(cookie.Value)
if err != nil {
ce.Reply("Failed to parse cookie %s: %v", cookie.Name, err)
return
}
}
var missingKeys, unsupportedKeys []string
for _, field := range clcs.Data.Fields {
var value string
var supported bool
for _, src := range field.Sources {
switch src.Type {
case bridgev2.LoginCookieTypeCookie:
supported = true
value = reqCookies[src.Name]
case bridgev2.LoginCookieTypeRequestHeader:
supported = true
value = parsed.Header.Get(src.Name)
case bridgev2.LoginCookieTypeRequestBody:
supported = true
switch {
case parsed.MultipartForm != nil:
values, ok := parsed.MultipartForm.Value[src.Name]
if ok && len(values) > 0 {
value = values[0]
}
case parsed.ParsedJSON != nil:
untypedValue, ok := parsed.ParsedJSON[src.Name]
if ok {
value = fmt.Sprintf("%v", untypedValue)
}
}
}
if value != "" {
cookiesInput[field.ID] = value
break
}
}
if value == "" && field.Required {
if supported {
missingKeys = append(missingKeys, field.ID)
} else {
unsupportedKeys = append(unsupportedKeys, field.ID)
}
}
}
if len(unsupportedKeys) > 0 {
ce.Reply("Some keys can't be extracted from a cURL request: %+v\n\nPlease provide a JSON object instead.", unsupportedKeys)
return
} else if len(missingKeys) > 0 {
ce.Reply("Missing some keys: %+v", missingKeys)
return
}
} else {
err := json.Unmarshal([]byte(ce.RawArgs), &cookiesInput)
if err != nil {
ce.Reply("Failed to parse input as JSON: %v", err)
return
}
for _, field := range clcs.Data.Fields {
val, ok := cookiesInput[field.ID]
if ok {
cookiesInput[field.ID] = maybeURLDecodeCookie(val, &field)
}
}
}
var missingKeys []string
for _, field := range clcs.Data.Fields {
val, ok := cookiesInput[field.ID]
if !ok && field.Required {
missingKeys = append(missingKeys, field.ID)
}
if match, _ := regexp.MatchString(field.Pattern, val); !match {
ce.Reply("Invalid value for %s: `%s` doesn't match regex `%s`", field.ID, val, field.Pattern)
return
}
}
if len(missingKeys) > 0 {
ce.Reply("Missing some keys: %+v", missingKeys)
return
}
StoreCommandState(ce.User, nil)
nextStep, err := clcs.Login.SubmitCookies(ce.Ctx, cookiesInput)
if err != nil {
ce.Reply("Login failed: %v", err)
return
}
doLoginStep(ce, clcs.Login, nextStep, clcs.Override)
}
func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string {
if val == "" {
return val
}
isCookie := slices.ContainsFunc(field.Sources, func(src bridgev2.LoginCookieFieldSource) bool {
return src.Type == bridgev2.LoginCookieTypeCookie
})
if !isCookie {
return val
}
decoded, err := url.PathUnescape(val)
if err != nil {
return val
}
return decoded
}
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)
}
switch step.Type {
case bridgev2.LoginStepTypeDisplayAndWait:
doLoginDisplayAndWait(ce, login.(bridgev2.LoginProcessDisplayAndWait), step, override)
case bridgev2.LoginStepTypeCookies:
(&cookieLoginCommandState{
Login: login.(bridgev2.LoginProcessCookies),
Data: step.CookiesParams,
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,
Data: make(map[string]string),
Override: override,
}).promptNext(ce)
case bridgev2.LoginStepTypeComplete:
if override != nil && override.ID != step.CompleteParams.UserLoginID {
ce.Log.Info().
Str("old_login_id", string(override.ID)).
Str("new_login_id", string(step.CompleteParams.UserLoginID)).
Msg("Login resulted in different remote ID than what was being overridden. Deleting previous login")
override.Delete(ce.Ctx, status.BridgeState{
StateEvent: status.StateLoggedOut,
Reason: "LOGIN_OVERRIDDEN",
}, bridgev2.DeleteOpts{LogoutRemote: true})
}
default:
panic(fmt.Errorf("unknown login step type %q", step.Type))
}
}
var CommandListLogins = &FullHandler{
Func: fnListLogins,
Name: "list-logins",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "List your logins",
},
RequiresLoginPermission: true,
}
func fnListLogins(ce *Event) {
logins := ce.User.GetFormattedUserLogins()
if len(logins) == 0 {
ce.Reply("You're not logged in")
} else {
ce.Reply("%s", logins)
}
}
var CommandLogout = &FullHandler{
Func: fnLogout,
Name: "logout",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Log out of the bridge",
Args: "<_login ID_>",
},
}
func fnLogout(ce *Event) {
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix logout <login ID>`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins())
return
}
login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if login == nil || login.UserMXID != ce.User.MXID {
ce.Reply("Login `%s` not found", ce.Args[0])
return
}
login.Logout(ce.Ctx)
ce.Reply("Logged out")
}
var CommandSetPreferredLogin = &FullHandler{
Func: fnSetPreferredLogin,
Name: "set-preferred-login",
Aliases: []string{"prefer"},
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Set the preferred login ID for sending messages to this portal (only relevant when logged into multiple accounts via the bridge)",
Args: "<_login ID_>",
},
RequiresPortal: true,
RequiresLoginPermission: true,
}
func fnSetPreferredLogin(ce *Event) {
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix set-preferred-login <login ID>`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins())
return
}
login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if login == nil || login.UserMXID != ce.User.MXID {
ce.Reply("Login `%s` not found", ce.Args[0])
return
}
err := login.MarkAsPreferredIn(ce.Ctx, ce.Portal)
if err != nil {
ce.Reply("Failed to set preferred login: %v", err)
} else {
ce.Reply("Preferred login set")
}
}

View file

@ -1,206 +0,0 @@
// 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/.
package commands
import (
"context"
"fmt"
"runtime/debug"
"strings"
"sync/atomic"
"unsafe"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type Processor struct {
bridge *bridgev2.Bridge
log *zerolog.Logger
handlers map[string]CommandHandler
aliases map[string]string
}
// NewProcessor creates a Processor
func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor {
proc := &Processor{
bridge: bridge,
log: &bridge.Log,
handlers: make(map[string]CommandHandler),
aliases: make(map[string]string),
}
proc.AddHandlers(
CommandHelp, CommandCancel,
CommandRegisterPush, CommandSendAccountData, CommandResetNetwork,
CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom,
CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin,
CommandSetRelay, CommandUnsetRelay,
CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandMute,
CommandSudo, CommandDoIn,
)
return proc
}
func (proc *Processor) AddHandlers(handlers ...CommandHandler) {
for _, handler := range handlers {
proc.AddHandler(handler)
}
}
func (proc *Processor) AddHandler(handler CommandHandler) {
proc.handlers[handler.GetName()] = handler
aliased, ok := handler.(AliasedCommandHandler)
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 *bridgev2.User, message string, replyTo id.EventID) {
ms := &bridgev2.MessageStatus{
Step: status.MsgStepCommand,
Status: event.MessageStatusSuccess,
}
logCopy := zerolog.Ctx(ctx).With().Logger()
log := &logCopy
defer func() {
statusInfo := &bridgev2.MessageStatusEventInfo{
RoomID: roomID,
SourceEventID: eventID,
EventType: event.EventMessage,
Sender: user.MXID,
}
err := recover()
if err != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack())
if realErr, ok := err.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
}
logEvt.Msg("Panic in Matrix command handler")
ms.Status = event.MessageStatusFail
ms.IsCertain = true
if realErr, ok := err.(error); ok {
ms.InternalError = realErr
} else {
ms.InternalError = fmt.Errorf("%v", err)
}
ms.ErrorAsMessage = true
}
proc.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo)
}()
args := strings.Fields(message)
if len(args) == 0 {
args = []string{"unknown-command"}
}
command := strings.ToLower(args[0])
rawArgs := strings.TrimLeft(strings.TrimPrefix(message, command), " ")
portal, err := proc.bridge.GetPortalByMXID(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to get portal")
// :(
}
ce := &Event{
Bot: proc.bridge.Bot,
Bridge: proc.bridge,
Portal: portal,
Processor: proc,
RoomID: roomID,
OrigRoomID: roomID,
EventID: eventID,
User: user,
Command: command,
Args: args[1:],
RawArgs: rawArgs,
ReplyTo: replyTo,
Ctx: ctx,
Log: log,
MessageStatus: ms,
}
proc.handleCommand(ctx, ce, message, args)
}
func (proc *Processor) handleCommand(ctx context.Context, ce *Event, origMessage string, origArgs []string) {
realCommand, ok := proc.aliases[ce.Command]
if !ok {
realCommand = ce.Command
}
log := zerolog.Ctx(ctx)
var handler MinimalCommandHandler
handler, ok = proc.handlers[realCommand]
if !ok {
state := LoadCommandState(ce.User)
if state != nil && state.Next != nil {
ce.Command = ""
ce.RawArgs = origMessage
ce.Args = origArgs
ce.Handler = state.Next
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Str("action", state.Action)
})
log.Debug().Msg("Received reply to command state")
state.Next.Run(ce)
} else {
zerolog.Ctx(ctx).Debug().Str("mx_command", ce.Command).Msg("Received unknown command")
ce.Reply("Unknown command, use the `help` command for help.")
}
} else {
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Str("mx_command", ce.Command)
})
log.Debug().Msg("Received command")
ce.Handler = handler
handler.Run(ce)
}
}
func LoadCommandState(user *bridgev2.User) *CommandState {
return (*CommandState)(atomic.LoadPointer(&user.CommandState))
}
func StoreCommandState(user *bridgev2.User, cs *CommandState) {
atomic.StorePointer(&user.CommandState, unsafe.Pointer(cs))
}
func SwapCommandState(user *bridgev2.User, cs *CommandState) *CommandState {
return (*CommandState)(atomic.SwapPointer(&user.CommandState, unsafe.Pointer(cs)))
}
var CommandCancel = &FullHandler{
Func: func(ce *Event) {
state := SwapCommandState(ce.User, nil)
if state != nil {
action := state.Action
if action == "" {
action = "Unknown action"
}
if state.Cancel != nil {
state.Cancel()
}
ce.Reply("%s cancelled.", action)
} else {
ce.Reply("No ongoing command.")
}
},
Name: "cancel",
Help: HelpMeta{
Section: HelpSectionGeneral,
Description: "Cancel an ongoing action.",
},
}

View file

@ -1,156 +0,0 @@
// 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 (
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
)
var fakeEvtSetRelay = event.Type{Type: "fi.mau.bridge.set_relay", Class: event.StateEventType}
var CommandSetRelay = &FullHandler{
Func: fnSetRelay,
Name: "set-relay",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Use your account to relay messages sent by users who haven't logged in",
Args: "[_login ID_]",
},
RequiresPortal: true,
}
func fnSetRelay(ce *Event) {
if !ce.Bridge.Config.Relay.Enabled {
ce.Reply("This bridge does not allow relay mode")
return
} else if !canManageRelay(ce) {
ce.Reply("You don't have permission to manage the relay in this room")
return
}
onlySetDefaultRelays := !ce.User.Permissions.Admin && ce.Bridge.Config.Relay.AdminOnly
var relay *bridgev2.UserLogin
if len(ce.Args) == 0 && ce.Portal.Receiver == "" {
relay = ce.User.GetDefaultLogin()
isLoggedIn := relay != nil
if onlySetDefaultRelays {
relay = nil
}
if relay == nil {
if len(ce.Bridge.Config.Relay.DefaultRelays) == 0 {
ce.Reply("You're not logged in and there are no default relay users configured")
return
}
logins, err := ce.Bridge.GetUserLoginsInPortal(ce.Ctx, ce.Portal.PortalKey)
if err != nil {
ce.Log.Err(err).Msg("Failed to get user logins in portal")
ce.Reply("Failed to get logins in portal to find default relay")
return
}
Outer:
for _, loginID := range ce.Bridge.Config.Relay.DefaultRelays {
for _, login := range logins {
if login.ID == loginID {
relay = login
break Outer
}
}
}
if relay == nil {
if isLoggedIn {
ce.Reply("You're not allowed to use yourself as relay and none of the default relay users are in the chat")
} else {
ce.Reply("You're not logged in and none of the default relay users are in the chat")
}
return
}
}
} 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)
if relay == nil {
ce.Reply("User login with ID `%s` not found", targetID)
return
} else if slices.Contains(ce.Bridge.Config.Relay.DefaultRelays, relay.ID) {
// All good
} else if relay.UserMXID != ce.User.MXID && !ce.User.Permissions.Admin {
ce.Reply("Only bridge admins can set another user's login as the relay")
return
} else if onlySetDefaultRelays {
ce.Reply("You're not allowed to use yourself as relay")
return
}
}
err := ce.Portal.SetRelay(ce.Ctx, relay)
if err != nil {
ce.Log.Err(err).Msg("Failed to unset relay")
ce.Reply("Failed to save relay settings")
} else {
ce.Reply(
"Messages sent by users who haven't logged in will now be relayed through %s ([%s](%s)'s login)",
relay.RemoteName,
relay.UserMXID,
// TODO this will need to stop linkifying if we ever allow UserLogins that aren't bound to a real user.
relay.UserMXID.URI().MatrixToURL(),
)
}
}
var CommandUnsetRelay = &FullHandler{
Func: fnUnsetRelay,
Name: "unset-relay",
Help: HelpMeta{
Section: HelpSectionAuth,
Description: "Stop relaying messages sent by users who haven't logged in",
},
RequiresPortal: true,
}
func fnUnsetRelay(ce *Event) {
if ce.Portal.Relay == nil {
ce.Reply("This portal doesn't have a relay set.")
return
} else if !canManageRelay(ce) {
ce.Reply("You don't have permission to manage the relay in this room")
return
}
err := ce.Portal.SetRelay(ce.Ctx, nil)
if err != nil {
ce.Log.Err(err).Msg("Failed to unset relay")
ce.Reply("Failed to save relay settings")
} else {
ce.Reply("Stopped relaying messages for users who haven't logged in")
}
}
func canManageRelay(ce *Event) bool {
return ce.User.Permissions.ManageRelay &&
(ce.User.Permissions.Admin ||
(ce.Portal.Relay != nil && ce.Portal.Relay.UserMXID == ce.User.MXID) ||
hasRelayRoomPermissions(ce))
}
func hasRelayRoomPermissions(ce *Event) bool {
levels, err := ce.Bridge.Matrix.GetPowerLevels(ce.Ctx, ce.RoomID)
if err != nil {
ce.Log.Err(err).Msg("Failed to check room power levels")
return false
}
return levels.GetUserLevel(ce.User.MXID) >= levels.GetEventLevel(fakeEvtSetRelay)
}

View file

@ -1,333 +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 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"
)
var CommandResolveIdentifier = &FullHandler{
Func: fnResolveIdentifier,
Name: "resolve-identifier",
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Check if a given identifier is on the remote network",
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{
Func: fnResolveIdentifier,
Name: "start-chat",
Aliases: []string{"pm"},
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Start a direct chat with the given user",
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]))
}
if login == nil || login.UserMXID != ce.User.MXID {
remainingArgs = ce.Args
login = ce.User.GetDefaultLogin()
}
api, ok := login.Client.(T)
if !ok {
ce.Reply("This bridge does not support %s", thing)
}
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)
} else {
return fmt.Sprintf("`%s`", resp.ID)
}
}
func fnResolveIdentifier(ce *Event) {
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix %s <identifier>`", ce.Command)
return
}
login, api, identifierParts := getClientForStartingChat[bridgev2.IdentifierResolvingNetworkAPI](ce, "resolving identifiers")
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)
}
if err != nil {
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)
if createChat {
name := resp.Portal.Name
if name == "" {
name = resp.Portal.MXID.String()
}
if !resp.JustCreated {
ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL())
} else {
ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, resp.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",
Help: HelpMeta{
Section: HelpSectionChats,
Description: "Search for users on the remote network",
Args: "<_query_>",
},
RequiresLogin: true,
NetworkAPI: NetworkAPIImplements[bridgev2.UserSearchingNetworkAPI],
}
func fnSearch(ce *Event) {
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix search <query>`")
return
}
login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users")
if api == nil {
return
}
resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " "))
if err != nil {
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[i] = fmt.Sprintf("* %s", formattedName)
if res.Portal != nil && res.Portal.MXID != "" {
portalName := res.Portal.Name
if portalName == "" {
portalName = res.Portal.MXID.String()
}
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

@ -1,107 +0,0 @@
// 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/.
package commands
import (
"strings"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var CommandSudo = &FullHandler{
Func: fnSudo,
Name: "sudo",
Aliases: []string{"doas", "do-as", "runas", "run-as"},
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Run a command as a different user.",
Args: "[--create] <_user ID_> <_command_> [_args..._]",
},
RequiresAdmin: true,
}
func fnSudo(ce *Event) {
forceNonexistentUser := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--create"
if forceNonexistentUser {
ce.Args = ce.Args[1:]
}
if len(ce.Args) < 2 {
ce.Reply("Usage: `$cmdprefix sudo [--create] <user ID> <command> [args...]`")
return
}
targetUserID := id.UserID(ce.Args[0])
if _, _, err := targetUserID.Parse(); err != nil || len(targetUserID) > id.UserIDMaxLength {
ce.Reply("Invalid user ID `%s`", targetUserID)
return
}
var targetUser *bridgev2.User
var err error
if forceNonexistentUser {
targetUser, err = ce.Bridge.GetUserByMXID(ce.Ctx, targetUserID)
} else {
targetUser, err = ce.Bridge.GetExistingUserByMXID(ce.Ctx, targetUserID)
}
if err != nil {
ce.Log.Err(err).Msg("Failed to get user from database")
ce.Reply("Failed to get user")
return
} else if targetUser == nil {
ce.Reply("User not found. Use `--create` if you want to run commands as a user who has never used the bridge.")
return
}
ce.User = targetUser
origArgs := ce.Args[1:]
ce.Command = strings.ToLower(ce.Args[1])
ce.Args = ce.Args[2:]
ce.RawArgs = strings.Join(ce.Args, " ")
ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs)
}
var CommandDoIn = &FullHandler{
Func: fnDoIn,
Name: "doin",
Aliases: []string{"do-in", "runin", "run-in"},
Help: HelpMeta{
Section: HelpSectionAdmin,
Description: "Run a command in a different room.",
Args: "<_room ID_> <_command_> [_args..._]",
},
}
func fnDoIn(ce *Event) {
if len(ce.Args) < 2 {
ce.Reply("Usage: `$cmdprefix doin <room ID> <command> [args...]`")
return
}
targetRoomID := id.RoomID(ce.Args[0])
if !ce.User.Permissions.Admin {
memberInfo, err := ce.Bridge.Matrix.GetMemberInfo(ce.Ctx, targetRoomID, ce.User.MXID)
if err != nil {
ce.Log.Err(err).Msg("Failed to check if user is in doin target room")
ce.Reply("Failed to check if you're in the target room")
return
} else if memberInfo == nil || memberInfo.Membership != event.MembershipJoin {
ce.Reply("You must be in the target room to run commands there")
return
}
}
ce.RoomID = targetRoomID
var err error
ce.Portal, err = ce.Bridge.GetPortalByMXID(ce.Ctx, targetRoomID)
if err != nil {
ce.Log.Err(err).Msg("Failed to get target portal")
ce.Reply("Failed to get portal")
return
}
origArgs := ce.Args[1:]
ce.Command = strings.ToLower(ce.Args[1])
ce.Args = ce.Args[2:]
ce.RawArgs = strings.Join(ce.Args, " ")
ce.Processor.handleCommand(ce.Ctx, ce, strings.Join(origArgs, " "), origArgs)
}

View file

@ -1,182 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type BackfillTaskQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*BackfillTask]
}
type BackfillTask struct {
BridgeID networkid.BridgeID
PortalKey networkid.PortalKey
UserLoginID networkid.UserLoginID
BatchCount int
IsDone bool
Cursor networkid.PaginationCursor
OldestMessageID networkid.MessageID
DispatchedAt time.Time
CompletedAt time.Time
NextDispatchMinTS time.Time
}
var BackfillNextDispatchNever = time.Unix(0, (1<<63)-1)
const (
ensureBackfillExistsQuery = `
INSERT INTO backfill_task (bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done, next_dispatch_min_ts)
VALUES ($1, $2, $3, $4, -1, false, $5)
ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE
SET user_login_id=CASE
WHEN backfill_task.user_login_id=''
THEN excluded.user_login_id
ELSE backfill_task.user_login_id
END,
next_dispatch_min_ts=CASE
WHEN backfill_task.next_dispatch_min_ts=9223372036854775807
THEN excluded.next_dispatch_min_ts
ELSE backfill_task.next_dispatch_min_ts
END
`
upsertBackfillQueueQuery = `
INSERT INTO backfill_task (
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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (bridge_id, portal_id, portal_receiver) DO UPDATE
SET user_login_id=excluded.user_login_id,
batch_count=excluded.batch_count,
is_done=excluded.is_done,
cursor=excluded.cursor,
oldest_message_id=excluded.oldest_message_id,
dispatched_at=excluded.dispatched_at,
completed_at=excluded.completed_at,
next_dispatch_min_ts=excluded.next_dispatch_min_ts
`
markBackfillDispatchedQuery = `
UPDATE backfill_task SET dispatched_at=$4, completed_at=NULL, next_dispatch_min_ts=$5
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3
`
updateBackfillQueueQuery = `
UPDATE backfill_task
SET user_login_id=$4, batch_count=$5, is_done=$6, cursor=$7, oldest_message_id=$8,
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,
cursor, oldest_message_id, dispatched_at, completed_at, next_dispatch_min_ts
FROM backfill_task
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
`
)
func (btq *BackfillTaskQuery) EnsureExists(ctx context.Context, portal networkid.PortalKey, loginID networkid.UserLoginID) error {
return btq.Exec(ctx, ensureBackfillExistsQuery, btq.BridgeID, portal.ID, portal.Receiver, loginID, time.Now().UnixNano())
}
func (btq *BackfillTaskQuery) Upsert(ctx context.Context, bq *BackfillTask) error {
ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID)
return btq.Exec(ctx, upsertBackfillQueueQuery, bq.sqlVariables()...)
}
const UnfinishedBackfillBackoff = 1 * time.Hour
func (btq *BackfillTaskQuery) MarkDispatched(ctx context.Context, bq *BackfillTask) error {
ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID)
bq.DispatchedAt = time.Now()
bq.CompletedAt = time.Time{}
bq.NextDispatchMinTS = bq.DispatchedAt.Add(UnfinishedBackfillBackoff)
return btq.Exec(
ctx, markBackfillDispatchedQuery,
bq.BridgeID, bq.PortalKey.ID, bq.PortalKey.Receiver,
bq.DispatchedAt.UnixNano(), bq.NextDispatchMinTS.UnixNano(),
)
}
func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) error {
ensureBridgeIDMatches(&bq.BridgeID, btq.BridgeID)
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)
}
func (bt *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) {
var cursor, oldestMessageID sql.NullString
var dispatchedAt, completedAt, nextDispatchMinTS sql.NullInt64
err := row.Scan(
&bt.BridgeID, &bt.PortalKey.ID, &bt.PortalKey.Receiver, &bt.UserLoginID, &bt.BatchCount, &bt.IsDone,
&cursor, &oldestMessageID, &dispatchedAt, &completedAt, &nextDispatchMinTS)
if err != nil {
return nil, err
}
bt.Cursor = networkid.PaginationCursor(cursor.String)
bt.OldestMessageID = networkid.MessageID(oldestMessageID.String)
if dispatchedAt.Valid {
bt.DispatchedAt = time.Unix(0, dispatchedAt.Int64)
}
if completedAt.Valid {
bt.CompletedAt = time.Unix(0, completedAt.Int64)
}
if nextDispatchMinTS.Valid {
bt.NextDispatchMinTS = time.Unix(0, nextDispatchMinTS.Int64)
}
return bt, nil
}
func (bt *BackfillTask) sqlVariables() []any {
return []any{
bt.BridgeID, bt.PortalKey.ID, bt.PortalKey.Receiver, bt.UserLoginID, bt.BatchCount, bt.IsDone,
dbutil.StrPtr(bt.Cursor), dbutil.StrPtr(bt.OldestMessageID),
dbutil.ConvertedPtr(bt.DispatchedAt, time.Time.UnixNano),
dbutil.ConvertedPtr(bt.CompletedAt, time.Time.UnixNano),
bt.NextDispatchMinTS.UnixNano(),
}
}

View file

@ -1,154 +0,0 @@
// 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/.
package database
import (
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/database/upgrades"
)
type Database struct {
*dbutil.Database
BridgeID networkid.BridgeID
Portal *PortalQuery
Ghost *GhostQuery
Message *MessageQuery
DisappearingMessage *DisappearingMessageQuery
Reaction *ReactionQuery
User *UserQuery
UserLogin *UserLoginQuery
UserPortal *UserPortalQuery
BackfillTask *BackfillTaskQuery
KV *KVQuery
PublicMedia *PublicMediaQuery
}
type MetaMerger interface {
CopyFrom(other any)
}
type MetaTypeCreator func() any
type MetaTypes struct {
Portal MetaTypeCreator
Ghost MetaTypeCreator
Message MetaTypeCreator
Reaction MetaTypeCreator
UserLogin MetaTypeCreator
}
type blankMeta struct{}
var blankMetaItem = &blankMeta{}
func blankMetaCreator() any {
return blankMetaItem
}
func New(bridgeID networkid.BridgeID, mt MetaTypes, db *dbutil.Database) *Database {
if mt.Portal == nil {
mt.Portal = blankMetaCreator
}
if mt.Ghost == nil {
mt.Ghost = blankMetaCreator
}
if mt.Message == nil {
mt.Message = blankMetaCreator
}
if mt.Reaction == nil {
mt.Reaction = blankMetaCreator
}
if mt.UserLogin == nil {
mt.UserLogin = blankMetaCreator
}
db.UpgradeTable = upgrades.Table
return &Database{
Database: db,
BridgeID: bridgeID,
Portal: &PortalQuery{
BridgeID: bridgeID,
MetaType: mt.Portal,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Portal]) *Portal {
return (&Portal{}).ensureHasMetadata(mt.Portal)
}),
},
Ghost: &GhostQuery{
BridgeID: bridgeID,
MetaType: mt.Ghost,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Ghost]) *Ghost {
return (&Ghost{}).ensureHasMetadata(mt.Ghost)
}),
},
Message: &MessageQuery{
BridgeID: bridgeID,
MetaType: mt.Message,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Message]) *Message {
return (&Message{}).ensureHasMetadata(mt.Message)
}),
},
DisappearingMessage: &DisappearingMessageQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage {
return &DisappearingMessage{}
}),
},
Reaction: &ReactionQuery{
BridgeID: bridgeID,
MetaType: mt.Reaction,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*Reaction]) *Reaction {
return (&Reaction{}).ensureHasMetadata(mt.Reaction)
}),
},
User: &UserQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*User]) *User {
return &User{}
}),
},
UserLogin: &UserLoginQuery{
BridgeID: bridgeID,
MetaType: mt.UserLogin,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserLogin]) *UserLogin {
return (&UserLogin{}).ensureHasMetadata(mt.UserLogin)
}),
},
UserPortal: &UserPortalQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*UserPortal]) *UserPortal {
return &UserPortal{}
}),
},
BackfillTask: &BackfillTaskQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*BackfillTask]) *BackfillTask {
return &BackfillTask{}
}),
},
KV: &KVQuery{
BridgeID: bridgeID,
Database: db,
},
PublicMedia: &PublicMediaQuery{
BridgeID: bridgeID,
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*PublicMedia]) *PublicMedia {
return &PublicMedia{}
}),
},
}
}
func ensureBridgeIDMatches(ptr *networkid.BridgeID, expected networkid.BridgeID) {
if *ptr == "" {
*ptr = expected
} else if *ptr != expected {
panic("bridge ID mismatch")
}
}

View file

@ -1,142 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"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
// Deprecated: use constants in event package
const (
DisappearingTypeNone = event.DisappearingTypeNone
DisappearingTypeAfterRead = event.DisappearingTypeAfterRead
DisappearingTypeAfterSend = event.DisappearingTypeAfterSend
)
// 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
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
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)
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
`
getUpcomingDisappearingMessagesQuery = `
SELECT bridge_id, mx_room, mxid, timestamp, 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
`
deleteDisappearingMessageQuery = `
DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2
`
)
func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMessage) error {
ensureBridgeIDMatches(&dm.BridgeID, dmq.BridgeID)
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) 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) Delete(ctx context.Context, eventID id.EventID) error {
return dmq.Exec(ctx, deleteDisappearingMessageQuery, dmq.BridgeID, eventID)
}
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)
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)}
}

View file

@ -1,177 +0,0 @@
// 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/.
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"
)
type GhostQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*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
Name string
AvatarID networkid.AvatarID
AvatarHash [32]byte
AvatarMXC id.ContentURIString
NameSet bool
AvatarSet bool
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
FROM ghost
`
getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2`
getGhostByMetadataQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND metadata->>$2=$3`
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
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`
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
WHERE bridge_id=$1 AND id=$2
`
)
func (gq *GhostQuery) GetByID(ctx context.Context, id networkid.UserID) (*Ghost, error) {
return gq.QueryOne(ctx, getGhostByIDQuery, gq.BridgeID, id)
}
// GetByMetadata returns the ghosts whose metadata field at the given JSON key
// matches the given value.
func (gq *GhostQuery) GetByMetadata(ctx context.Context, key string, value any) ([]*Ghost, error) {
return gq.QueryMany(ctx, getGhostByMetadataQuery, gq.BridgeID, key, value)
}
func (gq *GhostQuery) Insert(ctx context.Context, ghost *Ghost) error {
ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID)
return gq.Exec(ctx, insertGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...)
}
func (gq *GhostQuery) Update(ctx context.Context, ghost *Ghost) error {
ensureBridgeIDMatches(&ghost.BridgeID, gq.BridgeID)
return gq.Exec(ctx, updateGhostQuery, ghost.ensureHasMetadata(gq.MetaType).sqlVariables()...)
}
func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) {
var avatarHash string
err := row.Scan(
&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},
)
if err != nil {
return nil, err
}
if avatarHash != "" {
data, _ := hex.DecodeString(avatarHash)
if len(data) == 32 {
g.AvatarHash = *(*[32]byte)(data)
}
}
return g, nil
}
func (g *Ghost) ensureHasMetadata(metaType MetaTypeCreator) *Ghost {
if g.Metadata == nil {
g.Metadata = metaType()
}
return g
}
func (g *Ghost) sqlVariables() []any {
var avatarHash string
if g.AvatarHash != [32]byte{} {
avatarHash = hex.EncodeToString(g.AvatarHash[:])
}
return []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},
}
}

View file

@ -1,59 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"errors"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type Key string
const (
KeySplitPortalsEnabled Key = "split_portals_enabled"
KeyBridgeInfoVersion Key = "bridge_info_version"
KeyEncryptionStateResynced Key = "encryption_state_resynced"
KeyRecoveryKey Key = "recovery_key"
)
type KVQuery struct {
BridgeID networkid.BridgeID
*dbutil.Database
}
const (
getKVQuery = `SELECT value FROM kv_store WHERE bridge_id = $1 AND key = $2`
setKVQuery = `
INSERT INTO kv_store (bridge_id, key, value) VALUES ($1, $2, $3)
ON CONFLICT (bridge_id, key) DO UPDATE SET value = $3
`
)
func (kvq *KVQuery) Get(ctx context.Context, key Key) string {
var value string
err := kvq.QueryRow(ctx, getKVQuery, kvq.BridgeID, key).Scan(&value)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).Str("key", string(key)).Msg("Failed to get key from kvstore")
}
return value
}
func (kvq *KVQuery) Set(ctx context.Context, key Key, value string) {
_, err := kvq.Exec(ctx, setKVQuery, kvq.BridgeID, key, value)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Str("key", string(key)).
Str("value", value).
Msg("Failed to set key in kvstore")
}
}

View file

@ -1,334 +0,0 @@
// 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/.
package database
import (
"context"
"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"
"maunium.net/go/mautrix/id"
)
type MessageQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*dbutil.QueryHelper[*Message]
chunkDeleteLock sync.Mutex
}
type Message struct {
RowID int64
BridgeID networkid.BridgeID
ID networkid.MessageID
PartID networkid.PartID
MXID id.EventID
Room networkid.PortalKey
SenderID networkid.UserID
SenderMXID id.UserID
Timestamp time.Time
EditCount int
IsDoublePuppeted bool
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
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`
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`
countMessagesInPortalQuery = `
SELECT COUNT(*) FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3
`
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
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
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
`
deleteAllMessagePartsByIDQuery = `
DELETE FROM message WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3
`
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) {
return mq.QueryMany(ctx, getAllMessagePartsByIDQuery, mq.BridgeID, receiver, id)
}
func (mq *MessageQuery) GetPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID, partID networkid.PartID) (*Message, error) {
return mq.QueryOne(ctx, getMessagePartByIDQuery, mq.BridgeID, receiver, id, partID)
}
func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
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)
}
func (mq *MessageQuery) GetFirstPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getFirstMessagePartByIDQuery, mq.BridgeID, receiver, id)
}
func (mq *MessageQuery) GetByRowID(ctx context.Context, rowID int64) (*Message, error) {
return mq.QueryOne(ctx, getMessagePartByRowIDQuery, mq.BridgeID, rowID)
}
func (mq *MessageQuery) GetFirstOrSpecificPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageOptionalPartID) (*Message, error) {
if id.PartID == nil {
return mq.GetFirstPartByID(ctx, receiver, id.MessageID)
} else {
return mq.GetPartByID(ctx, receiver, id.MessageID, *id.PartID)
}
}
func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) {
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())
}
func (mq *MessageQuery) GetFirstPortalMessage(ctx context.Context, portal networkid.PortalKey) (*Message, error) {
return mq.QueryOne(ctx, getOldestMessageInPortal, mq.BridgeID, portal.ID, portal.Receiver)
}
func (mq *MessageQuery) GetFirstThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getFirstMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot)
}
func (mq *MessageQuery) GetLastThreadMessage(ctx context.Context, portal networkid.PortalKey, threadRoot networkid.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getLastMessageInThread, mq.BridgeID, portal.ID, portal.Receiver, threadRoot)
}
func (mq *MessageQuery) GetLastNInPortal(ctx context.Context, portal networkid.PortalKey, n int) ([]*Message, error) {
return mq.QueryMany(ctx, getLastNInPortal, mq.BridgeID, portal.ID, portal.Receiver, n)
}
func (mq *MessageQuery) Insert(ctx context.Context, msg *Message) error {
ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID)
return mq.GetDB().QueryRow(ctx, insertMessageQuery, msg.ensureHasMetadata(mq.MetaType).sqlVariables()...).Scan(&msg.RowID)
}
func (mq *MessageQuery) Update(ctx context.Context, msg *Message) error {
ensureBridgeIDMatches(&msg.BridgeID, mq.BridgeID)
return mq.Exec(ctx, updateMessageQuery, msg.ensureHasMetadata(mq.MetaType).updateSQLVariables()...)
}
func (mq *MessageQuery) DeleteAllParts(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) error {
return mq.Exec(ctx, deleteAllMessagePartsByIDQuery, mq.BridgeID, receiver, id)
}
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
}
func (m *Message) Scan(row dbutil.Scannable) (*Message, error) {
var timestamp int64
var threadRootID, replyToID, replyToPartID, sendTxnID sql.NullString
var doublePuppeted sql.NullBool
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},
)
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
}
func (m *Message) ensureHasMetadata(metaType MetaTypeCreator) *Message {
if m.Metadata == nil {
m.Metadata = metaType()
}
return m
}
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),
dbutil.JSON{Data: m.Metadata},
}
}
func (m *Message) updateSQLVariables() []any {
return append(m.sqlVariables(), m.RowID)
}
const FakeMXIDPrefix = "~fake:"
const TxnMXIDPrefix = "~txn:"
const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:"
const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:"
func (m *Message) SetFakeMXID() {
hash := sha256.Sum256([]byte(m.ID))
m.MXID = id.EventID(FakeMXIDPrefix + base64.RawURLEncoding.EncodeToString(hash[:]))
}
func (m *Message) HasFakeMXID() bool {
return strings.HasPrefix(m.MXID.String(), FakeMXIDPrefix)
}

View file

@ -1,296 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"encoding/hex"
"errors"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type RoomType string
const (
RoomTypeDefault RoomType = ""
RoomTypeDM RoomType = "dm"
RoomTypeGroupDM RoomType = "group_dm"
RoomTypeSpace RoomType = "space"
)
type PortalQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*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
}
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,
metadata
FROM portal
`
getPortalByKeyQuery = getPortalBaseQuery + `WHERE bridge_id=$1 AND id=$2 AND receiver=$3`
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`
findPortalReceiverQuery = `SELECT id, receiver FROM portal WHERE bridge_id=$1 AND id=$2 AND (receiver=$3 OR receiver='') LIMIT 1`
insertPortalQuery = `
INSERT INTO portal (
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,
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,
CASE WHEN cast($7 AS TEXT) IS NULL THEN NULL ELSE $1 END
)
`
updatePortalQuery = `
UPDATE portal
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
WHERE bridge_id=$1 AND id=$2 AND receiver=$3
`
deletePortalQuery = `
DELETE FROM portal
WHERE bridge_id=$1 AND id=$2 AND receiver=$3
`
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);
`
)
func (pq *PortalQuery) GetByKey(ctx context.Context, key networkid.PortalKey) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByKeyQuery, pq.BridgeID, key.ID, key.Receiver)
}
func (pq *PortalQuery) FindReceiver(ctx context.Context, id networkid.PortalID, maybeReceiver networkid.UserLoginID) (key networkid.PortalKey, err error) {
err = pq.GetDB().QueryRow(ctx, findPortalReceiverQuery, pq.BridgeID, id, maybeReceiver).Scan(&key.ID, &key.Receiver)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return
}
func (pq *PortalQuery) GetByIDWithUncertainReceiver(ctx context.Context, key networkid.PortalKey) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByIDWithUncertainReceiverQuery, pq.BridgeID, key.ID, key.Receiver)
}
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByMXIDQuery, pq.BridgeID, mxid)
}
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)
}
func (pq *PortalQuery) GetAllDMsWith(ctx context.Context, otherUserID networkid.UserID) ([]*Portal, error) {
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)
}
func (pq *PortalQuery) ReID(ctx context.Context, oldID, newID networkid.PortalKey) error {
return pq.Exec(ctx, reIDPortalQuery, pq.BridgeID, oldID.ID, oldID.Receiver, newID.ID, newID.Receiver)
}
func (pq *PortalQuery) Insert(ctx context.Context, p *Portal) error {
ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID)
return pq.Exec(ctx, insertPortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...)
}
func (pq *PortalQuery) Update(ctx context.Context, p *Portal) error {
ensureBridgeIDMatches(&p.BridgeID, pq.BridgeID)
return pq.Exec(ctx, updatePortalQuery, p.ensureHasMetadata(pq.MetaType).sqlVariables()...)
}
func (pq *PortalQuery) Delete(ctx context.Context, key networkid.PortalKey) error {
return pq.Exec(ctx, deletePortalQuery, pq.BridgeID, key.ID, key.Receiver)
}
func (pq *PortalQuery) MigrateToSplitPortals(ctx context.Context) (int64, error) {
res, err := pq.GetDB().Exec(ctx, migrateToSplitPortalsQuery, pq.BridgeID)
if err != nil {
return 0, err
}
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
var avatarHash string
err := row.Scan(
&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.RoomType, &disappearType, &disappearTimer,
dbutil.JSON{Data: &p.CapState}, dbutil.JSON{Data: p.Metadata},
)
if err != nil {
return nil, err
}
if avatarHash != "" {
data, _ := hex.DecodeString(avatarHash)
if len(data) == 32 {
p.AvatarHash = *(*[32]byte)(data)
}
}
if disappearType.Valid {
p.Disappear = DisappearingSetting{
Type: event.DisappearingType(disappearType.String),
Timer: time.Duration(disappearTimer.Int64),
}
}
p.MXID = id.RoomID(mxid.String)
p.OtherUserID = networkid.UserID(otherUserID.String)
if parentID.Valid {
p.ParentKey = networkid.PortalKey{
ID: networkid.PortalID(parentID.String),
Receiver: networkid.UserLoginID(parentReceiver.String),
}
}
p.RelayLoginID = networkid.UserLoginID(relayLoginID.String)
return p, nil
}
func (p *Portal) ensureHasMetadata(metaType MetaTypeCreator) *Portal {
if p.Metadata == nil {
p.Metadata = metaType()
}
return p
}
func (p *Portal) sqlVariables() []any {
var avatarHash string
if p.AvatarHash != [32]byte{} {
avatarHash = hex.EncodeToString(p.AvatarHash[:])
}
return []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.RoomType, dbutil.StrPtr(p.Disappear.Type), dbutil.NumPtr(p.Disappear.Timer),
dbutil.JSON{Data: p.CapState}, 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,120 +0,0 @@
// 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/.
package database
import (
"context"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
)
type ReactionQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*dbutil.QueryHelper[*Reaction]
}
type Reaction struct {
BridgeID networkid.BridgeID
Room networkid.PortalKey
MessageID networkid.MessageID
MessagePartID networkid.PartID
SenderID networkid.UserID
SenderMXID id.UserID
EmojiID networkid.EmojiID
MXID id.EventID
Timestamp time.Time
Emoji string
Metadata any
}
const (
getReactionBaseQuery = `
SELECT bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata FROM reaction
`
getReactionByIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6`
getReactionByIDWithoutMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 AND emoji_id=$5 ORDER BY message_part_id ASC LIMIT 1`
getAllReactionsToMessageBySenderQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND sender_id=$4 ORDER BY timestamp DESC`
getAllReactionsToMessageQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3`
getAllReactionsToMessagePartQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4`
getReactionByMXIDQuery = getReactionBaseQuery + `WHERE bridge_id=$1 AND mxid=$2`
upsertReactionQuery = `
INSERT INTO reaction (bridge_id, message_id, message_part_id, sender_id, sender_mxid, emoji_id, emoji, room_id, room_receiver, mxid, timestamp, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id)
DO UPDATE SET sender_mxid=excluded.sender_mxid, mxid=excluded.mxid, timestamp=excluded.timestamp, emoji=excluded.emoji, metadata=excluded.metadata
`
deleteReactionQuery = `
DELETE FROM reaction WHERE bridge_id=$1 AND room_receiver=$2 AND message_id=$3 AND message_part_id=$4 AND sender_id=$5 AND emoji_id=$6
`
)
func (rq *ReactionQuery) GetByID(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, messagePartID networkid.PartID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByIDQuery, rq.BridgeID, receiver, messageID, messagePartID, senderID, emojiID)
}
func (rq *ReactionQuery) GetByIDWithoutMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID, emojiID networkid.EmojiID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByIDWithoutMessagePartQuery, rq.BridgeID, receiver, messageID, senderID, emojiID)
}
func (rq *ReactionQuery) GetAllToMessageBySender(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, senderID networkid.UserID) ([]*Reaction, error) {
return rq.QueryMany(ctx, getAllReactionsToMessageBySenderQuery, rq.BridgeID, receiver, messageID, senderID)
}
func (rq *ReactionQuery) GetAllToMessage(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID) ([]*Reaction, error) {
return rq.QueryMany(ctx, getAllReactionsToMessageQuery, rq.BridgeID, receiver, messageID)
}
func (rq *ReactionQuery) GetAllToMessagePart(ctx context.Context, receiver networkid.UserLoginID, messageID networkid.MessageID, partID networkid.PartID) ([]*Reaction, error) {
return rq.QueryMany(ctx, getAllReactionsToMessagePartQuery, rq.BridgeID, receiver, messageID, partID)
}
func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByMXIDQuery, rq.BridgeID, mxid)
}
func (rq *ReactionQuery) Upsert(ctx context.Context, reaction *Reaction) error {
ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID)
return rq.Exec(ctx, upsertReactionQuery, reaction.ensureHasMetadata(rq.MetaType).sqlVariables()...)
}
func (rq *ReactionQuery) Delete(ctx context.Context, reaction *Reaction) error {
ensureBridgeIDMatches(&reaction.BridgeID, rq.BridgeID)
return rq.Exec(ctx, deleteReactionQuery, reaction.BridgeID, reaction.Room.Receiver, reaction.MessageID, reaction.MessagePartID, reaction.SenderID, reaction.EmojiID)
}
func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) {
var timestamp int64
err := row.Scan(
&r.BridgeID, &r.MessageID, &r.MessagePartID, &r.SenderID, &r.SenderMXID, &r.EmojiID, &r.Emoji,
&r.Room.ID, &r.Room.Receiver, &r.MXID, &timestamp, dbutil.JSON{Data: r.Metadata},
)
if err != nil {
return nil, err
}
r.Timestamp = time.Unix(0, timestamp)
return r, nil
}
func (r *Reaction) ensureHasMetadata(metaType MetaTypeCreator) *Reaction {
if r.Metadata == nil {
r.Metadata = metaType()
}
return r
}
func (r *Reaction) sqlVariables() []any {
return []any{
r.BridgeID, r.MessageID, r.MessagePartID, r.SenderID, r.SenderMXID, r.EmojiID, r.Emoji,
r.Room.ID, r.Room.Receiver, r.MXID, r.Timestamp.UnixNano(), dbutil.JSON{Data: r.Metadata},
}
}

View file

@ -1,233 +0,0 @@
-- v0 -> v27 (compatible with v9+): Latest revision
CREATE TABLE "user" (
bridge_id TEXT NOT NULL,
mxid TEXT NOT NULL,
management_room TEXT,
access_token TEXT,
PRIMARY KEY (bridge_id, mxid)
);
CREATE TABLE user_login (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
id TEXT NOT NULL,
remote_name TEXT NOT NULL,
remote_profile jsonb,
space_room TEXT,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id),
CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid)
REFERENCES "user" (bridge_id, mxid)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE portal (
bridge_id TEXT NOT NULL,
id TEXT NOT NULL,
receiver TEXT NOT NULL,
mxid TEXT,
parent_id TEXT,
parent_receiver TEXT NOT NULL DEFAULT '',
relay_bridge_id TEXT,
relay_login_id TEXT,
other_user_id TEXT,
name TEXT NOT NULL,
topic TEXT NOT NULL,
avatar_id TEXT NOT NULL,
avatar_hash TEXT NOT NULL,
avatar_mxc TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar_set BOOLEAN NOT NULL,
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),
CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver)
-- Deletes aren't allowed to cascade here:
-- children should be re-parented or cleaned up manually
REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE,
CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id)
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,
id TEXT NOT NULL,
name TEXT NOT NULL,
avatar_id TEXT NOT NULL,
avatar_hash TEXT NOT NULL,
avatar_mxc TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar_set BOOLEAN NOT NULL,
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)
);
CREATE TABLE message (
-- Messages have an extra rowid to allow a single relates_to column with ON DELETE SET NULL
-- If the foreign key used (bridge_id, relates_to), then deleting the target column
-- would try to set bridge_id to null as well.
-- only: sqlite (line commented)
-- rowid INTEGER PRIMARY KEY,
-- only: postgres
rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
bridge_id TEXT NOT NULL,
id TEXT NOT NULL,
part_id TEXT NOT NULL,
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
room_receiver TEXT NOT NULL,
sender_id TEXT NOT NULL,
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)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id)
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)
);
CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver);
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
);
CREATE INDEX disappearing_message_portal_idx ON disappearing_message (bridge_id, mx_room);
CREATE TABLE reaction (
bridge_id TEXT NOT NULL,
message_id TEXT NOT NULL,
message_part_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
sender_mxid TEXT NOT NULL DEFAULT '',
emoji_id TEXT NOT NULL,
room_id TEXT NOT NULL,
room_receiver TEXT NOT NULL,
mxid TEXT NOT NULL,
timestamp BIGINT NOT NULL,
emoji TEXT NOT NULL,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id),
CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id)
REFERENCES message (bridge_id, room_receiver, id, part_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id)
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid)
);
CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver);
CREATE TABLE user_portal (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
login_id TEXT NOT NULL,
portal_id TEXT NOT NULL,
portal_receiver TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
preferred BOOLEAN NOT NULL,
last_read BIGINT,
PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver),
CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id)
REFERENCES user_login (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id);
CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver);
CREATE TABLE backfill_task (
bridge_id TEXT NOT NULL,
portal_id TEXT NOT NULL,
portal_receiver TEXT NOT NULL,
user_login_id TEXT NOT NULL,
batch_count INTEGER NOT NULL,
is_done BOOLEAN NOT NULL,
cursor TEXT,
oldest_message_id TEXT,
dispatched_at BIGINT,
completed_at BIGINT,
next_dispatch_min_ts BIGINT NOT NULL,
PRIMARY KEY (bridge_id, portal_id, portal_receiver),
CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE kv_store (
bridge_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
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,11 +0,0 @@
-- v2 (compatible with v1+): Add disappearing messages table
CREATE TABLE disappearing_message (
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)
);

View file

@ -1,13 +0,0 @@
-- v3 (compatible with v1+): Add relay column for portals (Postgres)
-- only: postgres
ALTER TABLE portal ADD COLUMN relay_bridge_id TEXT;
ALTER TABLE portal ADD COLUMN relay_login_id TEXT;
ALTER TABLE user_portal DROP CONSTRAINT user_portal_user_login_fkey;
ALTER TABLE user_login DROP CONSTRAINT user_login_pkey;
ALTER TABLE user_login ADD CONSTRAINT user_login_pkey PRIMARY KEY (bridge_id, id);
ALTER TABLE user_portal ADD CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id)
REFERENCES user_login (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE portal ADD CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id)
REFERENCES user_login (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,100 +0,0 @@
-- v4 (compatible with v1+): Add relay column for portals (SQLite)
-- transaction: off
-- only: sqlite
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE user_login_new (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
id TEXT NOT NULL,
space_room TEXT,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id),
CONSTRAINT user_login_user_fkey FOREIGN KEY (bridge_id, user_mxid)
REFERENCES "user" (bridge_id, mxid)
ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO user_login_new
SELECT bridge_id, user_mxid, id, space_room, metadata
FROM user_login;
DROP TABLE user_login;
ALTER TABLE user_login_new RENAME TO user_login;
CREATE TABLE user_portal_new (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
login_id TEXT NOT NULL,
portal_id TEXT NOT NULL,
portal_receiver TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
preferred BOOLEAN NOT NULL,
last_read BIGINT,
PRIMARY KEY (bridge_id, user_mxid, login_id, portal_id, portal_receiver),
CONSTRAINT user_portal_user_login_fkey FOREIGN KEY (bridge_id, login_id)
REFERENCES user_login (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT user_portal_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO user_portal_new
SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read
FROM user_portal;
DROP TABLE user_portal;
ALTER TABLE user_portal_new RENAME TO user_portal;
CREATE TABLE portal_new (
bridge_id TEXT NOT NULL,
id TEXT NOT NULL,
receiver TEXT NOT NULL,
mxid TEXT,
parent_id TEXT,
-- This is not accessed by the bridge, it's only used for the portal parent foreign key.
-- Parent groups are probably never DMs, so they don't need a receiver.
parent_receiver TEXT NOT NULL DEFAULT '',
relay_bridge_id TEXT,
relay_login_id TEXT,
name TEXT NOT NULL,
topic TEXT NOT NULL,
avatar_id TEXT NOT NULL,
avatar_hash TEXT NOT NULL,
avatar_mxc TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar_set BOOLEAN NOT NULL,
topic_set BOOLEAN NOT NULL,
in_space BOOLEAN NOT NULL,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id, receiver),
CONSTRAINT portal_parent_fkey FOREIGN KEY (bridge_id, parent_id, parent_receiver)
-- Deletes aren't allowed to cascade here:
-- children should be re-parented or cleaned up manually
REFERENCES portal (bridge_id, id, receiver) ON UPDATE CASCADE,
CONSTRAINT portal_relay_fkey FOREIGN KEY (relay_bridge_id, relay_login_id)
REFERENCES user_login (bridge_id, id)
ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO portal_new
SELECT bridge_id, id, receiver, mxid, parent_id, parent_receiver, NULL, NULL,
name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, in_space, metadata
FROM portal;
DROP TABLE portal;
ALTER TABLE portal_new RENAME TO portal;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View file

@ -1,10 +0,0 @@
-- v5 (compatible with v1+): Add room_receiver to message unique key (Postgres)
-- only: postgres
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE reaction DROP CONSTRAINT reaction_pkey1;
ALTER TABLE reaction ADD PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id);
ALTER TABLE message DROP CONSTRAINT message_real_pkey;
ALTER TABLE message ADD CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id);
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id)
REFERENCES message (bridge_id, room_receiver, id, part_id)
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,75 +0,0 @@
-- v6 (compatible with v1+): Add room_receiver to message unique key (SQLite)
-- transaction: off
-- only: sqlite
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
rowid INTEGER PRIMARY KEY,
bridge_id TEXT NOT NULL,
id TEXT NOT NULL,
part_id TEXT NOT NULL,
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
room_receiver TEXT NOT NULL,
sender_id TEXT NOT NULL,
timestamp BIGINT NOT NULL,
relates_to BIGINT,
metadata jsonb NOT NULL,
CONSTRAINT message_relation_fkey FOREIGN KEY (relates_to)
REFERENCES message (rowid) ON DELETE SET NULL,
CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id)
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id)
);
INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata)
SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, relates_to, metadata
FROM message;
DROP TABLE message;
ALTER TABLE message_new RENAME TO message;
CREATE TABLE reaction_new (
bridge_id TEXT NOT NULL,
message_id TEXT NOT NULL,
message_part_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
emoji_id TEXT NOT NULL,
room_id TEXT NOT NULL,
room_receiver TEXT NOT NULL,
mxid TEXT NOT NULL,
timestamp BIGINT NOT NULL,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, room_receiver, message_id, message_part_id, sender_id, emoji_id),
CONSTRAINT reaction_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT reaction_message_fkey FOREIGN KEY (bridge_id, room_receiver, message_id, message_part_id)
REFERENCES message (bridge_id, room_receiver, id, part_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT reaction_sender_fkey FOREIGN KEY (bridge_id, sender_id)
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO reaction_new
SELECT bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, metadata
FROM reaction;
DROP TABLE reaction;
ALTER TABLE reaction_new RENAME TO reaction;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View file

@ -1,4 +0,0 @@
-- v7: Add new relation columns to messages
ALTER TABLE message ADD COLUMN thread_root_id TEXT;
ALTER TABLE message ADD COLUMN reply_to_id TEXT;
ALTER TABLE message ADD COLUMN reply_to_part_id TEXT;

View file

@ -1,3 +0,0 @@
-- v8: Drop relates_to column in messages
-- transaction: off
ALTER TABLE message DROP COLUMN relates_to;

View file

@ -1,41 +0,0 @@
-- v8: Drop relates_to column in messages
-- transaction: off
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
rowid INTEGER PRIMARY KEY,
bridge_id TEXT NOT NULL,
id TEXT NOT NULL,
part_id TEXT NOT NULL,
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
room_receiver TEXT NOT NULL,
sender_id TEXT NOT NULL,
timestamp BIGINT NOT NULL,
thread_root_id TEXT,
reply_to_id TEXT,
reply_to_part_id TEXT,
metadata jsonb NOT NULL,
CONSTRAINT message_room_fkey FOREIGN KEY (bridge_id, room_id, room_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_sender_fkey FOREIGN KEY (bridge_id, sender_id)
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id)
);
INSERT INTO message_new (rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata)
SELECT rowid, bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, timestamp, metadata
FROM message;
DROP TABLE message;
ALTER TABLE message_new RENAME TO message;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View file

@ -1,45 +0,0 @@
-- v9: Move standard metadata to separate columns
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';
UPDATE message SET sender_mxid=COALESCE((metadata->>'sender_mxid'), '');
ALTER TABLE message ADD COLUMN edit_count INTEGER NOT NULL DEFAULT 0;
UPDATE message SET edit_count=COALESCE(CAST((metadata->>'edit_count') AS INTEGER), 0);
ALTER TABLE portal ADD COLUMN disappear_type TEXT;
UPDATE portal SET disappear_type=(metadata->>'disappear_type');
ALTER TABLE portal ADD COLUMN disappear_timer BIGINT;
-- only: postgres
UPDATE portal SET disappear_timer=(metadata->>'disappear_timer')::BIGINT;
-- only: sqlite
UPDATE portal SET disappear_timer=CAST(metadata->>'disappear_timer' AS INTEGER);
ALTER TABLE portal ADD COLUMN room_type TEXT NOT NULL DEFAULT '';
UPDATE portal SET room_type='dm' WHERE CAST(metadata->>'is_direct' AS BOOLEAN) IS true;
UPDATE portal SET room_type='space' WHERE CAST(metadata->>'is_space' AS BOOLEAN) IS true;
ALTER TABLE reaction ADD COLUMN emoji TEXT NOT NULL DEFAULT '';
UPDATE reaction SET emoji=COALESCE((metadata->>'emoji'), '');
ALTER TABLE user_login ADD COLUMN remote_name TEXT NOT NULL DEFAULT '';
UPDATE user_login SET remote_name=COALESCE((metadata->>'remote_name'), '');
ALTER TABLE ghost ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;
UPDATE ghost SET contact_info_set=COALESCE(CAST((metadata->>'contact_info_set') AS BOOLEAN), false);
ALTER TABLE ghost ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;
UPDATE ghost SET is_bot=COALESCE(CAST((metadata->>'is_bot') AS BOOLEAN), false);
ALTER TABLE ghost ADD COLUMN identifiers jsonb NOT NULL DEFAULT '[]';
UPDATE ghost SET identifiers=COALESCE((metadata->'identifiers'), '[]');
-- only: postgres until "end only"
ALTER TABLE message ALTER COLUMN sender_mxid DROP DEFAULT;
ALTER TABLE message ALTER COLUMN edit_count DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN room_type DROP DEFAULT;
ALTER TABLE reaction ALTER COLUMN emoji DROP DEFAULT;
ALTER TABLE user_login ALTER COLUMN remote_name DROP DEFAULT;
ALTER TABLE ghost ALTER COLUMN contact_info_set DROP DEFAULT;
ALTER TABLE ghost ALTER COLUMN is_bot DROP DEFAULT;
ALTER TABLE ghost ALTER COLUMN identifiers DROP DEFAULT;
-- end only postgres

View file

@ -1,4 +0,0 @@
-- v10 (compatible with v9+): Fix Signal portal revisions
UPDATE portal
SET metadata=jsonb_set(metadata, '{revision}', CAST((metadata->>'revision') AS jsonb))
WHERE jsonb_typeof(metadata->'revision')='string';

View file

@ -1,4 +0,0 @@
-- v10 (compatible with v9+): Fix Signal portal revisions
UPDATE portal
SET metadata=json_set(metadata, '$.revision', CAST(json_extract(metadata, '$.revision') AS INTEGER))
WHERE json_type(metadata, '$.revision')='text';

View file

@ -1,5 +0,0 @@
-- v11: Add indexes for some foreign keys
CREATE INDEX message_room_idx ON message (bridge_id, room_id, room_receiver);
CREATE INDEX reaction_room_idx ON reaction (bridge_id, room_id, room_receiver);
CREATE INDEX user_portal_portal_idx ON user_portal (bridge_id, portal_id, portal_receiver);
CREATE INDEX user_portal_login_idx ON user_portal (bridge_id, login_id);

View file

@ -1,2 +0,0 @@
-- v12 (compatible with v9+): Save other user ID in DM portals
ALTER TABLE portal ADD COLUMN other_user_id TEXT;

View file

@ -1,20 +0,0 @@
-- v13 (compatible with v9+): Add backfill queue
CREATE TABLE backfill_task (
bridge_id TEXT NOT NULL,
portal_id TEXT NOT NULL,
portal_receiver TEXT NOT NULL,
user_login_id TEXT NOT NULL,
batch_count INTEGER NOT NULL,
is_done BOOLEAN NOT NULL,
cursor TEXT,
oldest_message_id TEXT,
dispatched_at BIGINT,
completed_at BIGINT,
next_dispatch_min_ts BIGINT NOT NULL,
PRIMARY KEY (bridge_id, portal_id, portal_receiver),
CONSTRAINT backfill_queue_portal_fkey FOREIGN KEY (bridge_id, portal_id, portal_receiver)
REFERENCES portal (bridge_id, id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE
);

View file

@ -1,2 +0,0 @@
-- v14 (compatible with v9+): Save whether name is custom in portals
ALTER TABLE portal ADD COLUMN name_is_custom BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,2 +0,0 @@
-- v15 (compatible with v9+): Save sender MXID for reactions
ALTER TABLE reaction ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';

View file

@ -1,2 +0,0 @@
-- v16 (compatible with v9+): Save remote profile in user logins
ALTER TABLE user_login ADD COLUMN remote_profile jsonb;

View file

@ -1,8 +0,0 @@
-- v17 (compatible with v9+): Add unique constraint for message and reaction mxids
DELETE FROM message WHERE mxid IN (SELECT mxid FROM message GROUP BY mxid HAVING COUNT(*) > 1);
-- only: postgres for next 2 lines
ALTER TABLE message ADD CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid);
ALTER TABLE reaction ADD CONSTRAINT reaction_mxid_unique UNIQUE (bridge_id, mxid);
-- only: sqlite for next 2 lines
CREATE UNIQUE INDEX message_mxid_unique ON message (bridge_id, mxid);
CREATE UNIQUE INDEX reaction_mxid_unique ON reaction (bridge_id, mxid);

View file

@ -1,8 +0,0 @@
-- v18 (compatible with v9+): Add generic key-value store
CREATE TABLE kv_store (
bridge_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (bridge_id, key)
);

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

@ -1,22 +0,0 @@
// 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/.
package upgrades
import (
"embed"
"go.mau.fi/util/dbutil"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.RegisterFS(rawUpgrades)
}

View file

@ -1,74 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
)
type UserQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*User]
}
type User struct {
BridgeID networkid.BridgeID
MXID id.UserID
ManagementRoom id.RoomID
AccessToken string
}
const (
getUserBaseQuery = `
SELECT bridge_id, mxid, management_room, access_token FROM "user"
`
getUserByMXIDQuery = getUserBaseQuery + `WHERE bridge_id=$1 AND mxid=$2`
insertUserQuery = `
INSERT INTO "user" (bridge_id, mxid, management_room, access_token)
VALUES ($1, $2, $3, $4)
`
updateUserQuery = `
UPDATE "user" SET management_room=$3, access_token=$4
WHERE bridge_id=$1 AND mxid=$2
`
)
func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) {
return uq.QueryOne(ctx, getUserByMXIDQuery, uq.BridgeID, userID)
}
func (uq *UserQuery) Insert(ctx context.Context, user *User) error {
ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID)
return uq.Exec(ctx, insertUserQuery, user.sqlVariables()...)
}
func (uq *UserQuery) Update(ctx context.Context, user *User) error {
ensureBridgeIDMatches(&user.BridgeID, uq.BridgeID)
return uq.Exec(ctx, updateUserQuery, user.sqlVariables()...)
}
func (u *User) Scan(row dbutil.Scannable) (*User, error) {
var managementRoom, accessToken sql.NullString
err := row.Scan(&u.BridgeID, &u.MXID, &managementRoom, &accessToken)
if err != nil {
return nil, err
}
u.ManagementRoom = id.RoomID(managementRoom.String)
u.AccessToken = accessToken.String
return u, nil
}
func (u *User) sqlVariables() []any {
return []any{u.BridgeID, u.MXID, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.AccessToken)}
}

View file

@ -1,123 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
)
type UserLoginQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*dbutil.QueryHelper[*UserLogin]
}
type UserLogin struct {
BridgeID networkid.BridgeID
UserMXID id.UserID
ID networkid.UserLoginID
RemoteName string
RemoteProfile status.RemoteProfile
SpaceRoom id.RoomID
Metadata any
}
const (
getUserLoginBaseQuery = `
SELECT bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata FROM user_login
`
getLoginByIDQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND id=$2`
getAllUsersWithLoginsQuery = `SELECT DISTINCT user_mxid FROM user_login WHERE bridge_id=$1`
getAllLoginsForUserQuery = getUserLoginBaseQuery + `WHERE bridge_id=$1 AND user_mxid=$2`
getAllLoginsInPortalQuery = `
SELECT ul.bridge_id, ul.user_mxid, ul.id, ul.remote_name, ul.remote_profile, ul.space_room, ul.metadata FROM user_portal
LEFT JOIN user_login ul ON user_portal.bridge_id=ul.bridge_id AND user_portal.user_mxid=ul.user_mxid AND user_portal.login_id=ul.id
WHERE user_portal.bridge_id=$1 AND user_portal.portal_id=$2 AND user_portal.portal_receiver=$3
`
insertUserLoginQuery = `
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
updateUserLoginQuery = `
UPDATE user_login SET remote_name=$4, remote_profile=$5, space_room=$6, metadata=$7
WHERE bridge_id=$1 AND user_mxid=$2 AND id=$3
`
deleteUserLoginQuery = `
DELETE FROM user_login WHERE bridge_id=$1 AND id=$2
`
)
func (uq *UserLoginQuery) GetByID(ctx context.Context, id networkid.UserLoginID) (*UserLogin, error) {
return uq.QueryOne(ctx, getLoginByIDQuery, uq.BridgeID, id)
}
func (uq *UserLoginQuery) GetAllUserIDsWithLogins(ctx context.Context) ([]id.UserID, error) {
rows, err := uq.GetDB().Query(ctx, getAllUsersWithLoginsQuery, uq.BridgeID)
return dbutil.NewRowIterWithError(rows, dbutil.ScanSingleColumn[id.UserID], err).AsList()
}
func (uq *UserLoginQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserLogin, error) {
return uq.QueryMany(ctx, getAllLoginsInPortalQuery, uq.BridgeID, portal.ID, portal.Receiver)
}
func (uq *UserLoginQuery) GetAllForUser(ctx context.Context, userID id.UserID) ([]*UserLogin, error) {
return uq.QueryMany(ctx, getAllLoginsForUserQuery, uq.BridgeID, userID)
}
func (uq *UserLoginQuery) Insert(ctx context.Context, login *UserLogin) error {
ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID)
return uq.Exec(ctx, insertUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...)
}
func (uq *UserLoginQuery) Update(ctx context.Context, login *UserLogin) error {
ensureBridgeIDMatches(&login.BridgeID, uq.BridgeID)
return uq.Exec(ctx, updateUserLoginQuery, login.ensureHasMetadata(uq.MetaType).sqlVariables()...)
}
func (uq *UserLoginQuery) Delete(ctx context.Context, loginID networkid.UserLoginID) error {
return uq.Exec(ctx, deleteUserLoginQuery, uq.BridgeID, loginID)
}
func (u *UserLogin) Scan(row dbutil.Scannable) (*UserLogin, error) {
var spaceRoom sql.NullString
err := row.Scan(
&u.BridgeID,
&u.UserMXID,
&u.ID,
&u.RemoteName,
dbutil.JSON{Data: &u.RemoteProfile},
&spaceRoom,
dbutil.JSON{Data: u.Metadata},
)
if err != nil {
return nil, err
}
u.SpaceRoom = id.RoomID(spaceRoom.String)
return u, nil
}
func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin {
if u.Metadata == nil {
u.Metadata = metaType()
}
return u
}
func (u *UserLogin) sqlVariables() []any {
var remoteProfile dbutil.JSON
if !u.RemoteProfile.IsZero() {
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

@ -1,155 +0,0 @@
// 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/.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
)
type UserPortalQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*UserPortal]
}
type UserPortal struct {
BridgeID networkid.BridgeID
UserMXID id.UserID
LoginID networkid.UserLoginID
Portal networkid.PortalKey
InSpace *bool
Preferred *bool
LastRead time.Time
}
const (
getUserPortalBaseQuery = `
SELECT bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read
FROM user_portal
`
getUserPortalQuery = getUserPortalBaseQuery + `
WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3 AND portal_id=$4 AND portal_receiver=$5
`
findUserLoginsOfUserByPortalIDQuery = getUserPortalBaseQuery + `
WHERE bridge_id=$1 AND user_mxid=$2 AND portal_id=$3 AND portal_receiver=$4
ORDER BY CASE WHEN preferred THEN 0 ELSE 1 END, login_id
`
getAllUserLoginsInPortalQuery = getUserPortalBaseQuery + `
WHERE bridge_id=$1 AND portal_id=$2 AND portal_receiver=$3
`
getAllPortalsForLoginQuery = getUserPortalBaseQuery + `
WHERE bridge_id=$1 AND user_mxid=$2 AND login_id=$3
`
getOrCreateUserPortalQuery = `
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
VALUES ($1, $2, $3, $4, $5, false, false)
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE SET portal_id=user_portal.portal_id
RETURNING bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read
`
upsertUserPortalQuery = `
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), COALESCE($7, false), $8)
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO UPDATE
SET in_space=COALESCE($6, user_portal.in_space),
preferred=COALESCE($7, user_portal.preferred),
last_read=COALESCE($8, user_portal.last_read)
`
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
`
)
func UserPortalFor(ul *UserLogin, portal networkid.PortalKey) *UserPortal {
return &UserPortal{
BridgeID: ul.BridgeID,
UserMXID: ul.UserMXID,
LoginID: ul.ID,
Portal: portal,
}
}
func (upq *UserPortalQuery) GetAllForUserInPortal(ctx context.Context, userID id.UserID, portal networkid.PortalKey) ([]*UserPortal, error) {
return upq.QueryMany(ctx, findUserLoginsOfUserByPortalIDQuery, upq.BridgeID, userID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) GetAllForLogin(ctx context.Context, login *UserLogin) ([]*UserPortal, error) {
return upq.QueryMany(ctx, getAllPortalsForLoginQuery, upq.BridgeID, login.UserMXID, login.ID)
}
func (upq *UserPortalQuery) GetAllInPortal(ctx context.Context, portal networkid.PortalKey) ([]*UserPortal, error) {
return upq.QueryMany(ctx, getAllUserLoginsInPortalQuery, upq.BridgeID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) Get(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) {
return upq.QueryOne(ctx, getUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) GetOrCreate(ctx context.Context, login *UserLogin, portal networkid.PortalKey) (*UserPortal, error) {
return upq.QueryOne(ctx, getOrCreateUserPortalQuery, upq.BridgeID, login.UserMXID, login.ID, portal.ID, portal.Receiver)
}
func (upq *UserPortalQuery) Put(ctx context.Context, up *UserPortal) error {
ensureBridgeIDMatches(&up.BridgeID, upq.BridgeID)
return upq.Exec(ctx, upsertUserPortalQuery, up.sqlVariables()...)
}
func (upq *UserPortalQuery) MarkAsPreferred(ctx context.Context, login *UserLogin, portal networkid.PortalKey) error {
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)
}
func (up *UserPortal) Scan(row dbutil.Scannable) (*UserPortal, error) {
var lastRead sql.NullInt64
err := row.Scan(
&up.BridgeID, &up.UserMXID, &up.LoginID, &up.Portal.ID, &up.Portal.Receiver,
&up.InSpace, &up.Preferred, &lastRead,
)
if err != nil {
return nil, err
}
if lastRead.Valid {
up.LastRead = time.Unix(0, lastRead.Int64)
}
return up, nil
}
func (up *UserPortal) sqlVariables() []any {
return []any{
up.BridgeID, up.UserMXID, up.LoginID, up.Portal.ID, up.Portal.Receiver,
up.InSpace,
up.Preferred,
dbutil.ConvertedPtr(up.LastRead, time.Time.UnixNano),
}
}
func (up *UserPortal) CopyWithoutValues() *UserPortal {
return &UserPortal{
BridgeID: up.BridgeID,
UserMXID: up.UserMXID,
LoginID: up.LoginID,
Portal: up.Portal,
}
}

View file

@ -1,153 +0,0 @@
// 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/.
package bridgev2
import (
"context"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type DisappearLoop struct {
br *Bridge
nextCheck atomic.Pointer[time.Time]
stop atomic.Pointer[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)()
}
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)
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 <-ctx.Done():
log.Debug().Msg("Disappearing message loop stopping")
return
}
}
}
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)()
}
}
func (dl *DisappearLoop) StartAllBefore(ctx context.Context, roomID id.RoomID, beforeTS time.Time) {
startedMessages, err := dl.br.DB.DisappearingMessage.StartAllBefore(ctx, roomID, beforeTS)
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())
})
slices.SortFunc(startedMessages, func(a, b *database.DisappearingMessage) int {
return a.DisappearAt.Compare(b.DisappearAt)
})
if len(startedMessages) > 0 {
go dl.sleepAndDisappear(ctx, startedMessages...)
}
}
func (dl *DisappearLoop) Add(ctx context.Context, dm *database.DisappearingMessage) {
err := dl.br.DB.DisappearingMessage.Put(ctx, dm)
if err != nil {
zerolog.Ctx(ctx).Err(err).
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)
}
}
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
}
}
resp, err := dl.br.Bot.SendMessage(ctx, msg.RoomID, event.EventRedaction, &event.Content{
Parsed: &event.RedactionEventContent{
Redacts: msg.EventID,
Reason: "Message disappeared",
},
}, nil)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", msg.EventID).Msg("Failed to disappear message")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("target_event_id", msg.EventID).
Stringer("redaction_event_id", resp.EventID).
Msg("Disappeared message")
}
err = dl.br.DB.DisappearingMessage.Delete(ctx, msg.EventID)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("event_id", msg.EventID).
Msg("Failed to delete disappearing message entry from database")
}
}
}

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