Compare commits

..

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

448 changed files with 10525 additions and 59095 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,16 @@ 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@v3
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v3
with:
go-version: "1.26"
go-version: "1.21"
cache: true
- name: Install libolm
@ -24,25 +20,25 @@ 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.20", "1.21"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
cache: true
@ -60,30 +56,3 @@ 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)
steps:
- uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Build
run: |
rm -rf crypto/libolm
go build -tags=goolm -v ./...

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,21 +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:
- "-local"
- "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
hooks:
- id: prevent-literal-http-methods
- id: zerolog-ban-global-log
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View file

@ -1,701 +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
maulogger. Only zerolog loggers are now provided by default.
* *(bridge)* Fixed upload size limit not having a default if the server
returned no value.
* *(synapseadmin)* Added wrappers for some room and user admin APIs.
(thanks to [@grvn-ht] in [#181]).
* *(crypto/verificationhelper)* Fixed bugs.
* *(crypto)* Fixed key backup uploading doing too much base64.
* *(crypto)* Changed `EncryptMegolmEvent` to return an error if persisting the
megolm session fails. This ensures that database errors won't cause messages
to be sent with duplicate indexes.
* *(crypto)* Changed `GetOrRequestSecret` to use a callback instead of returning
the value directly. This allows validating the value in order to ignore
invalid secrets.
* *(id)* Added `ParseCommonIdentifier` function to parse any Matrix identifier
in the [Common Identifier Format].
* *(federation)* Added simple key server that passes the federation tester.
[@grvn-ht]: https://github.com/grvn-ht
[#181]: https://github.com/mautrix/go/pull/181
[Common Identifier Format]: https://spec.matrix.org/v1.9/appendices/#common-identifier-format
### beta.1 (2024-02-16)
* Bumped minimum Go version to 1.21.
* *(bridge)* Bumped minimum Matrix spec version to v1.4.
* **Breaking change *(crypto)*** Deleted old half-broken interactive
verification code and replaced it with a new `verificationhelper`.
* The new verification helper is still experimental.
* Both QR and emoji verification are supported (in theory).
* *(crypto)* Added support for server-side key backup.
* *(crypto)* Added support for receiving and sending secrets like cross-signing
private keys via secret sharing.
* *(crypto)* Added support for tracking which devices megolm sessions were
initially shared to, and allowing re-sharing the keys to those sessions.
* *(client)* Changed cross-signing key upload method to accept a callback for
user-interactive auth instead of only hardcoding password support.
* *(appservice)* Dropped support for legacy non-prefixed appservice paths
(e.g. `/transactions` instead of `/_matrix/app/v1/transactions`).
* *(appservice)* Dropped support for legacy `access_token` authorization in
appservice endpoints.
* *(bridge)* Fixed `RawArgs` field in command events of command state callbacks.
* *(appservice)* Added `CreateFull` helper function for creating an `AppService`
instance with all the mandatory fields set.
## v0.17.0 (2024-01-16)
* **Breaking change *(bridge)*** Added raw event to portal membership handling
functions.
* **Breaking change *(everything)*** Added context parameters to all functions
(started by [@recht] in [#144]).
* **Breaking change *(client)*** Moved event source from sync event handler
function parameters to the `Mautrix.EventSource` field inside the event
struct.
* **Breaking change *(client)*** Moved `EventSource` to `event.Source`.
* *(client)* Removed deprecated `OldEventIgnorer`. The non-deprecated version
(`Client.DontProcessOldEvents`) is still available.
* *(crypto)* Added experimental pure Go Olm implementation to replace libolm
(thanks to [@DerLukas15] in [#106]).
* You can use the `goolm` build tag to the new implementation.
* *(bridge)* Added context parameter for bridge command events.
* *(bridge)* Added method to allow custom validation for the entire config.
* *(client)* Changed default syncer to not drop unknown events.
* The syncer will still drop known events if parsing the content fails.
* The behavior can be changed by changing the `ParseErrorHandler` function.
* *(crypto)* Fixed some places using math/rand instead of crypto/rand.
[@DerLukas15]: https://github.com/DerLukas15
[@recht]: https://github.com/recht
[#106]: https://github.com/mautrix/go/pull/106
[#144]: https://github.com/mautrix/go/pull/144
## v0.16.2 (2023-11-16)
* *(event)* Added `Redacts` field to `RedactionEventContent` for room v11+.
* *(event)* Added `ReverseTextToHTML` which reverses the changes made by
`TextToHTML` (i.e. unescapes HTML characters and replaces `<br/>` with `\n`).
* *(bridge)* Added global zerologger to ensure all logs go through the bridge
logger.
* *(bridge)* Changed encryption error messages to be sent in a thread if the
message that failed to decrypt was in a thread.
## v0.16.1 (2023-09-16)
* **Breaking change *(id)*** Updated user ID localpart encoding to not encode
`+` as per [MSC4009].
* *(bridge)* Added bridge utility to handle double puppeting logins.
* The utility supports automatic logins with all three current methods
(shared secret, legacy appservice, new appservice).
* *(appservice)* Added warning logs and timeout on appservice event handling.
* Defaults to warning after 30 seconds and timeout 15 minutes after that.
* Timeouts can be adjusted or disabled by setting `ExecSync` variables in the
`EventProcessor`.
* *(crypto/olm)* Added `PkDecryption` wrapper.
[MSC4009]: https://github.com/matrix-org/matrix-spec-proposals/pull/4009
## v0.16.0 (2023-08-16)
* Bumped minimum Go version to 1.20.

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,12 @@ 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
This project contains modules that are licensed under Apache 2.0:
* [maunium.net/go/mautrix/crypto/canonicaljson](crypto/canonicaljson)
* [maunium.net/go/mautrix/crypto/olm](crypto/olm)

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,10 +19,12 @@ 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"
"maunium.net/go/maulogger/v2/maulogadapt"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@ -31,9 +33,9 @@ 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.
// Create a blank appservice instance.
func Create() *AppService {
jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
as := &AppService{
@ -42,7 +44,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,
@ -54,72 +56,31 @@ func Create() *AppService {
OTKCounts: make(chan *mautrix.OTKCount, OTKChannelSize),
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("/transactions/{txnID}", as.PutTransaction).Methods(http.MethodPut)
as.Router.HandleFunc("/rooms/{roomAlias}", as.GetRoom).Methods(http.MethodGet)
as.Router.HandleFunc("/users/{userID}", as.GetUser).Methods(http.MethodGet)
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/app/unstable/fi.mau.msc2659/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
}
// CreateOpts contains the options for initializing a new [AppService] instance.
type CreateOpts struct {
// Required, the registration file data for this appservice.
Registration *Registration
// Required, the homeserver's server_name.
HomeserverDomain string
// Required, the homeserver URL to connect to. Should be either https://address or unix:path
HomeserverURL string
// Required if you want to use the standard HTTP server, optional for websockets (non-standard)
HostConfig HostConfig
// Optional, defaults to a memory state store
StateStore StateStore
}
// CreateFull creates a fully configured appservice instance that can be [Start]ed and used directly.
func CreateFull(opts CreateOpts) (*AppService, error) {
if opts.HomeserverDomain == "" {
return nil, fmt.Errorf("missing homeserver domain")
} else if opts.HomeserverURL == "" {
return nil, fmt.Errorf("missing homeserver URL")
} else if opts.Registration == nil {
return nil, fmt.Errorf("missing registration")
}
as := Create()
as.HomeserverDomain = opts.HomeserverDomain
as.Host = opts.HostConfig
as.Registration = opts.Registration
err := as.SetHomeserverURL(opts.HomeserverURL)
if err != nil {
return nil, err
}
if opts.StateStore != nil {
as.StateStore = opts.StateStore
} else {
as.StateStore = mautrix.NewMemoryStateStore().(StateStore)
}
return as, nil
}
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,17 +88,17 @@ 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
IsRegistered(ctx context.Context, userID id.UserID) (bool, error)
MarkRegistered(ctx context.Context, userID id.UserID) error
IsRegistered(userID id.UserID) bool
MarkRegistered(userID id.UserID)
GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error)
GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error)
HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error)
GetPowerLevel(roomID id.RoomID, userID id.UserID) int
GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int
HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool
}
// AppService is the main config for all appservices.
@ -159,13 +120,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 +138,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 +155,6 @@ type AppService struct {
}
const DoublePuppetKey = "fi.mau.double_puppet_source"
const DoublePuppetTSKey = "fi.mau.double_puppet_ts"
func getDefaultProcessID() string {
pid := syscall.Getpid()
@ -218,10 +178,10 @@ func (as *AppService) PrepareWebsocket() {
// HostConfig contains info about how to host the appservice.
type HostConfig struct {
// Hostname can be an IP address or an absolute path for a unix socket.
Hostname string `yaml:"hostname"`
// Port is required when Hostname is an IP address, optional for unix sockets
Port uint16 `yaml:"port"`
Port uint16 `yaml:"port"`
TLSKey string `yaml:"tls_key,omitempty"`
TLSCert string `yaml:"tls_cert,omitempty"`
}
// Address gets the whole address of the Appservice.
@ -255,7 +215,6 @@ func (as *AppService) YAML() (string, error) {
return string(data), nil
}
// BotMXID returns the user ID corresponding to the appservice's sender_localpart
func (as *AppService) BotMXID() id.UserID {
return id.NewUserID(as.Registration.SenderLocalpart, as.HomeserverDomain)
}
@ -292,12 +251,6 @@ func (as *AppService) makeIntent(userID id.UserID) *IntentAPI {
return intent
}
// Intent returns an [IntentAPI] object for the given user ID.
//
// This will return nil if the given user ID has an empty localpart,
// or if the server name is not the same as the appservice's HomeserverDomain.
// It does not currently validate that the given user ID is actually in the
// appservice's namespace. Validation may be added later.
func (as *AppService) Intent(userID id.UserID) *IntentAPI {
as.intentsLock.RLock()
intent, ok := as.intents[userID]
@ -308,7 +261,6 @@ func (as *AppService) Intent(userID id.UserID) *IntentAPI {
return intent
}
// BotIntent returns an [IntentAPI] object for the appservice's sender_localpart user.
func (as *AppService) BotIntent() *IntentAPI {
if as.botIntent == nil {
as.botIntent = as.makeIntent(as.BotMXID())
@ -316,10 +268,6 @@ func (as *AppService) BotIntent() *IntentAPI {
return as.botIntent
}
// SetHomeserverURL updates the appservice's homeserver URL.
//
// Note that this does not affect already-created [IntentAPI] or [mautrix.Client] objects,
// so it should not be called after Intent or Client are called.
func (as *AppService) SetHomeserverURL(homeserverURL string) error {
parsedURL, err := url.Parse(homeserverURL)
if err != nil {
@ -334,7 +282,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}
@ -348,30 +296,22 @@ func (as *AppService) SetHomeserverURL(homeserverURL string) error {
return nil
}
// NewMautrixClient creates a new [mautrix.Client] instance for the given user ID.
//
// This does not do any validation, and it does not cache the client.
// Usually you should prefer [AppService.Client] or [AppService.Intent] over this method.
func (as *AppService) NewMautrixClient(userID id.UserID) *mautrix.Client {
return &mautrix.Client{
client := &mautrix.Client{
HomeserverURL: as.hsURLForClient,
UserID: userID,
SetAppServiceUserID: true,
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,
}
client.Logger = maulogadapt.ZeroAsMau(&client.Log)
return client
}
// NewExternalMautrixClient creates a new [mautrix.Client] instance for an external user,
// with a token and homeserver URL separate from the main appservice.
//
// This is primarily meant to facilitate double puppeting in bridges, and is used by [bridge.doublePuppetUtil].
// Non-bridge appservices will likely not need this.
func (as *AppService) NewExternalMautrixClient(userID id.UserID, token string, homeserverURL string) (*mautrix.Client, error) {
client := as.NewMautrixClient(userID)
client.AccessToken = token
@ -399,11 +339,6 @@ func (as *AppService) makeClient(userID id.UserID) *mautrix.Client {
return client
}
// Client returns the [mautrix.Client] instance for the given user ID.
//
// Unlike [AppService.Intent], this does not do any validation, and will always return a value.
// Usually you should prefer creating intents and using intent methods over direct client methods.
// You can always access the client inside an intent with [IntentAPI.Client].
func (as *AppService) Client(userID id.UserID) *mautrix.Client {
as.clientsLock.RLock()
client, ok := as.clients[userID]
@ -414,9 +349,6 @@ func (as *AppService) Client(userID id.UserID) *mautrix.Client {
return client
}
// BotClient returns the [mautrix.Client] instance for the appservice's sender_localpart user.
//
// Like with the generic Client method, [AppService.BotIntent] should be preferred over this.
func (as *AppService) BotClient() *mautrix.Client {
if as.botClient == nil {
as.botClient = as.makeClient(as.BotMXID())

View file

@ -1,7 +1,6 @@
package appservice
import (
"context"
"fmt"
"net"
"net/http"
@ -36,7 +35,7 @@ func TestClient_UnixSocket(t *testing.T) {
err = as.SetHomeserverURL(fmt.Sprintf("unix://%s", socket))
assert.NoError(t, err)
client := as.Client("user1")
resp, err := client.Whoami(context.Background())
resp, err := client.Whoami()
assert.NoError(t, err)
assert.Equal(t, "@joe:example.org", string(resp.UserID))
}

View file

@ -7,10 +7,8 @@
package appservice
import (
"context"
"encoding/json"
"runtime/debug"
"time"
"github.com/rs/zerolog"
@ -26,16 +24,13 @@ const (
Sync
)
type EventHandler = func(ctx context.Context, evt *event.Event)
type OTKHandler = func(ctx context.Context, otk *mautrix.OTKCount)
type DeviceListHandler = func(ctx context.Context, lists *mautrix.DeviceLists, since string)
type EventHandler = func(evt *event.Event)
type OTKHandler = func(otk *mautrix.OTKCount)
type DeviceListHandler = func(lists *mautrix.DeviceLists, since string)
type EventProcessor struct {
ExecMode ExecMode
ExecSyncWarnTime time.Duration
ExecSyncTimeout time.Duration
as *AppService
stop chan struct{}
handlers map[event.Type][]EventHandler
@ -51,9 +46,6 @@ func NewEventProcessor(as *AppService) *EventProcessor {
stop: make(chan struct{}, 1),
handlers: make(map[event.Type][]EventHandler),
ExecSyncWarnTime: 30 * time.Second,
ExecSyncTimeout: 15 * time.Minute,
otkHandlers: make([]OTKHandler, 0),
deviceListHandlers: make([]DeviceListHandler, 0),
}
@ -98,34 +90,34 @@ func (ep *EventProcessor) recoverFunc(data interface{}) {
}
}
func (ep *EventProcessor) callHandler(ctx context.Context, handler EventHandler, evt *event.Event) {
func (ep *EventProcessor) callHandler(handler EventHandler, evt *event.Event) {
defer ep.recoverFunc(evt)
handler(ctx, evt)
handler(evt)
}
func (ep *EventProcessor) callOTKHandler(ctx context.Context, handler OTKHandler, otk *mautrix.OTKCount) {
func (ep *EventProcessor) callOTKHandler(handler OTKHandler, otk *mautrix.OTKCount) {
defer ep.recoverFunc(otk)
handler(ctx, otk)
handler(otk)
}
func (ep *EventProcessor) callDeviceListHandler(ctx context.Context, handler DeviceListHandler, dl *mautrix.DeviceLists) {
func (ep *EventProcessor) callDeviceListHandler(handler DeviceListHandler, dl *mautrix.DeviceLists) {
defer ep.recoverFunc(dl)
handler(ctx, dl, "")
handler(dl, "")
}
func (ep *EventProcessor) DispatchOTK(ctx context.Context, otk *mautrix.OTKCount) {
func (ep *EventProcessor) DispatchOTK(otk *mautrix.OTKCount) {
for _, handler := range ep.otkHandlers {
go ep.callOTKHandler(ctx, handler, otk)
go ep.callOTKHandler(handler, otk)
}
}
func (ep *EventProcessor) DispatchDeviceList(ctx context.Context, dl *mautrix.DeviceLists) {
func (ep *EventProcessor) DispatchDeviceList(dl *mautrix.DeviceLists) {
for _, handler := range ep.deviceListHandlers {
go ep.callDeviceListHandler(ctx, handler, dl)
go ep.callDeviceListHandler(handler, dl)
}
}
func (ep *EventProcessor) Dispatch(ctx context.Context, evt *event.Event) {
func (ep *EventProcessor) Dispatch(evt *event.Event) {
handlers, ok := ep.handlers[evt.Type]
if !ok {
return
@ -133,75 +125,49 @@ func (ep *EventProcessor) Dispatch(ctx context.Context, evt *event.Event) {
switch ep.ExecMode {
case AsyncHandlers:
for _, handler := range handlers {
go ep.callHandler(ctx, handler, evt)
go ep.callHandler(handler, evt)
}
case AsyncLoop:
go func() {
for _, handler := range handlers {
ep.callHandler(ctx, handler, evt)
ep.callHandler(handler, evt)
}
}()
case Sync:
if ep.ExecSyncWarnTime == 0 && ep.ExecSyncTimeout == 0 {
for _, handler := range handlers {
ep.callHandler(ctx, handler, evt)
}
return
}
doneChan := make(chan struct{})
go func() {
for _, handler := range handlers {
ep.callHandler(ctx, handler, evt)
}
close(doneChan)
}()
select {
case <-doneChan:
return
case <-time.After(ep.ExecSyncWarnTime):
log := ep.as.Log.With().
Str("event_id", evt.ID.String()).
Str("event_type", evt.Type.String()).
Logger()
log.Warn().Msg("Handling event in appservice transaction channel is taking long")
select {
case <-doneChan:
return
case <-time.After(ep.ExecSyncTimeout):
log.Error().Msg("Giving up waiting for event handler")
}
for _, handler := range handlers {
ep.callHandler(handler, evt)
}
}
}
func (ep *EventProcessor) startEvents(ctx context.Context) {
func (ep *EventProcessor) startEvents() {
for {
select {
case evt := <-ep.as.Events:
ep.Dispatch(ctx, evt)
ep.Dispatch(evt)
case <-ep.stop:
return
}
}
}
func (ep *EventProcessor) startEncryption(ctx context.Context) {
func (ep *EventProcessor) startEncryption() {
for {
select {
case evt := <-ep.as.ToDeviceEvents:
ep.Dispatch(ctx, evt)
ep.Dispatch(evt)
case otk := <-ep.as.OTKCounts:
ep.DispatchOTK(ctx, otk)
ep.DispatchOTK(otk)
case dl := <-ep.as.DeviceLists:
ep.DispatchDeviceList(ctx, dl)
ep.DispatchDeviceList(dl)
case <-ep.stop:
return
}
}
}
func (ep *EventProcessor) Start(ctx context.Context) {
go ep.startEvents(ctx)
go ep.startEncryption(ctx)
func (ep *EventProcessor) Start() {
go ep.startEvents()
go ep.startEncryption()
}
func (ep *EventProcessor) Stop() {

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() {
@ -78,12 +82,27 @@ func (as *AppService) Stop() {
// CheckServerToken checks if the given request originated from the Matrix homeserver.
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)
if len(authHeader) > 0 && strings.HasPrefix(authHeader, "Bearer ") {
isValid = authHeader[len("Bearer "):] == as.Registration.ServerToken
} else {
isValid = true
queryToken := r.URL.Query().Get("access_token")
if len(queryToken) > 0 {
isValid = queryToken == as.Registration.ServerToken
} else {
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Missing access token",
}.Write(w)
return
}
}
if !isValid {
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Incorrect access token",
}.Write(w)
}
return
}
@ -94,24 +113,32 @@ 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()
// Don't use request context, handling shouldn't be stopped even if the request times out
ctx := context.Background()
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 +147,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 +217,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()).
@ -211,7 +235,7 @@ func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, def
}
if evt.Type.IsState() {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
mautrix.UpdateStateStore(as.StateStore, evt)
}
var ch chan *event.Event
if evt.Type.Class == event.ToDeviceEventType {
@ -238,12 +262,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 +281,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 +300,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 +312,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

@ -7,14 +7,9 @@
package appservice
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@ -28,8 +23,6 @@ type IntentAPI struct {
Localpart string
UserID id.UserID
registerLock sync.Mutex
IsCustomPuppet bool
}
@ -50,67 +43,49 @@ 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]{
func (intent *IntentAPI) Register() error {
_, _, err := intent.Client.Register(&mautrix.ReqRegister{
Username: intent.Localpart,
Type: mautrix.AuthTypeAppservice,
InhibitLogin: true,
}, nil)
})
return err
}
func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error {
if intent.IsCustomPuppet {
return nil
}
intent.registerLock.Lock()
defer intent.registerLock.Unlock()
isRegistered, err := intent.as.StateStore.IsRegistered(ctx, intent.UserID)
if err != nil {
return fmt.Errorf("failed to check if user is registered: %w", err)
} else if isRegistered {
func (intent *IntentAPI) EnsureRegistered() error {
if intent.IsCustomPuppet || intent.as.StateStore.IsRegistered(intent.UserID) {
return nil
}
err = intent.Register(ctx)
err := intent.Register()
if err != nil && !errors.Is(err, mautrix.MUserInUse) {
return fmt.Errorf("failed to ensure registered: %w", err)
}
err = intent.as.StateStore.MarkRegistered(ctx, intent.UserID)
if err != nil {
return fmt.Errorf("failed to mark user as registered in state store: %w", err)
}
intent.as.StateStore.MarkRegistered(intent.UserID)
return nil
}
type EnsureJoinedParams struct {
IgnoreCache bool
BotOverride *mautrix.Client
Via []string
}
func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error {
func (intent *IntentAPI) EnsureJoined(roomID id.RoomID, extra ...EnsureJoinedParams) error {
var params EnsureJoinedParams
if len(extra) > 1 {
panic("invalid number of extra parameters")
} else if len(extra) == 1 {
params = extra[0]
}
if intent.as.StateStore.IsInRoom(ctx, roomID, intent.UserID) && !params.IgnoreCache {
if intent.as.StateStore.IsInRoom(roomID, intent.UserID) && !params.IgnoreCache {
return nil
}
err := intent.EnsureRegistered(ctx)
if err != nil {
if err := intent.EnsureRegistered(); 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(roomID)
if err != nil {
bot := intent.bot
if params.BotOverride != nil {
@ -119,170 +94,119 @@ 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(roomID, &mautrix.ReqInviteUser{
UserID: intent.UserID,
})
if inviteErr != nil {
return fmt.Errorf("failed to invite in ensure joined: %w", inviteErr)
}
resp, err = intent.JoinRoomByID(ctx, roomID)
resp, err = intent.JoinRoomByID(roomID)
if err != nil {
return fmt.Errorf("failed to ensure joined after invite: %w", err)
}
}
err = intent.as.StateStore.SetMembership(ctx, resp.RoomID, intent.UserID, event.MembershipJoin)
if err != nil {
return fmt.Errorf("failed to set membership in state store: %w", err)
}
intent.as.StateStore.SetMembership(resp.RoomID, intent.UserID, event.MembershipJoin)
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) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) SendMessageEvent(roomID id.RoomID, eventType event.Type, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...)
return intent.Client.SendMessageEvent(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 {
func (intent *IntentAPI) SendMassagedMessageEvent(roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(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...)
return intent.Client.SendMessageEvent(roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
}
// 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})
}
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(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 {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
} else if err := intent.EnsureRegistered(ctx); err != nil {
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(roomID, eventType, stateKey, contentJSON)
}
func (intent *IntentAPI) SendMassagedStateEvent(roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...)
return intent.Client.SendMassagedStateEvent(roomID, eventType, stateKey, contentJSON, ts)
}
// 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})
}
func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) StateEvent(roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error {
if err := intent.EnsureJoined(roomID); err != nil {
return err
}
return intent.Client.StateEvent(ctx, roomID, eventType, stateKey, outContent)
return intent.Client.StateEvent(roomID, eventType, stateKey, outContent)
}
func (intent *IntentAPI) State(ctx context.Context, roomID id.RoomID) (mautrix.RoomStateMap, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) State(roomID id.RoomID) (mautrix.RoomStateMap, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.State(ctx, roomID)
return intent.Client.State(roomID)
}
func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID id.RoomID, target id.UserID, membership event.Membership, reason string, extraContent ...map[string]interface{}) (*mautrix.RespSendEvent, error) {
func (intent *IntentAPI) SendCustomMembershipEvent(roomID id.RoomID, target id.UserID, membership event.Membership, reason string, extraContent ...map[string]interface{}) (*mautrix.RespSendEvent, error) {
content := &event.MemberEventContent{
Membership: membership,
Reason: reason,
}
memberContent, err := intent.as.StateStore.TryGetMember(ctx, roomID, target)
if err != nil {
return nil, fmt.Errorf("failed to get old member content from state store: %w", err)
} else if memberContent == nil {
memberContent, ok := intent.as.StateStore.TryGetMember(roomID, target)
if !ok {
if intent.as.GetProfile != nil {
memberContent = intent.as.GetProfile(target, roomID)
ok = memberContent != nil
}
if memberContent == nil {
profile, err := intent.GetProfile(ctx, target)
if !ok {
profile, err := intent.GetProfile(target)
if err != nil {
intent.Log.Debug().Err(err).
Str("target_user_id", target.String()).
@ -294,7 +218,7 @@ func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID i
}
}
}
if memberContent != nil {
if ok && memberContent != nil {
content.Displayname = memberContent.Displayname
content.AvatarURL = memberContent.AvatarURL
}
@ -302,21 +226,21 @@ func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID i
if len(extraContent) > 0 {
extra = extraContent[0]
}
return intent.SendStateEvent(ctx, roomID, event.StateMember, target.String(), &event.Content{
return intent.SendStateEvent(roomID, event.StateMember, target.String(), &event.Content{
Parsed: content,
Raw: extra,
})
}
func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) {
func (intent *IntentAPI) JoinRoomByID(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
_, err = intent.SendCustomMembershipEvent(roomID, intent.UserID, event.MembershipJoin, "", extraContent...)
return &mautrix.RespJoinRoom{}, err
}
return intent.Client.JoinRoomByID(ctx, roomID)
return intent.Client.JoinRoomByID(roomID)
}
func (intent *IntentAPI) LeaveRoom(ctx context.Context, roomID id.RoomID, extra ...interface{}) (resp *mautrix.RespLeaveRoom, err error) {
func (intent *IntentAPI) LeaveRoom(roomID id.RoomID, extra ...interface{}) (resp *mautrix.RespLeaveRoom, err error) {
var extraContent map[string]interface{}
leaveReq := &mautrix.ReqLeave{}
for _, item := range extra {
@ -328,127 +252,94 @@ func (intent *IntentAPI) LeaveRoom(ctx context.Context, roomID id.RoomID, extra
}
}
if intent.IsCustomPuppet || extraContent != nil {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipLeave, leaveReq.Reason, extraContent)
_, err = intent.SendCustomMembershipEvent(roomID, intent.UserID, event.MembershipLeave, leaveReq.Reason, extraContent)
return &mautrix.RespLeaveRoom{}, err
}
return intent.Client.LeaveRoom(ctx, roomID, leaveReq)
return intent.Client.LeaveRoom(roomID, leaveReq)
}
func (intent *IntentAPI) InviteUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqInviteUser, extraContent ...map[string]interface{}) (resp *mautrix.RespInviteUser, err error) {
func (intent *IntentAPI) InviteUser(roomID id.RoomID, req *mautrix.ReqInviteUser, extraContent ...map[string]interface{}) (resp *mautrix.RespInviteUser, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipInvite, req.Reason, extraContent...)
_, err = intent.SendCustomMembershipEvent(roomID, req.UserID, event.MembershipInvite, req.Reason, extraContent...)
return &mautrix.RespInviteUser{}, err
}
return intent.Client.InviteUser(ctx, roomID, req)
return intent.Client.InviteUser(roomID, req)
}
func (intent *IntentAPI) KickUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqKickUser, extraContent ...map[string]interface{}) (resp *mautrix.RespKickUser, err error) {
func (intent *IntentAPI) KickUser(roomID id.RoomID, req *mautrix.ReqKickUser, extraContent ...map[string]interface{}) (resp *mautrix.RespKickUser, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...)
_, err = intent.SendCustomMembershipEvent(roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...)
return &mautrix.RespKickUser{}, err
}
return intent.Client.KickUser(ctx, roomID, req)
return intent.Client.KickUser(roomID, req)
}
func (intent *IntentAPI) BanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqBanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespBanUser, err error) {
func (intent *IntentAPI) BanUser(roomID id.RoomID, req *mautrix.ReqBanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespBanUser, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipBan, req.Reason, extraContent...)
_, err = intent.SendCustomMembershipEvent(roomID, req.UserID, event.MembershipBan, req.Reason, extraContent...)
return &mautrix.RespBanUser{}, err
}
return intent.Client.BanUser(ctx, roomID, req)
return intent.Client.BanUser(roomID, req)
}
func (intent *IntentAPI) UnbanUser(ctx context.Context, roomID id.RoomID, req *mautrix.ReqUnbanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespUnbanUser, err error) {
func (intent *IntentAPI) UnbanUser(roomID id.RoomID, req *mautrix.ReqUnbanUser, extraContent ...map[string]interface{}) (resp *mautrix.RespUnbanUser, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...)
_, err = intent.SendCustomMembershipEvent(roomID, req.UserID, event.MembershipLeave, req.Reason, extraContent...)
return &mautrix.RespUnbanUser{}, err
}
return intent.Client.UnbanUser(ctx, roomID, req)
return intent.Client.UnbanUser(roomID, req)
}
func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
member, err := intent.as.StateStore.TryGetMember(ctx, roomID, userID)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Str("room_id", roomID.String()).
Str("user_id", userID.String()).
Msg("Failed to get member from state store")
}
if member == nil {
_ = intent.StateEvent(ctx, roomID, event.StateMember, string(userID), &member)
func (intent *IntentAPI) Member(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
member, ok := intent.as.StateStore.TryGetMember(roomID, userID)
if !ok {
_ = intent.StateEvent(roomID, event.StateMember, string(userID), &member)
}
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 {
err = fmt.Errorf("failed to get cached power levels: %w", err)
return
}
func (intent *IntentAPI) PowerLevels(roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) {
pl = intent.as.StateStore.GetPowerLevels(roomID)
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, "")
err = intent.StateEvent(roomID, event.StatePowerLevels, "", pl)
}
return
}
func (intent *IntentAPI) SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) (resp *mautrix.RespSendEvent, err error) {
return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &levels)
func (intent *IntentAPI) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) (resp *mautrix.RespSendEvent, err error) {
return intent.SendStateEvent(roomID, event.StatePowerLevels, "", &levels)
}
func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, level int) (*mautrix.RespSendEvent, error) {
pl, err := intent.PowerLevels(ctx, roomID)
func (intent *IntentAPI) SetPowerLevel(roomID id.RoomID, userID id.UserID, level int) (*mautrix.RespSendEvent, error) {
pl, err := intent.PowerLevels(roomID)
if err != nil {
return nil, err
}
if pl.EnsureUserLevelAs(intent.UserID, userID, level) {
return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl)
if pl.GetUserLevel(userID) != level {
pl.SetUserLevel(userID, level)
return intent.SendStateEvent(roomID, event.StatePowerLevels, "", &pl)
}
return nil, nil
}
func (intent *IntentAPI) SendText(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) SendText(roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendText(ctx, roomID, text)
return intent.Client.SendText(roomID, text)
}
func (intent *IntentAPI) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) SendNotice(roomID id.RoomID, text string) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
return intent.Client.SendNotice(ctx, roomID, text)
return intent.Client.SendNotice(roomID, text)
}
func (intent *IntentAPI) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...mautrix.ReqRedact) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
func (intent *IntentAPI) RedactEvent(roomID id.RoomID, eventID id.EventID, extra ...mautrix.ReqRedact) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(roomID); err != nil {
return nil, err
}
var req mautrix.ReqRedact
@ -456,86 +347,65 @@ func (intent *IntentAPI) RedactEvent(ctx context.Context, roomID id.RoomID, even
req = extra[0]
}
intent.AddDoublePuppetValue(&req.Extra)
return intent.Client.RedactEvent(ctx, roomID, eventID, req)
return intent.Client.RedactEvent(roomID, eventID, req)
}
func (intent *IntentAPI) SetRoomName(ctx context.Context, roomID id.RoomID, roomName string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(ctx, roomID, event.StateRoomName, "", map[string]interface{}{
func (intent *IntentAPI) SetRoomName(roomID id.RoomID, roomName string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateRoomName, "", map[string]interface{}{
"name": roomName,
})
}
func (intent *IntentAPI) SetRoomAvatar(ctx context.Context, roomID id.RoomID, avatarURL id.ContentURI) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(ctx, roomID, event.StateRoomAvatar, "", map[string]interface{}{
func (intent *IntentAPI) SetRoomAvatar(roomID id.RoomID, avatarURL id.ContentURI) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateRoomAvatar, "", map[string]interface{}{
"url": avatarURL.String(),
})
}
func (intent *IntentAPI) SetRoomTopic(ctx context.Context, roomID id.RoomID, topic string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(ctx, roomID, event.StateTopic, "", map[string]interface{}{
func (intent *IntentAPI) SetRoomTopic(roomID id.RoomID, topic string) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(roomID, event.StateTopic, "", map[string]interface{}{
"topic": topic,
})
}
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 {
func (intent *IntentAPI) SetDisplayName(displayName string) error {
if err := intent.EnsureRegistered(); err != nil {
return err
}
resp, err := intent.Client.GetOwnDisplayName(ctx)
resp, err := intent.Client.GetOwnDisplayName()
if err != nil {
return fmt.Errorf("failed to check current displayname: %w", err)
} else if resp.DisplayName == displayName {
// No need to update
return nil
}
return intent.Client.SetDisplayName(ctx, displayName)
return intent.Client.SetDisplayName(displayName)
}
func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentURI) error {
if err := intent.EnsureRegistered(ctx); err != nil {
func (intent *IntentAPI) SetAvatarURL(avatarURL id.ContentURI) error {
if err := intent.EnsureRegistered(); err != nil {
return err
}
resp, err := intent.Client.GetOwnAvatarURL(ctx)
resp, err := intent.Client.GetOwnAvatarURL()
if err != nil {
return fmt.Errorf("failed to check current avatar URL: %w", err)
} else if resp.FileID == avatarURL.FileID && resp.Homeserver == avatarURL.Homeserver {
// No need to update
return nil
}
if !avatarURL.IsEmpty() && !intent.SpecVersions.Supports(mautrix.BeeperFeatureHungry) {
// Some homeservers require the avatar to be downloaded before setting it
resp, _ := intent.Download(ctx, avatarURL)
if resp != nil {
_ = resp.Body.Close()
}
}
return intent.Client.SetAvatarURL(ctx, avatarURL)
return intent.Client.SetAvatarURL(avatarURL)
}
func (intent *IntentAPI) Whoami(ctx context.Context) (*mautrix.RespWhoami, error) {
if err := intent.EnsureRegistered(ctx); err != nil {
func (intent *IntentAPI) Whoami() (*mautrix.RespWhoami, error) {
if err := intent.EnsureRegistered(); err != nil {
return nil, err
}
return intent.Client.Whoami(ctx)
return intent.Client.Whoami()
}
func (intent *IntentAPI) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error {
if !intent.as.StateStore.IsInvited(ctx, roomID, userID) {
_, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{
func (intent *IntentAPI) EnsureInvited(roomID id.RoomID, userID id.UserID) error {
if !intent.as.StateStore.IsInvited(roomID, userID) {
_, err := intent.InviteUser(roomID, &mautrix.ReqInviteUser{
UserID: userID,
})
if httpErr, ok := err.(mautrix.HTTPError); ok &&

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

@ -10,8 +10,9 @@ import (
"os"
"regexp"
"go.mau.fi/util/random"
"gopkg.in/yaml.v3"
"go.mau.fi/util/random"
)
// Registration contains the data in a Matrix appservice registration.
@ -27,9 +28,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

810
bridge/bridge.go Normal file
View file

@ -0,0 +1,810 @@
// 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/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/maulogger/v2"
"maunium.net/go/maulogger/v2/maulogadapt"
"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 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()
}
type MembershipHandlingPortal interface {
Portal
HandleMatrixLeave(sender User)
HandleMatrixKick(sender User, ghost Ghost)
HandleMatrixInvite(sender User, ghost Ghost)
}
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 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
}
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 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
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
// Deprecated: Switch to ZLog
Log maulogger.Logger
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(*event.Event)
Decrypt(*event.Event) (*event.Event, error)
Encrypt(id.RoomID, event.Type, *event.Content) error
WaitForSession(id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
RequestSession(id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
ResetSession(id.RoomID)
Init() error
Start()
Stop()
Reset(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.SpecV11
func (br *Bridge) ensureConnection() {
for {
versions, err := br.Bot.Versions()
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
}
}
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(zerolog.FatalLevel).
Stringer("server_supports", br.SpecVersions.GetLatest()).
Stringer("bridge_requires", MinSpecVersion).
Msg("The homeserver is outdated (supported spec versions are below minimum required by bridge)")
os.Exit(18)
} else if fr, ok := br.Child.(CSFeatureRequirer); ok {
if msg, hasFeatures := fr.CheckFeatures(&br.SpecVersions); !hasFeatures {
br.ZLog.WithLevel(zerolog.FatalLevel).Msg(msg)
os.Exit(18)
}
}
resp, err := br.Bot.Whoami()
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?")
} 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 and username template in the config correct, and do they match the values in the registration?")
} 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(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")
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() {
cfg, err := br.Bot.GetMediaConfig()
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to fetch media config")
} else {
br.MediaConfig = *cfg
}
}
func (br *Bridge) UpdateBotProfile() {
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(mxc)
} else if !botConfig.ParsedAvatar.IsEmpty() {
err = br.Bot.SetAvatarURL(botConfig.ParsedAvatar)
}
if err != nil {
br.ZLog.Warn().Err(err).Msg("Failed to update bot avatar")
}
if botConfig.Displayname == "remove" {
err = br.Bot.SetDisplayName("")
} else if len(botConfig.Displayname) > 0 {
err = br.Bot.SetDisplayName(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(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:
return br.Config.Bridge.Validate()
}
}
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)
}
defaultCtxLog := br.ZLog.With().Bool("default_context_log", true).Caller().Logger()
zerolog.TimeFieldFormat = time.RFC3339Nano
zerolog.CallerMarshalFunc = exzerolog.CallerWithFunctionName
zerolog.DefaultContextLogger = &defaultCtxLog
br.Log = maulogadapt.ZeroAsMau(br.ZLog)
err = br.validateConfig()
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
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 = br.Config.MakeAppService()
br.AS.DoublePuppetValue = br.Name
br.AS.GetProfile = br.getProfile
br.AS.Log = *br.ZLog
br.AS.StateStore = br.StateStore
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()
}
func (br *Bridge) LogDBUpgradeErrorAndExit(name string, err error) {
br.ZLog.WithLevel(zerolog.FatalLevel).
Err(err).
Str("db_section", name).
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")
} 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()
if err != nil {
br.LogDBUpgradeErrorAndExit("main", err)
} else if err = br.StateStore.Upgrade(); 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")
br.ensureConnection()
go br.fetchMediaConfig()
if br.Crypto != nil {
err = br.Crypto.Init()
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()
go br.UpdateBotProfile()
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()
}
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,327 @@
// 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.DefaultHTTPRetries = 4
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
GetResendBridgeInfo() bool
EnableMessageStatusEvents() bool
EnableMessageErrorNotices() bool
Validate() error
}
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 {
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.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.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,87 @@
// 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()
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
}
err := puppet.SwitchCustomMXID("", "")
if err != nil {
ce.Reply("Failed to disable double puppeting: %v", err)
return
}
ce.Reply("Successfully disabled double puppeting.")
}

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

@ -0,0 +1,96 @@
// 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 (
"fmt"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/maulogger/v2"
"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
ZLog *zerolog.Logger
// Deprecated: switch to ZLog
Log maulogger.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.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.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.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.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.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,16 +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)
output.WriteByte('\n')
output.WriteString("Parameters in [square brackets] are optional, while parameters in <angle brackets> are required.")
_, _ = fmt.Fprintf(&output, prefixMsg, ce.Bridge.Config.Bridge.GetCommandPrefix())
output.WriteByte('\n')
output.WriteByte('\n')
@ -128,14 +125,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,126 @@
// 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 (
"runtime/debug"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/maulogger/v2/maulogadapt"
"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(roomID id.RoomID, eventID id.EventID, user bridge.User, message string, replyTo id.EventID) {
defer func() {
err := recover()
if err != nil {
proc.log.Error().
Str(zerolog.ErrorStackFieldName, string(debug.Stack())).
Interface(zerolog.ErrorFieldName, err).
Str("event_id", eventID.String()).
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 := proc.log.With().
Str("user_id", user.GetMXID().String()).
Str("event_id", eventID.String()).
Str("room_id", roomID.String()).
Str("mx_command", command).
Logger()
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,
ZLog: &log,
Log: maulogadapt.ZeroAsMau(&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.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

@ -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,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,40 +49,39 @@ 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 {
func (helper *CryptoHelper) Init() error {
if len(helper.bridge.CryptoPickleKey) == 0 {
panic("CryptoPickleKey not set")
}
helper.log.Debug().Msg("Initializing end-to-bridge encryption...")
helper.store = NewSQLCryptoStore(
helper.bridge.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)
err := helper.store.DB.Upgrade()
if err != nil {
return bridgev2.DBUpgradeError{Section: "crypto", Err: err}
helper.bridge.LogDBUpgradeErrorAndExit("crypto", err)
}
var isExistingDevice bool
helper.client, isExistingDevice, err = helper.loginBot(ctx)
helper.client, isExistingDevice, err = helper.loginBot()
if err != nil {
return err
}
@ -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
@ -119,7 +111,7 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
}
if encryptionConfig.DeleteKeys.DeleteOutdatedInbound {
deleted, err := helper.store.RedactOutdatedGroupSessions(ctx)
deleted, err := helper.store.RedactOutdatedGroupSessions()
if err != nil {
return err
}
@ -131,91 +123,49 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
helper.client.Syncer = &cryptoSyncer{helper.mach}
helper.client.Store = helper.store
err = helper.mach.Load(ctx)
err = helper.mach.Load()
if err != nil {
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()
}
go helper.resyncEncryptionInfo(context.TODO())
go helper.resyncEncryptionInfo()
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) {
func (helper *CryptoHelper) resyncEncryptionInfo() {
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(`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
}
var roomIDs []id.RoomID
for rows.Next() {
var roomID id.RoomID
err = rows.Scan(&roomID)
if err != nil {
log.Err(err).Msg("Failed to scan room ID")
continue
}
roomIDs = append(roomIDs, roomID)
}
_ = rows.Close()
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)
err = helper.client.StateEvent(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(`
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 +182,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(`
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 +198,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
@ -281,44 +222,30 @@ func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device
}
}
func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool, error) {
deviceID, err := helper.store.FindDeviceID(ctx)
if err != nil {
return nil, false, fmt.Errorf("failed to find existing device ID: %w", err)
} else if len(deviceID) > 0 {
helper.log.Debug().Stringer("device_id", deviceID).Msg("Found existing device ID for bot in database")
func (helper *CryptoHelper) loginBot() (*mautrix.Client, bool, error) {
deviceID := helper.store.FindDeviceID()
if len(deviceID) > 0 {
helper.log.Debug().Str("device_id", deviceID.String()).Msg("Found existing device ID for bot in database")
}
// Create a new client instance with the default AS settings (including as_token),
// the Login call will then override the access token in the client.
client := helper.bridge.AS.NewMautrixClient(helper.bridge.AS.BotMXID())
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)
flows, err := client.GetLoginFlows()
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{
resp, err := client.Login(&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,9 +254,9 @@ 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() {
helper.log.Debug().Msg("Making sure keys are still on server")
resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{
resp, err := helper.client.QueryKeys(&mautrix.ReqQueryKeys{
DeviceKeys: map[id.UserID]mautrix.DeviceIDList{
helper.client.UserID: {helper.client.DeviceID},
},
@ -340,15 +267,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
helper.Reset(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)
@ -380,16 +306,16 @@ func (helper *CryptoHelper) Stop() {
helper.syncDone.Wait()
}
func (helper *CryptoHelper) clearDatabase(ctx context.Context) {
_, err := helper.store.DB.Exec(ctx, "DELETE FROM crypto_account")
func (helper *CryptoHelper) clearDatabase() {
_, err := helper.store.DB.Exec("DELETE FROM crypto_account")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_account table")
}
_, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_olm_session")
_, err = helper.store.DB.Exec("DELETE FROM crypto_olm_session")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_olm_session table")
}
_, err = helper.store.DB.Exec(ctx, "DELETE FROM crypto_megolm_outbound_session")
_, err = helper.store.DB.Exec("DELETE FROM crypto_megolm_outbound_session")
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to clear crypto_megolm_outbound_session table")
}
@ -399,22 +325,22 @@ func (helper *CryptoHelper) clearDatabase(ctx context.Context) {
//_, _ = helper.store.DB.Exec("DELETE FROM crypto_cross_signing_signatures")
}
func (helper *CryptoHelper) Reset(ctx context.Context, startAfterReset bool) {
func (helper *CryptoHelper) Reset(startAfterReset bool) {
helper.lock.Lock()
defer helper.lock.Unlock()
helper.log.Info().Msg("Resetting end-to-bridge encryption device")
helper.Stop()
helper.log.Debug().Msg("Crypto syncer stopped, clearing database")
helper.clearDatabase(ctx)
helper.clearDatabase()
helper.log.Debug().Msg("Crypto database cleared, logging out of all sessions")
_, err := helper.client.LogoutAll(ctx)
_, err := helper.client.LogoutAll()
if err != nil {
helper.log.Warn().Err(err).Msg("Failed to log out all devices")
}
helper.client = nil
helper.store = nil
helper.mach = nil
err = helper.Init(ctx)
err = helper.Init()
if err != nil {
helper.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Error reinitializing end-to-bridge encryption")
os.Exit(50)
@ -429,24 +355,25 @@ func (helper *CryptoHelper) Client() *mautrix.Client {
return helper.client
}
func (helper *CryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) {
return helper.mach.DecryptMegolmEvent(ctx, evt)
func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
return helper.mach.DecryptMegolmEvent(context.TODO(), evt)
}
func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content *event.Content) (err error) {
func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content *event.Content) (err error) {
helper.lock.RLock()
defer helper.lock.RUnlock()
var encrypted *event.EncryptedEventContent
ctx := context.TODO()
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 err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
return
}
helper.log.Debug().Err(err).
Str("room_id", roomID.String()).
Msg("Got error while encrypting event for room, sharing group session and trying again...")
var users []id.UserID
users, err = helper.store.GetRoomJoinedOrInvitedMembers(ctx, roomID)
users, err = helper.store.GetRoomJoinedOrInvitedMembers(roomID)
if err != nil {
err = fmt.Errorf("failed to get room member list: %w", err)
} else if err = helper.mach.ShareGroupSession(ctx, roomID, users); err != nil {
@ -462,19 +389,19 @@ func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtTy
return
}
func (helper *CryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
func (helper *CryptoHelper) WaitForSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
helper.lock.RLock()
defer helper.lock.RUnlock()
return helper.mach.WaitForSession(ctx, roomID, senderKey, sessionID, timeout)
return helper.mach.WaitForSession(roomID, senderKey, sessionID, timeout)
}
func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
func (helper *CryptoHelper) RequestSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
helper.lock.RLock()
defer helper.lock.RUnlock()
if deviceID == "" {
deviceID = "*"
}
err := helper.mach.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
err := helper.mach.SendRoomKeyRequest(roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
if err != nil {
helper.log.Warn().Err(err).
Str("user_id", userID.String()).
@ -492,10 +419,10 @@ func (helper *CryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID
}
}
func (helper *CryptoHelper) ResetSession(ctx context.Context, roomID id.RoomID) {
func (helper *CryptoHelper) ResetSession(roomID id.RoomID) {
helper.lock.RLock()
defer helper.lock.RUnlock()
err := helper.mach.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
err := helper.mach.CryptoStore.RemoveOutboundGroupSession(roomID)
if err != nil {
helper.log.Debug().Err(err).
Str("room_id", roomID.String()).
@ -503,10 +430,10 @@ func (helper *CryptoHelper) ResetSession(ctx context.Context, roomID id.RoomID)
}
}
func (helper *CryptoHelper) HandleMemberEvent(ctx context.Context, evt *event.Event) {
func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
helper.lock.RLock()
defer helper.lock.RUnlock()
helper.mach.HandleMemberEvent(ctx, evt)
helper.mach.HandleMemberEvent(0, evt)
}
// ShareKeys uploads the given number of one-time-keys to the server.
@ -518,7 +445,7 @@ type cryptoSyncer struct {
*crypto.OlmMachine
}
func (syncer *cryptoSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
done := make(chan struct{})
go func() {
defer func() {
@ -532,7 +459,7 @@ func (syncer *cryptoSyncer) ProcessResponse(ctx context.Context, resp *mautrix.R
done <- struct{}{}
}()
syncer.Log.Trace().Str("since", since).Msg("Starting sync response handling")
syncer.ProcessSyncResponse(ctx, resp, since)
syncer.ProcessSyncResponse(resp, since)
syncer.Log.Trace().Str("since", since).Msg("Successfully handled sync response")
}()
select {
@ -554,14 +481,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(id id.RoomID) bool {
portal := c.bridge.Child.GetIPortal(id)
if portal != nil {
return portal.IsEncrypted()
}
return c.bridge.StateStore.IsEncrypted(id)
}
func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
return c.bridge.StateStore.FindSharedRooms(id)
}
func (c *cryptoStateStore) GetEncryptionEvent(id id.RoomID) *event.EncryptionEventContent {
return c.bridge.StateStore.GetEncryptionEvent(id)
}

View file

@ -6,11 +6,9 @@
//go:build cgo && !nocrypto
package matrix
package bridge
import (
"context"
"github.com/lib/pq"
"go.mau.fi/util/dbutil"
@ -30,22 +28,22 @@ 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,
}
}
func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) (members []id.UserID, err error) {
func (store *SQLCryptoStore) GetRoomJoinedOrInvitedMembers(roomID id.RoomID) (members []id.UserID, err error) {
var rows dbutil.Rows
rows, err = store.DB.Query(ctx, `
rows, err = store.DB.Query(`
SELECT user_id FROM mx_user_profile
WHERE room_id=$1
AND (membership='join' OR membership='invite')
AND user_id<>$2
AND user_id NOT LIKE $3 ESCAPE '\'
AND user_id NOT LIKE $3
`, roomID, store.UserID, store.GhostIDFormat)
if err != nil {
return

676
bridge/matrix.go Normal file
View file

@ -0,0 +1,676 @@
// 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(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)
return handler
}
func (mx *MatrixHandler) sendBridgeCheckpoint(evt *event.Event) {
if !evt.Mautrix.CheckpointSent {
go mx.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepBridge, 0)
}
}
func (mx *MatrixHandler) HandleEncryption(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(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(evt.RoomID)
if err != nil {
log.Warn().Err(err).Msg("Failed to join room with invite")
return nil
}
members, err := intent.JoinedMembers(resp.RoomID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get members in room after accepting invite, leaving room")
_, _ = intent.LeaveRoom(resp.RoomID)
return nil
}
if len(members.Joined) < 2 {
log.Debug().Msg("Leaving empty room after accepting invite")
_, _ = intent.LeaveRoom(resp.RoomID)
return nil
}
return members
}
func (mx *MatrixHandler) sendNoticeWithMarkdown(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(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(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(evt.RoomID)
return
}
texts := mx.bridge.Config.Bridge.GetManagementRoomTexts()
_, _ = mx.sendNoticeWithMarkdown(evt.RoomID, texts.Welcome)
if len(members.Joined) == 2 && (len(user.GetManagementRoomID()) == 0 || evt.Content.AsMember().IsDirect) {
user.SetManagementRoom(evt.RoomID)
_, _ = intent.SendNotice(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(evt.RoomID, texts.WelcomeConnected)
} else {
_, _ = mx.sendNoticeWithMarkdown(evt.RoomID, texts.WelcomeUnconnected)
}
additionalHelp := texts.AdditionalHelp
if len(additionalHelp) > 0 {
_, _ = mx.sendNoticeWithMarkdown(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(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(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(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(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(evt.RoomID, "Please invite the bridge bot first if you want to bridge to a remote chat.")
_, _ = intent.LeaveRoom(evt.RoomID)
} else {
_, _ = intent.SendNotice(evt.RoomID, "This puppet will remain inactive until this room is bridged to a remote chat.")
}
}
func (mx *MatrixHandler) HandleMembership(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(evt)
}
ctx := context.Background()
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)
} else if ghost != nil {
mhp.HandleMatrixKick(user, ghost)
}
} else if content.Membership == event.MembershipInvite && !isSelf && ghost != nil {
mhp.HandleMatrixInvite(user, ghost)
}
// TODO kicking/inviting non-ghost users users
}
func (mx *MatrixHandler) HandleRoomMetadata(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(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"
}
if editEvent != "" {
update.SetEdit(editEvent)
}
resp, sendErr := mx.bridge.Bot.SendMessageEvent(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
mx.bridge.EventProcessor.Dispatch(decrypted)
if errorEventID != "" {
_, _ = mx.bridge.Bot.RedactEvent(decrypted.RoomID, errorEventID)
}
}
func (mx *MatrixHandler) HandleEncrypted(evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
}
content := evt.Content.AsEncrypted()
ctx := context.Background()
log := mx.log.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(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(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(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(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(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(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(evt *event.Event) {
defer mx.TrackEventDuration(evt.Type)()
if mx.shouldIgnoreEvent(evt) {
return
} else if !evt.Mautrix.WasEncrypted && mx.bridge.Config.Bridge.GetEncryptionConfig().Require {
log := mx.log.With().Str("event_id", evt.ID.String()).Logger()
log.Warn().Msg("Dropping unencrypted event")
ctx := log.WithContext(context.Background())
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(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(evt.RoomID, event.BeeperMessageStatus, statusEvent)
if sendErr != nil {
mx.log.Warn().Str("event_id", evt.ID.String()).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)
}
}
func (mx *MatrixHandler) HandleReaction(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)
}
}
func (mx *MatrixHandler) HandleRedaction(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)
}
}
func (mx *MatrixHandler) HandleReceipt(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(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)
}

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().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.Log.Debugln("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,
}
}

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