Compare commits

..

557 commits

Author SHA1 Message Date
Tulir Asokan
ef6de851a2 format/htmlparser: fix generating markdown for code blocks with backticks
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-03-13 18:33:22 +02:00
Tulir Asokan
b42ac0e83d bridgev2/status: make RemoteProfile a non-pointer
Closes #468
2026-03-13 16:28:07 +02:00
Tulir Asokan
92cfc0095d
bridgev2: add support for custom profile fields for ghosts (#462) 2026-03-13 16:24:31 +02:00
Tulir Asokan
8fb92239dc bridgev2: fix bugs with threads
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-03-10 13:00:00 +02:00
Tulir Asokan
c243dad24a bridgev2/portal: include portal receiver in logs
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-09 14:27:28 +02:00
timedout
c107c25d07
client: add type parameter to UIA request bodies (#469)
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-03-07 14:26:42 +00:00
Tulir Asokan
df24fb96e2 client: update MSC2666 implementation
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-06 20:58:18 +02:00
Tulir Asokan
531822f6dc bridgev2/config: add limit for unknown error auto-reconnects
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-06 16:08:28 +02:00
Tulir Asokan
7a53f3928a bridgev2/portal: redact conflicting reactions before sending MSS success 2026-03-06 14:37:36 +02:00
Tulir Asokan
7836f35a1a bridgev2/portal: fix third matrix reaction not removing previous one on single-reaction networks
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-05 23:57:35 +02:00
Tulir Asokan
0f6a779dd2 readme: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-05 11:59:11 +02:00
Tulir Asokan
ed6dbcaaee client: log content length when uploading to external url
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-04 22:50:43 +02:00
Tulir Asokan
ed9820356e bridgev2/portalreid: try to fix deadlock when racing with room creation
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-04 13:58:24 +02:00
batuhan içöz
fef4326fbc
client,event,bridgev2: add support for Beeper's custom ephemeral events and AI stream events (#457)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-04 01:38:50 +01:00
Tulir Asokan
77f0658365 bridgev2/{commands,provisioning}: log full login step data
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-03-03 17:33:51 +02:00
Tulir Asokan
e1529f9616 bridgev2/provisioning: log when returning login steps in provisioning API 2026-03-03 17:28:19 +02:00
Tulir Asokan
26a62a7eec event: add missing omitempty
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-03-01 13:49:04 +02:00
Tulir Asokan
f8234ecf85 event: add m.room.policy event type 2026-03-01 13:23:32 +02:00
Tulir Asokan
36c353abc7 federation/pdu: add AddSignature helper method 2026-03-01 12:37:13 +02:00
Tulir Asokan
dd51c562ab crypto: log destination map when sharing megolm sessions
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-02-26 17:40:15 +02:00
Tulir Asokan
98c830181b client: omit large request bodies from logs 2026-02-26 17:40:15 +02:00
Radon Rosborough
7f24c78002
bridgev2/login: add attachments option to user input step type (#465)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-25 08:52:29 -08:00
Tulir Asokan
3efa3ef73a bridgev2/portal: log remote event timestamps by default
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-02-23 22:14:23 +02:00
timedout
28b7bf7e56
federation/eventauth: Fix inverted membership check for 5.6.1 (#464)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-22 19:37:19 +00:00
Tulir Asokan
5779871f1b bridgev2/commands: add file info for QR codes
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-21 14:10:55 +02:00
Tulir Asokan
bc79822eab crypto: save source of megolm sessions
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-21 01:06:12 +02:00
Tulir Asokan
67d30e054c dependencies: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-19 22:51:37 +02:00
Tulir Asokan
974f7dc544 crypto/decryptmegolm: allow device key mismatches, but mark as untrusted
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-19 14:10:20 +02:00
Tulir Asokan
ae58161412 bridgev2/provisioning: log group create params 2026-02-19 14:09:59 +02:00
Tulir Asokan
de0d12e26a goolm/crypto: add test to ensure shared secrets can't be zero
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-18 12:53:37 +02:00
Tulir Asokan
9cd7258764 Bump version to v0.26.3
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-02-16 14:33:21 +02:00
Tulir Asokan
0b9471e190 dependencies: update 2026-02-16 14:31:01 +02:00
Tulir Asokan
53ed8526c6 federation/eventauth: disable underscore support in string power levels 2026-02-16 14:29:09 +02:00
Tulir Asokan
c52d87b6ea mediaproxy: handle federation thumbnail requests
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-15 21:47:10 +02:00
Tulir Asokan
bafba9b227 federation/eventauth: make expected success a part of test name 2026-02-14 23:49:14 +02:00
Tulir Asokan
b97f989032 federation/eventauth: add support for underscores in string power levels
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-14 23:37:20 +02:00
Tulir Asokan
7dbc4dd16a appservice: fix building websocket url
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-02-12 17:34:40 +02:00
Tulir Asokan
fe541df217 main: bump minimum Go version to 1.25
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-02-11 21:34:47 +02:00
Tulir Asokan
d2364b3822 bridgev2/portal: allow delivery receipts even if portal has no other user ID
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-29 19:47:19 +02:00
Nick Mills-Barrett
4b387c305b
error: add RespError.CanRetry field (#456)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-29 15:01:48 +00:00
Tulir Asokan
60742c4b61 crypto: update test
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-28 21:37:23 +02:00
Tulir Asokan
2423716f83 crypto/keysharing: don't send withheld response to some key requests 2026-01-28 21:34:07 +02:00
Tulir Asokan
b613f4d676 crypto/sessions: add missing field in export 2026-01-28 21:32:48 +02:00
Tulir Asokan
2c0d51ee7d crypto/ssss: handle slightly broken key metadata better 2026-01-28 14:43:02 +02:00
Tulir Asokan
c4ce008c8e crypto/ssss: skip verifying recovery key if MAC or IV are missing
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-28 12:51:46 +02:00
Tulir Asokan
9d30203f6b bridgev2/userlogin: add todo 2026-01-26 13:42:33 +02:00
Tulir Asokan
074a2d8d4d crypto/keysharing: fix including sender key in forwards
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-26 01:39:44 +02:00
Tulir Asokan
b041eb924e error: allow storing extra headers in RespError 2026-01-26 01:21:20 +02:00
Tulir Asokan
8b04430d84 event: switch url preview image blurhash to use MSC2448 field
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-23 19:38:09 +02:00
SpiritCroc
d057f1c673
event: add action message content for rich call notifications (#454) 2026-01-23 15:38:17 +01:00
Tulir Asokan
a1236b65be crypto/keyimport: call session received callback for all sessions in import
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-20 14:28:21 +02:00
Tulir Asokan
a55693bbd7 client,bridgev2/matrix: fix context used for async uploads
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-20 12:09:01 +02:00
Nick Mills-Barrett
f32af79d20
bridgev2/ghost: consider avatar being set in Ghost.UpdateInfoIfNecessary (#453)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2026-01-19 14:26:22 +00:00
Tulir Asokan
e28f7170bc
bridgev2/portal: auto-accept message requests on message (#451) 2026-01-19 14:58:18 +02:00
Tulir Asokan
28bcc356db client: add MemberCount helper method for lazy load summary
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-18 22:41:34 +02:00
Tulir Asokan
0b6fa137ce client: add support for sending MSC4354 sticky events
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-18 14:49:06 +02:00
Tulir Asokan
b2b58f3a29 bridgev2/provisioning: cancel logins on error and delete completed logins from map
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-17 01:36:36 +02:00
Tulir Asokan
ec3cf5fbdd crypto/decryptmegolm: add additional checks for megolm decryption 2026-01-17 01:02:39 +02:00
Tulir Asokan
b226c03277 crypto: add length check to hacky megolm message index parser 2026-01-17 00:55:16 +02:00
Tulir Asokan
0e4b074b57 event: add detail to not json string parse error 2026-01-17 00:43:41 +02:00
Tulir Asokan
65d708f1b7 Bump version to v0.26.2
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-16 14:50:43 +02:00
Tulir Asokan
34bcd027e5 bridgev2/commands: add debug command for resetting connections
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-15 14:02:00 +02:00
Tulir Asokan
75f9cb369b bridgev2: add helper method for getting HTTP settings from matrix connector
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-14 17:06:32 +02:00
Tulir Asokan
38799be3ca bridgev2/networkinterface: let matrix connector reset remote network connections
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-13 23:23:31 +02:00
Tulir Asokan
d77cb628ff bridgev2/matrixinterface: let matrix connector suggest HTTP client settings 2026-01-13 23:11:50 +02:00
Tulir Asokan
3d5de4ed2f bridgev2/matrixinterface: add parent interface to MatrixConnector subinterfaces 2026-01-13 23:11:18 +02:00
Tulir Asokan
9d70b2b845 bridgev2/matrixinterface: properly expose GetProvisioning
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-12 12:33:55 +02:00
Tulir Asokan
650f9c3139 event/cmdschema: adjust handling of unterminated quotes
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-12 00:57:12 +02:00
Tulir Asokan
4c0b511c01 event/cmdschema: add JSON schemas for test data 2026-01-12 00:52:24 +02:00
Tulir Asokan
e034c16753 event/cmdschema: don't allow flags after tail parameter 2026-01-12 00:09:05 +02:00
Tulir Asokan
4cd376cd90 event/cmdschema: disallow positional optional parameters and add tail parameters 2026-01-11 23:42:24 +02:00
Tulir Asokan
60be954407 event/cmdschema: make boolean parsing stricter 2026-01-11 23:42:16 +02:00
Tulir Asokan
d63a008ec6 commands: add MSC4391 support
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-10 20:55:11 +02:00
Tulir Asokan
5ac73563b0 event/cmdschema: add MSC4391 types, parser and stringifier 2026-01-10 20:55:11 +02:00
Tulir Asokan
be22286000 event: drop MSC4332 support 2026-01-10 20:55:11 +02:00
Tulir Asokan
c69518ab3c bridgev2/login: add default_value for user input fields 2026-01-10 20:53:44 +02:00
Tulir Asokan
6da5f6b5d0 federation: change serverauth test domains
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2026-01-10 14:18:57 +02:00
Tulir Asokan
32da107299 bridgev2/matrix: fix decrypting events in GetEvent
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-08 22:52:25 +02:00
Tulir Asokan
9f327602f6 event/beeper: add blurhash for link previews
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-07 20:05:59 +02:00
Tulir Asokan
f4434b33c6
crypto,bridgev2: add option to encrypt reactions and replies (#445) 2026-01-07 19:22:32 +02:00
Tulir Asokan
3a2c6ae865 client: stabilize MSC4323
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2026-01-05 14:58:29 +02:00
Tulir Asokan
788151bc50 client: error if Download parameter is empty
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-30 22:53:27 +02:00
Tulir Asokan
59ec890dcb changelog: add missing link
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-19 15:15:23 +02:00
Tulir Asokan
4825e41d5c bridgev2/portalreid: try to cancel room creation
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-19 13:32:55 +02:00
Tulir Asokan
af06098723 bridgev2/simplevent: add method to merge log contexts 2025-12-19 13:06:34 +02:00
Tulir Asokan
80b4201ff1 bridgev2/portalreid: add more logs 2025-12-19 13:03:19 +02:00
Tulir Asokan
33eb00fde0 bridgev2/database: reduce limit for using chunked deletion
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-16 19:29:26 +02:00
Tulir Asokan
b44f81d114 bridgev2/database: only allow one chunked portal deletion at a time 2025-12-16 18:57:39 +02:00
Tulir Asokan
e38d758a52 bridgev2/database: delete messages in chunks if portal has too many 2025-12-16 16:59:54 +02:00
Tulir Asokan
e9b262e671 bridgev2/database: add index for disappearing messages and portal parents 2025-12-16 16:23:44 +02:00
Tulir Asokan
b9635964a5 Bump version to v0.26.1
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-16 12:20:42 +02:00
Tulir Asokan
950ce6636e crypto/goolm: include version number in version mismatches 2025-12-15 15:18:40 +02:00
Tulir Asokan
4be2562297 changelog: update
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-14 14:37:57 +02:00
Tulir Asokan
cb6f673e7a bridgev2/portal: fix event loop not stopping
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-13 11:09:09 +02:00
Tulir Asokan
9dc3772c47 ci: update actions and pre-commit hooks 2025-12-13 10:54:58 +02:00
Tulir Asokan
de52a753be bridgev2: remove hardcoded room version 2025-12-13 10:47:37 +02:00
Tulir Asokan
9e3fa96fb4 bridgev2/portal: handle portal deletion edge cases
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-12 17:31:56 +02:00
Tulir Asokan
efd4136c7a dependencies: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-11 14:17:45 +02:00
Tulir Asokan
2c62641c73 bridgev2/portal: make queueEvent slightly safer when deleting portals
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-10 13:15:33 +02:00
Tulir Asokan
31579be20a bridgev2,event: add interface for message requests
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-09 16:41:56 +02:00
Nick Mills-Barrett
e7a95b7f97
client: backoff before retrying external upload requests
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-08 14:33:02 +00:00
Tulir Asokan
315d2ab17d all: fix staticcheck issues
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-08 00:07:25 +02:00
Tulir Asokan
6017612c55 bridgev2/portal: only delete old reactions if new one is successful 2025-12-07 23:21:05 +02:00
Tulir Asokan
00c58efc59 bridgev2/portal: don't try to update functional members if portal doesn't exist 2025-12-07 19:52:22 +02:00
Tulir Asokan
0584fd0c0d bridgev2/portal: don't forward backfill without CanBackfill flag 2025-12-07 19:52:08 +02:00
Tulir Asokan
a2522192ff bridgev2/config: fix warning log for null env_config_prefix 2025-12-07 19:34:29 +02:00
Tulir Asokan
3e07631f9e bridgev2/mxmain: add better error for pre-megabridge dbs
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-06 22:58:11 +02:00
Tulir Asokan
4efa4bdac5 bridgev2/config: allow multiple prioritized backfill limit override keys
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-06 12:51:12 +02:00
Nick Mills-Barrett
f6d8362278
client: add missing retry cancel check while backing off requests
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-05 11:36:43 +00:00
Tulir Asokan
02ce6ff918 mediaproxy: allow delayed mime type and redirects for file responses
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-12-03 21:59:41 +02:00
Tulir Asokan
7d54edbfda bridgev2/mxmain: add support for reading env vars from config
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-02 19:04:04 +02:00
Tulir Asokan
2eeece6942 bridgev2/networkinterface: allow HandleMatrixMembership to redirect invites to another user ID 2025-12-02 15:22:04 +02:00
Tulir Asokan
dfd5485a0d bridgev2/networkinterface: remove deprecated fields in MatrixMembershipChange 2025-12-02 14:17:29 +02:00
Tulir Asokan
5206439b83 bridgev2/portal: pass is state request flag to event handlers 2025-12-02 13:52:48 +02:00
Tulir Asokan
e22802b9bb bridgev2/database: improve missing parents when migrating to split portals
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-12-01 17:07:54 +02:00
Tulir Asokan
09052986b2 bridgev2/commands: add command for muting chat on remote network 2025-12-01 15:28:56 +02:00
Tulir Asokan
6e402e8fd2 bridgev2/backfill: don't try to backfill empty threads 2025-12-01 00:10:29 +02:00
Tulir Asokan
1d1ecb2286 federation/eventauth: fix sender membership check when kicking
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-28 13:40:54 +02:00
Tulir Asokan
3293e2f8ff dependencies: update 2025-11-28 13:38:05 +02:00
Nick Mills-Barrett
c3b85e8e3c
client: add special error that indicates to retry canceled contexts
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
On it's own this is useless since the retries would all immediately
fail with the canceled context error. The caller is expected to also
set a `UpdateRequestOnRetry` on the client which is used to swap out
the context.
2025-11-26 10:55:36 +00:00
Nick Mills-Barrett
016637ebf8
bridgev2/bridgestate: add var to disable catching bridge state queue panics 2025-11-26 10:54:18 +00:00
Nick Mills-Barrett
dc38165473
crypto: allow storing arbitrary metadata alongside encrypted account data
For example, the creation time of a key.
2025-11-26 10:42:32 +00:00
Tulir Asokan
0f2ff4a090 bridgev2/portal: improve error messages in FindPreferredLogin when portal has receiver
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-25 14:23:30 +02:00
Tulir Asokan
eaa4e07eae bridgev2/portal: only allow setting receiver as relay in split portals 2025-11-25 14:23:09 +02:00
Tulir Asokan
41b1dfc8c1 bridgev2/provisionutil: check for orphaned DMs in resolve identifier
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-23 15:51:15 +02:00
Tulir Asokan
75d54132ae bridgev2/portal: fix getting state events in roomIsPublic
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-21 16:07:16 +02:00
Tulir Asokan
1fac8ceb66 bridgev2/matrix: fix GetStateEvent not passing state key through
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-19 23:21:56 +02:00
Tulir Asokan
fa56255a06 bridgev2/portal: ignore not found errors when fetching prev state 2025-11-19 23:13:41 +02:00
Tulir Asokan
57657d54ee
bridgev2: add custom event for requesting state change (#428)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-19 13:15:38 +02:00
Tulir Asokan
8a59112eb1 client: move some room summary fields to public room info 2025-11-19 12:51:08 +02:00
Tulir Asokan
606b627d48 changelog: fix link 2025-11-19 12:51:08 +02:00
Finn
346100cfd4
statestore: fix missing JoinRules map when initializing MemoryStateStore (#432)
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-17 20:18:46 +02:00
timedout
14b85e98a6
federation: Implement federated membership functions (make/send join/knock/leave) (#422) 2025-11-17 16:35:46 +00:00
Tulir Asokan
36029b7622 Bump version to v0.26.0
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-16 12:51:14 +02:00
Tulir Asokan
202c7f1176 dependencies: update 2025-11-16 12:43:52 +02:00
Tulir Asokan
a0cb5c6129 bridgev2/backfill: ignore nil reactions 2025-11-13 18:10:27 +02:00
Tulir Asokan
a61e4d05f8 bridgev2/matrix: use MSC4169 to send redactions when available
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-13 17:39:27 +02:00
Tulir Asokan
0b73e9e7be client,appservice: deprecate SendMassagedStateEvent in favor of SendStateEvent params 2025-11-13 17:38:45 +02:00
Tulir Asokan
eb2fb84009 appservice/intent: don't EnsureJoined when sending massaged own join event 2025-11-13 17:32:14 +02:00
Tulir Asokan
151d945685 event/capabilities: add docstrings for state and member_actions 2025-11-13 01:29:45 +02:00
Tulir Asokan
828ba3cec1 bridgev2/portal: add capability to disable formatting relayed messages
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-12 23:14:37 +02:00
Tulir Asokan
85e25748a8 bridgev2/portal: ensure join is sent using target intent 2025-11-12 23:09:49 +02:00
Tulir Asokan
e9bfa0c519 bridgev2/portal: treat spam checker join rule as public 2025-11-12 22:04:29 +02:00
Tulir Asokan
6c7828afe3 bridgev2/portal: skip invite step if room is public 2025-11-12 21:46:23 +02:00
Tulir Asokan
e31d186dc8 statestore: save join rules for rooms 2025-11-12 21:46:23 +02:00
Tulir Asokan
981addddc9 bridgev2/config: add option to disable kicking matrix users
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-12 19:38:08 +02:00
Tulir Asokan
8b70baa336 bridgev2/commands: add support for ResolveIdentifierTryNext in pm command 2025-11-12 15:34:31 +02:00
Tulir Asokan
4913b123f1 bridgev2/space: let network connector customize personal filtering space 2025-11-12 14:57:18 +02:00
Tulir Asokan
7b33248d3d bridgev2: add flag to indicate when bridge is stopping
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-12 01:54:29 +02:00
Tulir Asokan
19ed3ac40b changelog: update 2025-11-11 01:32:27 +02:00
Tulir Asokan
bb0b26a58b bridgev2/database: fix latest version
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-11 01:07:40 +02:00
Tulir Asokan
77519b6de7 bridgev2/errors: send notice for public media errors 2025-11-11 01:07:40 +02:00
Nick Mills-Barrett
913a28fdce
bridgev2: pass back event ID and stream order in send results
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-10 13:44:04 +00:00
Nick Mills-Barrett
1779c72316
bridgev2: pass back event ID and stream order in send results 2025-11-10 13:44:04 +00:00
Tulir Asokan
aa53cbc528 bridgev2/publicmedia: add support for encrypted files
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-10 00:11:40 +02:00
Tulir Asokan
2eea2e7412 bridgev2/publicmedia: add support for file name in content disposition 2025-11-09 23:02:23 +02:00
Tulir Asokan
60cbe66e2f bridgev2/publicmedia: add support for custom path prefixes 2025-11-09 22:44:02 +02:00
Tulir Asokan
14e16a3a81 bridgev2/matrix: drop events from users without permission earlier
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-09 11:40:10 +02:00
Tulir Asokan
fdd7632e53 bridgev2/matrix: avoid sending message status notices for m.notice events 2025-11-09 11:33:39 +02:00
Tulir Asokan
a973e5dc94 event/reply: only remove plaintext reply fallback if there is one in HTML
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-08 09:49:15 +01:00
Tulir Asokan
bade596e49 bridgev2/portal: allow chaining ChatMembermap.Set calls
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-07 14:33:00 +01:00
Tulir Asokan
3014bf966c bridgev2/commands: include options in user input prompt
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-06 16:38:22 +01:00
Tulir Asokan
36d4e1f99c federation: don't close body when not reading it
Closes #431
2025-11-06 16:38:10 +01:00
Tulir Asokan
cfa47299df bridgev2/provisioning: add select type for login user input
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-11-06 09:26:28 +01:00
Tulir Asokan
6e7b692098 federation/eventauth: fix restricted joins typo
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-11-01 22:19:57 +01:00
Tulir Asokan
4ec3fbb4ab crypto/goolm: fix var bytes read overflow 2025-11-01 22:10:43 +01:00
Tulir Asokan
175f5a1c61 federation/serverauth: fix request uri
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-31 21:11:24 +01:00
Nick Mills-Barrett
8e23192a7d
client: support sending custom txn ID query param with state events
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-31 10:01:49 +00:00
Tulir Asokan
2ece053b2b
bridgev2: roll back failed room metadata changes (#425)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-31 00:07:24 +02:00
Tulir Asokan
be9bbf8d09 bridgev2/provisioning: fix max length checks in group creation
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-29 22:50:02 +02:00
Tulir Asokan
0da0175157 bridgev2: add new flag for slack remote ID migration
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-29 20:58:46 +02:00
timedout
1edfccb4e2
federation/client: Use PUT instead of POST to send transactions (#426) 2025-10-29 17:55:12 +00:00
Tulir Asokan
76cb8ee7d3 bridgev2/provisioning: add option to skip identifier validation in create group
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-28 22:46:29 +02:00
Tulir Asokan
bea28c1381 bridgev2/portal: log mismatching disappearing timers in events
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-28 15:06:46 +02:00
Tulir Asokan
adc035b6a5
event: add state and member action maps to room features (#424)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-27 18:39:10 +02:00
Tulir Asokan
d486dba927 event: add some getters for state content
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-25 16:59:36 +03:00
Tulir Asokan
364ae39fef responses: add Equal method for LazyLoadSummary 2025-10-25 15:34:48 +03:00
Tulir Asokan
02a0aad583 bridgev2/portal: add event for waiting for room creation
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-24 15:14:31 +03:00
Tulir Asokan
ee1e05c3e8 event: fix 32-bit compatibility 2025-10-24 13:15:46 +03:00
Tulir Asokan
5d87d14b88 event/powerlevels: fix some set user level calls in v12 rooms 2025-10-24 12:42:09 +03:00
Tulir Asokan
75ad1961d5 bridgev2/errors: add special-cased message for too long voice messages
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-23 17:35:08 +03:00
Tulir Asokan
1be49d53e4 bridgev2/config: add option to limit maximum number of logins 2025-10-23 15:49:11 +03:00
Tulir Asokan
756196ad4f
bridgev2/disappear: only start timers for read messages rather than all pending ones (#415) 2025-10-23 15:12:42 +03:00
Tulir Asokan
33d8d658fe bridgev2/commands: fix panic when creating group with no arguments
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-22 21:25:46 +03:00
Tulir Asokan
34a65d3087 bridgev2/commands: enable create group command 2025-10-22 21:24:14 +03:00
Tulir Asokan
bae61f955f bridgev2/matrixinvite: fix bugs in DM creation 2025-10-22 20:54:53 +03:00
Tulir Asokan
9fd1e0f87c bridgev2/networkinterface: allow deleting children in chat delete event 2025-10-22 18:56:41 +03:00
Tulir Asokan
7f0f51ecf3 bridgev2/commands: add command to sync single chat 2025-10-22 18:13:21 +03:00
Tulir Asokan
2a01535030 bridgev2/portal: add helpers for chat member map
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-22 16:50:27 +03:00
Tulir Asokan
1cd285dee0 bridgev2/matrixinvite: allow redirecting created DM to no ghost 2025-10-22 16:50:16 +03:00
Tulir Asokan
e805815e41 bridgev2/commands: add account data debug command 2025-10-22 13:03:32 +03:00
Tulir Asokan
237499fdf5 client: fix admin whois response body
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-21 22:53:18 +03:00
Tulir Asokan
ef31dae082 bridgev2/provisioning: include user and DM room MXID in failed participants 2025-10-21 18:55:49 +03:00
Tulir Asokan
1aacf6e987 bridgev2/commands: include failed participants in group create response
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-21 17:40:18 +03:00
Tulir Asokan
8ee8fb1a20 bridgev2/provisioning: allow group creation to signal failed participants 2025-10-21 17:31:10 +03:00
Tulir Asokan
36edccf61a bridgev2/provisionutil: allow mxids as participants in CreateGroup 2025-10-21 16:59:18 +03:00
Tulir Asokan
56b182f85d bridgev2/bridgestate: only send one delayed transient disconnect notice
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-20 11:48:45 +03:00
Tulir Asokan
7b70ec6d52 bridgev2/bridgestate: send transient disconnect notices if they persist 2025-10-20 11:45:35 +03:00
Tulir Asokan
a661641bcb bridgev2/matrix: don't sleep after registering bot on versions error
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-19 23:36:07 +03:00
timedout
2fd9e799d2
synapseadmin: Add force_purge option (#420)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-18 21:27:08 +01:00
timedout
e61c7b3f1e
client: Add AdminWhoIs func (#411) 2025-10-18 20:30:43 +01:00
Tulir Asokan
c50460cd6e client: add response size limits
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-18 13:54:14 +02:00
Tulir Asokan
827bb4c621 federation: add response size limit 2025-10-18 13:33:45 +02:00
Tulir Asokan
df957301be federation: don't allow redirects 2025-10-18 13:33:45 +02:00
Tulir Asokan
a214af5bab federation: fix server key query test 2025-10-18 13:33:45 +02:00
Brad Murray
572a704b04
errors: Add M_WRONG_ROOM_KEYS_VERSION (#419) 2025-10-18 05:42:01 -04:00
Tulir Asokan
50a49e01f3 Bump version to v0.25.2
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-16 11:26:46 +02:00
Toni Spets
22ea75db96 client,event: MSC4140: Delayed events
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
Includes transparent migration from deprecated MSC fields still used
in Synapse to later revision.
2025-10-14 14:22:47 +03:00
Toni Spets
080ad4c0a0 crypto: Allow decrypting message content without event id or ts
Replay attack prevention shouldn't store empty event id or ts to
database if we're decrypting without them. This may happen if we are
looking into a future delayed event for example as it doesn't yet have
those.

We still prevent doing that if we already know them meaning we have
gotten the actual event through sync as that's also when a delayed event
would move from scheduled to finalised and then it also contains those
fields.
2025-10-14 14:22:42 +03:00
Tulir Asokan
ab4a7852d6 bridgev2/provisionutil: don't allow self in create group participants 2025-10-14 13:01:21 +03:00
Tulir Asokan
097813c9b2 bridgev2/provisionutil: validate user IDs in CreateGroup if network supports it
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-14 00:20:04 +03:00
Tulir Asokan
5593d8afcd changelog: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-13 15:30:12 +03:00
Tulir Asokan
91ea77b4d4 bridgev2/portal: don't send implicit read receipts for account data
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-08 19:16:00 +03:00
Tulir Asokan
9654a0b01e bridgev2/portal: enforce media duration and size limits
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-08 18:47:55 +03:00
Tulir Asokan
d18142c794 bridgev2/errors: add reason for unsupported errors 2025-10-08 18:33:57 +03:00
Tulir Asokan
3a300246ac id/userid: split validation into 2 functions
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-06 23:10:04 +03:00
Tulir Asokan
51edfc27c0 bridgev2: add omitempty for group create params struct 2025-10-06 23:00:04 +03:00
Tulir Asokan
548970fd0f event: add Clone for other capability types too 2025-10-06 17:05:46 +03:00
Tulir Asokan
344b04c407 event: add Clone method for file features 2025-10-06 17:03:30 +03:00
Tulir Asokan
07bc756971 changelog: update 2025-10-06 16:51:41 +03:00
Tulir Asokan
13f251fe60 crypto/helper: don't block on decryption
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-05 12:30:54 +03:00
Tulir Asokan
8a72af9f6b federation/eventauth: require that join authorizer is in the room
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-10-03 22:51:38 +03:00
Tulir Asokan
4be60a0021 bridgev2/simplevent: allow upserts with PreConvertedMessage
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-03 03:14:51 +03:00
Tulir Asokan
ce667a65e5 bridgev2/simplevent: add simpler form of message event 2025-10-03 03:10:29 +03:00
Tulir Asokan
8e668586f9 appservice/intent: add room ID to fake join response
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-02 22:10:22 +03:00
Tulir Asokan
9fc5d98774 bridgev2/mxmain: fix --version flag output 2025-10-02 21:57:25 +03:00
Tulir Asokan
5d69963ab5 bridgev2/portal: add exclude from timeline flag for not in chat leaves 2025-10-02 17:19:45 +03:00
Tulir Asokan
97da8eb44d event: add helper to get remaining mute duration
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-02 14:45:46 +03:00
Tulir Asokan
dd778ae0cd bridgev2/portal: add option to exclude metadata changes from timeline
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-10-01 14:55:35 +03:00
Tulir Asokan
9ee13d1363 bridgev2/portal: add option to exclude member changes from timeline by default 2025-10-01 14:48:28 +03:00
Tulir Asokan
77682fb292 bridgev2,error: use NonNilClone instead of creating map manually 2025-10-01 14:48:11 +03:00
Tulir Asokan
329da10584 bridgev2/database: fix split portal parent migration query
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-30 15:35:25 +03:00
Tulir Asokan
b597f149b7 version: initialize go.mod version regex lazily 2025-09-28 20:39:07 +03:00
Tulir Asokan
f2b77f0433 version: find from build info if unset
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-28 20:35:41 +03:00
Tulir Asokan
d146b6caf8 bridgev2/mxmain: move version calculation to go-util
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-27 17:09:44 +03:00
Tulir Asokan
743cbb5f2c bridgev2/mxmain: add option to mix calendar and semantic versioning 2025-09-27 16:26:15 +03:00
Tulir Asokan
9878c3d675 federation/eventauth: change error message for users-specific power level check
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-26 23:36:58 +03:00
Tulir Asokan
6e231a45e4 federation/eventauth: fix gjson path construction in new power level check 2025-09-26 23:36:03 +03:00
Tulir Asokan
ae6a0b4f51 federation/eventauth: fix checking user power level changes 2025-09-26 23:26:17 +03:00
Tulir Asokan
a3c6832c48 federation/eventauth: fix default power levels in pre-v12 rooms 2025-09-26 23:18:05 +03:00
Tulir Asokan
acc449daf4 crypto: add basic group session sharing benchmark 2025-09-26 20:37:58 +03:00
Tulir Asokan
fa90bba820 crypto: don't check otk count if sharing new keys 2025-09-26 19:48:22 +03:00
Tulir Asokan
caca057b23 crypto/helper: always share keys when creating new device 2025-09-26 19:17:16 +03:00
Tulir Asokan
0685bd7786 crypto/verificationhelper: extract mockserver to new package 2025-09-26 16:56:48 +03:00
Tulir Asokan
b0481d4b43 client: re-add support for unstable profile fields
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-26 12:55:36 +03:00
Tulir Asokan
cf29b07f32 appservice/websocket: use io.ReadAll instead of json decoder
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-24 20:29:49 +03:00
Tulir Asokan
5c580a7859 crypto/sqlstore: fix query used for olm unwedging
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-22 20:28:49 +03:00
Tulir Asokan
4635590fca bridgev2/portal: add temporary flag to slack bridge info
To let clients detect that 952806ea52 is done
2025-09-22 18:24:49 +03:00
Tulir Asokan
a8b5fa9156 client: fix some footguns in compileRequest
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
* add warning log if RequestBody is used without length instead of
  silently discarding the body
* fix wrapping RequestBody in nopcloser
* always set content length
2025-09-22 16:32:29 +03:00
Tulir Asokan
d5c6393f23 bridgev2/portal: don't process any more events if portal is deleted 2025-09-22 16:11:21 +03:00
Tulir Asokan
a9ff1443f7 bridgev2: add interface for deleting chats from Matrix
Closes #408
2025-09-22 16:05:53 +03:00
Tulir Asokan
b3c883bc7f event: add beeper chat delete event 2025-09-22 16:05:28 +03:00
Tulir Asokan
23b18aa0ca bridgev2/provisioning: fix login_id query param name 2025-09-22 14:46:47 +03:00
Tulir Asokan
c4701ba06c responses: fix RespSearchUserDirectory type 2025-09-22 14:30:41 +03:00
Tulir Asokan
f9fb77d6aa client: add user directory search method 2025-09-22 13:46:46 +03:00
Toni Spets
cf814a5aaa
error: Add RespError WithExtraData convenience function (#416)
To dynamically build errors with extra keys like returning `max_delay`
for `M_MAX_DELAY_EXCEEDED`.
2025-09-22 13:30:08 +03:00
Tulir Asokan
0198ef315c changelog: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-21 20:51:51 +03:00
Tulir Asokan
658b2e1d1d bridgev2/matrix: share device keys as part of e2ee init 2025-09-21 20:34:04 +03:00
Tulir Asokan
6c37f2b21f bridgev2/matrix: add config option to self-sign bot device 2025-09-21 20:34:04 +03:00
Tulir Asokan
0a84c052dd crypto: add utilities for cross-signing 2025-09-21 20:10:59 +03:00
Tulir Asokan
0012a23d85 bridgev2/portal: don't allow queuing events into uninitialized portals
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-19 21:21:25 +03:00
Tulir Asokan
fbf8718e22 bridgev2: also fix portal parent receivers in split portal migration 2025-09-19 21:19:55 +03:00
Tulir Asokan
54c0e5c2f6 bridgev2/portal: remove portal from cache if loading parent/relay fails 2025-09-19 21:19:01 +03:00
Tulir Asokan
820d0ee66b bridgev2: only delete rooms in split portal migration after starting connectors 2025-09-19 21:01:42 +03:00
Tulir Asokan
f7bfa885c9 bridgev2: improve split portal migration 2025-09-19 20:45:17 +03:00
Tulir Asokan
9fbf1b8598 bridgev2: make split portal migration errors fatal 2025-09-19 20:26:55 +03:00
Tulir Asokan
b42fb5096a bridgev2/portal: also log long events when using async events 2025-09-19 19:53:22 +03:00
Tulir Asokan
2240aa0267 bridgev2/portal: log if room create event is taking long 2025-09-19 19:50:41 +03:00
Tulir Asokan
6acb04aa1e federation/pdu: use option to trust internal metadata for GetEventID 2025-09-19 19:15:02 +03:00
Tulir Asokan
b760023dca bridgev2/portal: add support for implicit read receipts to network
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-19 14:30:47 +03:00
Tulir Asokan
8780c2eb44 bridgev2/portal: set exclude from timeline flag for creation state 2025-09-19 13:23:15 +03:00
Tiago Loureiro
e19d009d59
event: add EventUnstablePollEnd to GuessClass() (#414)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-18 11:07:13 -03:00
Tulir Asokan
e932aff209 crypto/ssss: use constant time comparison when decrypting account data
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-17 22:30:32 +03:00
Tulir Asokan
5b860f8bfb responses: fix marshaling RespUserProfile 2025-09-17 22:30:16 +03:00
Tulir Asokan
35ac4fcb8d bridgev2/matrix: don't encrypt reactions in batch sends 2025-09-17 21:45:43 +03:00
Tulir Asokan
e6a1fa6fd7
bridgev2/provisioning: sync ghost info when searching (#413)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-17 15:18:43 +03:00
Tulir Asokan
af2e6c7ce0 bridgev2/portal: ensure state key is set when handling state events 2025-09-17 14:47:09 +03:00
Tulir Asokan
5af25d2eb7 event/poll: add missing omitempty
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-16 18:02:14 +03:00
Tulir Asokan
c37ddcc3a5 Bump version to v0.25.1
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-16 14:45:37 +03:00
Tulir Asokan
b5bec2e96c client: stabilize support for state_after
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-13 13:44:46 +03:00
Tulir Asokan
717c8c3092 bridgev2/database: normalize disappearing settings before insert
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-13 01:38:06 +03:00
Tulir Asokan
3a6f20bb62 crypto/sqlstore: ignore unused sessions in olm unwedging
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-12 19:30:05 +03:00
Tiago Loureiro
4603a344ce
event: add org.matrix.msc3381.poll.end type (#412)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-11 15:10:14 -03:00
Tulir Asokan
5dbab3ae99 crypto/machine: don't clear account on Destroy()
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-11 14:46:21 +03:00
Tulir Asokan
87fe127414 crypto/decryptolm: retry prekey decryption with goolm 2025-09-11 14:17:24 +03:00
Tulir Asokan
c716f30959 crypto/register: don't use init in *olm packages 2025-09-11 14:14:15 +03:00
Tulir Asokan
84e5d6bda1 crypto/machine: allow canceling background context 2025-09-11 14:13:18 +03:00
Tulir Asokan
69869f7cb5 crypto: log active driver 2025-09-11 14:12:35 +03:00
Tulir Asokan
bdb9e22a43 crypto/libolm: clean up pointer management 2025-09-11 13:22:45 +03:00
Tulir Asokan
faa1c5ff8d crypto/machine: log when loading olm account
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-10 16:46:05 +03:00
Tulir Asokan
22a908d8d6 crypto/decryptolm: add debug logs for failing to decrypt with new session 2025-09-10 16:24:43 +03:00
Tulir Asokan
e295028ffd client: stabilize arbitrary profile field support
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-09 19:10:07 +03:00
Tulir Asokan
41bbe4ace4 bridgev2/portal: add action message metadata to disappearing notices 2025-09-09 16:24:18 +03:00
Tulir Asokan
30ab68f7f1 appservice: maybe fix url template raw path for unix sockets
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-04 18:18:53 +03:00
Tulir Asokan
709f48f2b3 bridgev2/provisioning: remove unused structs
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-09-02 18:24:24 +03:00
Tulir Asokan
8f8b26d815 event: add is_animated flag from MSC4230 2025-09-02 10:33:49 +03:00
Tulir Asokan
bcd0a70bdf appservice/websocket: override read limit
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-02 00:31:15 +03:00
Tulir Asokan
f8c3a95de7
bridgev2: add support for creating groups (#405)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-01 18:01:20 +03:00
timedout (aka nexy7574)
0627c42270
client: implement MSC4323 (#407) 2025-09-01 16:01:05 +01:00
Tulir Asokan
61a90da145 event: use RawMessage instead of map for bot command arguments
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-09-01 00:45:32 +03:00
Tulir Asokan
cd927c2796 event: add types for MSC4332
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-30 19:54:58 +03:00
fmseals
1d6bea5fe3
client: fix v3/delete_devices method (#393)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-29 19:34:06 +03:00
Tulir Asokan
050fbbd466 bridgev2/status: change RemoteID to a UserLoginID 2025-08-29 18:32:04 +03:00
Tulir Asokan
f9e3e8a30f bridgev2/provisionutil: allow passing mxids to ResolveIdentifier
Closes #398
2025-08-29 18:32:04 +03:00
Tulir Asokan
8f464b5b76 bridgev2: move shared SNC code to provisionutil 2025-08-29 16:45:54 +03:00
Ping Chen
c18d2e2565
bridgev2/matrixinterface: add GetEvent interface for linkedin reply (#406)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-08-29 17:20:11 +09:00
Tulir Asokan
19f3b2179c pre-commit: ban log.Str(x.String()) 2025-08-29 11:07:16 +03:00
Tulir Asokan
3048d2edab bridgev2/provisioning: add minimum length for shared secret
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-28 02:20:41 +03:00
Tulir Asokan
359afbea2b bridgev2/matrix: remove provisioning API prefix option
Reverse proxy configuration should be used instead when adding prefixes
to the path. Changing the path entirely is not recommended even with
reverse proxies.

Fixes #403
2025-08-28 02:19:27 +03:00
Tulir Asokan
febca20dd7 bridgev2/status: use _file pattern for avatar instead of splitting url and keys
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-27 17:12:00 +03:00
Tulir Asokan
9f693702b0 federation/pdu: add extra field to internal metadata 2025-08-27 12:25:08 +03:00
Tulir Asokan
f131ae5aa4 federation/pdu: add cached event ID to internal metadata
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-27 12:24:15 +03:00
Tulir Asokan
ba16c30a8c
federation/eventauth: add v3-v12 event auth rules (#401)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-27 01:45:33 +03:00
Tulir Asokan
0345a5356d bridgev2/database: don't set disappearing timer content to nil
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-26 17:07:16 +03:00
Tulir Asokan
7b3a60742e event: allow omitting timers from disappearing timer capability 2025-08-26 15:57:10 +03:00
Tulir Asokan
e9d4eeb332 bridgev2/status: add avatar_keys to remote profile 2025-08-26 15:56:27 +03:00
Tulir Asokan
63b654187d event: marshal zero disappearing timers as empty object
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-25 19:03:07 +03:00
Tulir Asokan
c3a422347c bridgev2/portal: validate capabilities when updating disappearing timer 2025-08-25 18:37:15 +03:00
Tulir Asokan
bca8b0528c sqlstatestore: fix GetPowerLevels returning non-nil even if power levels weren't found 2025-08-25 18:27:49 +03:00
Tulir Asokan
4f7c7dafdc bridgev2/matrix: fix encryption error notice not being redacted after retry success 2025-08-25 17:42:20 +03:00
Tulir Asokan
a6bbe978bd bridgev2/networkinterface: add interface for handling disappearing timer changes from Matrix 2025-08-25 17:35:57 +03:00
Tulir Asokan
f860b0e238 bridgev2/portal: fix send notice option when updating disappearing message timer 2025-08-25 17:23:25 +03:00
Tulir Asokan
8e703410f4 bridgev2/portal: always set timestamp for disappearing message timer update 2025-08-25 17:21:55 +03:00
Tulir Asokan
5ac8a888a3 bridgev2/portal: make UpdateDisappearingSetting more versatile 2025-08-25 17:16:18 +03:00
Tulir Asokan
0fab92dbc1 event: add third party invite state event content 2025-08-25 17:16:18 +03:00
Tulir Asokan
c04d0b6681 bridgev2: merge mentions and url previews when merging caption 2025-08-25 17:16:18 +03:00
Brad Murray
fa7c1ae2bc
crypto/sqlstore: add index to make finding megolm sessions to backup faster (#402)
```
2025-08-24T22:23:19Z debug    [MatrixBridgeV2]           {"level":"warn","component":"matrix","component":"client_loop","subcomponent":"sync_key_backup_loop","rows":0,"duration_seconds":1.046191042,"method":"EndRows","query":"SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id=?1 AND session IS NOT NULL AND key_backup_version != ?2","time":"2025-08-24T22:23:19.22077Z","message":"Query took long"} 
```

before:
```
sqlite> EXPLAIN SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id='@brad:beeper.com/CHNWOJWEUC' AND sessi
addr  opcode         p1    p2    p3    p4             p5  comment
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     25    0                    0   Start at 25
1     OpenRead       0     48    0     15             0   root=48 iDb=0; crypto_megolm_inbound_session
2     OpenRead       1     49    0     k(3,,,)        2   root=49 iDb=0; sqlite_autoindex_crypto_megolm_inbound_session_1
3     String8        0     1     0     @brad:beeper.com/CHNWOJWEUC 0   r[1]='@brad:beeper.com/CHNWOJWEUC'
4     SeekGE         1     24    1     1              0   key=r[1]
5       IdxGT          1     24    1     1              0   key=r[1]
6       DeferredSeek   1     0     0                    0   Move 0 to 1.rowid if needed
7       Column         0     5     2                    128 r[2]= cursor 0 column 5
8       IsNull         2     23    0                    0   if r[2]==NULL goto 23
9       Column         0     14    2                    0   r[2]=crypto_megolm_inbound_session.key_backup_version
10      Eq             3     23    2     BINARY-8       82  if r[2]==r[3] goto 23
11      Column         0     4     4                    0   r[4]= cursor 0 column 4
12      Column         0     2     5                    0   r[5]= cursor 0 column 2
13      Column         0     3     6                    0   r[6]= cursor 0 column 3
14      Column         0     5     7                    0   r[7]= cursor 0 column 5
15      Column         0     6     8                    0   r[8]= cursor 0 column 6
16      Column         0     9     9                    0   r[9]= cursor 0 column 9
17      Column         0     10    10                   0   r[10]= cursor 0 column 10
18      Column         0     11    11                   0   r[11]= cursor 0 column 11
19      Column         0     12    12                   0   r[12]= cursor 0 column 12
20      Column         0     13    13    0              0   r[13]=crypto_megolm_inbound_session.is_scheduled
21      Column         0     14    14                   0   r[14]=crypto_megolm_inbound_session.key_backup_version
22      ResultRow      4     11    0                    0   output=r[4..14]
23    Next           1     5     0                    0
24    Halt           0     0     0                    0
25    Transaction    0     0     55    0              1   usesStmtJournal=0
26    Integer        1     3     0                    0   r[3]=1
27    Goto           0     1     0                    0
sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session ;
+----------+
| COUNT(*) |
+----------+
| 168792   |
+----------+
sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session WHERE session IS NULL;
+----------+
| COUNT(*) |
+----------+
| 39       |
+----------+
sqlite> SELECT COUNT(*) FROM crypto_megolm_inbound_session WHERE key_backup_version != 1;
+----------+
| COUNT(*) |
+----------+
| 39       |
+----------+
```

after:
```
sqlite> CREATE INDEX idx_megolm_filtered
   ...> ON crypto_megolm_inbound_session(account_id, key_backup_version, session);
sqlite> EXPLAIN SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version FROM crypto_megolm_inbound_session WHERE account_id='@brad:beeper.com/CHNWOJWEUC' AND session IS NOT NULL AND key_backup_version != 1;
addr  opcode         p1    p2    p3    p4             p5  comment
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     25    0                    0   Start at 25
1     OpenRead       0     48    0     15             0   root=48 iDb=0; crypto_megolm_inbound_session
2     OpenRead       1     91264 0     k(4,,,,)       2   root=91264 iDb=0; idx_megolm_filtered
3     String8        0     1     0     @brad:beeper.com/CHNWOJWEUC 0   r[1]='@brad:beeper.com/CHNWOJWEUC'
4     SeekGE         1     24    1     1              0   key=r[1]
5       IdxGT          1     24    1     1              0   key=r[1]
6       DeferredSeek   1     0     0                    0   Move 0 to 1.rowid if needed
7       Column         1     2     2                    128 r[2]= cursor 1 column 2
8       IsNull         2     23    0                    0   if r[2]==NULL goto 23
9       Column         1     1     2                    0   r[2]=crypto_megolm_inbound_session.key_backup_version
10      Eq             3     23    2     BINARY-8       82  if r[2]==r[3] goto 23
11      Column         0     4     4                    0   r[4]= cursor 0 column 4
12      Column         0     2     5                    0   r[5]= cursor 0 column 2
13      Column         0     3     6                    0   r[6]= cursor 0 column 3
14      Column         1     2     7                    0   r[7]= cursor 1 column 2
15      Column         0     6     8                    0   r[8]= cursor 0 column 6
16      Column         0     9     9                    0   r[9]= cursor 0 column 9
17      Column         0     10    10                   0   r[10]= cursor 0 column 10
18      Column         0     11    11                   0   r[11]= cursor 0 column 11
19      Column         0     12    12                   0   r[12]= cursor 0 column 12
20      Column         0     13    13    0              0   r[13]=crypto_megolm_inbound_session.is_scheduled
21      Column         1     1     14                   0   r[14]=crypto_megolm_inbound_session.key_backup_version
22      ResultRow      4     11    0                    0   output=r[4..14]
23    Next           1     5     0                    0
24    Halt           0     0     0                    0
25    Transaction    0     0     56    0              1   usesStmtJournal=0
26    Integer        1     3     0                    0   r[3]=1
27    Goto           0     1     0                    0
sqlite>
```
2025-08-25 08:03:13 -04:00
Tulir Asokan
7e07700a69 format: add MarkdownMentionRoomID helper
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-24 00:47:55 +03:00
Tulir Asokan
d2cad8c57e format: add MarkdownMentionWithName helper 2025-08-24 00:44:50 +03:00
Tulir Asokan
71bbbdb3c3 federation/pdu: use jsontext.Value instead of any for deprecated fields 2025-08-23 23:22:43 +03:00
Tulir Asokan
363aa94389 federation/pdu: add server name parameter to GetKeyFunc
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-23 03:13:10 +03:00
Tulir Asokan
fd20a61d87 event: add json struct tag to third party signed object 2025-08-23 03:08:44 +03:00
Tulir Asokan
35b805440f federation/pdu: add auth event selection 2025-08-22 19:37:53 +03:00
Tulir Asokan
206071ec03 federation/pdu: add redacted member event 2025-08-22 18:42:28 +03:00
Kishan Bagaria
1d484e01d0
event: implement disappearing timer types (#399)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-08-22 02:16:56 -07:00
Tulir Asokan
a547c0636c event,pushrules: replace assert.Nil with assert.NoError
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-21 13:19:11 +03:00
Tulir Asokan
29780ffb18 federation/pdu: refactor redaction to allow reuse of RedactContent 2025-08-21 13:18:11 +03:00
Tulir Asokan
baf54f57b6 crypto/encryptmegolm: add fallback for copying m.relates_to
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-19 19:44:53 +03:00
Tulir Asokan
05b711d181 federation/pdu: add more tests for signature checks
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-18 00:53:51 +03:00
Tulir Asokan
d1004d42b0 client: add method to download media thumbnail 2025-08-18 00:24:57 +03:00
Tulir Asokan
ca4ca62249 federation/pdu: add docs for GetKeyFunc 2025-08-17 20:24:18 +03:00
Tulir Asokan
ec663b53d4 federation/pdu: reorganize code and add methods to v1 struct 2025-08-17 20:12:38 +03:00
Tulir Asokan
cc80be1500 federation/pdu: add method to convert to client event
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-17 13:45:34 +03:00
Tulir Asokan
6eced49860 client,event: remove deprecated MSC2716 structs 2025-08-17 13:32:07 +03:00
Tulir Asokan
9b075f8bb9 ci: disable tests on goolm again 2025-08-17 13:15:53 +03:00
Tulir Asokan
0f177058c1 ci: move tags to correct place 2025-08-17 13:13:24 +03:00
Tulir Asokan
0dc957fa30 ci: fix more things 2025-08-17 13:11:26 +03:00
Tulir Asokan
31178e9f42 federation/pdu: fail on any signature check error 2025-08-17 13:02:47 +03:00
Tulir Asokan
86802be0f7 federation/pdu: gate signing key validity check by room version 2025-08-17 13:00:42 +03:00
Tulir Asokan
e85276fc0b ci: disable gotestfmt in goolm
It explodes with `panic: BUG: Empty package name encountered.`
2025-08-17 12:59:18 +03:00
Tulir Asokan
d2e7302dae ci: test goolm and jsonv2 2025-08-17 12:53:21 +03:00
Tulir Asokan
80c0b950dc federation/pdu: add utilities for PDU generation and validation 2025-08-17 12:52:58 +03:00
Tulir Asokan
2d4850a188 changelog: fix date
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-16 13:21:06 +03:00
Tulir Asokan
0bbfafe02f Bump version to v0.25.0 2025-08-16 13:13:55 +03:00
Tulir Asokan
cd022c9010 client: don't set user-agent header on wasm
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-15 16:45:18 +03:00
Tulir Asokan
ee869b97e6 dependencies: update
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-13 20:33:04 +03:00
V02460
809333fcc5
verificationhelper: use static format strings (#390) 2025-08-13 20:32:21 +03:00
Tulir Asokan
7dcd45eba2 changelog: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-12 23:50:50 +03:00
Tulir Asokan
5d84bddc62 crypto/attachments: hash correct data while decrypting
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-11 10:58:24 +03:00
Tulir Asokan
23df81f1cc crypto/attachments: fix hash check when decrypting 2025-08-11 10:46:22 +03:00
Tulir Asokan
78aea00999 format/htmlparser: collapse spaces when parsing html
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-10 23:23:15 +03:00
Tulir Asokan
6ea2337283 event: add policy server spammy flag to unsigned 2025-08-10 23:22:25 +03:00
Tulir Asokan
87d599c491 crypto: remove group session already shared error
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-09 17:42:34 +03:00
Tulir Asokan
135cffc7c1 requests: add json un/marshaler for Direction rune 2025-08-09 13:10:31 +03:00
Tulir Asokan
3865abb3b8 dependencies: update go-util and use new UnsafeString helper 2025-08-09 13:10:18 +03:00
Tulir Asokan
90e3427ac5 bridgev2: check that avatar mxc is set before ignoring update
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-07 12:08:25 +03:00
Tulir Asokan
7a791e908c federation: extract VerifyJSON into subpackage
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-03 20:37:11 +03:00
Tulir Asokan
1215f6237e event: fix json tag in power levels
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-08-03 15:16:42 +03:00
Tulir Asokan
e27e00b391 id: move room version from event package and add flags 2025-08-03 15:16:42 +03:00
Sumner Evans
654b6b1d45
crypto: replace t.Fatal and t.Error with require and assert
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-08-02 12:22:24 -06:00
Tulir Asokan
09e4706fdb crypto/backup: allow encrypting session without private key
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-08-01 14:13:55 +03:00
Tulir Asokan
aeeea09549 sqlstatestore: ensure empty room/user ids aren't stored in db 2025-08-01 12:19:51 +03:00
Tulir Asokan
0a804c58a1 bridgev2/matrix: don't ensure joined for state resync 2025-08-01 12:15:47 +03:00
timedout (aka nexy7574)
196164ed67
event: add join_authorised_via_users_server to MemberEventContent (#395)
Adds `JoinAuthorisedViaUsersServer` (`join_authorised_via_users_server`)
to `MemberEventContent`, introduced in room version 8
2025-08-01 09:47:53 +01:00
Tulir Asokan
66ec881a74 bridgev2/matrix: add hack for resyncing encryption state cache 2025-08-01 11:00:37 +03:00
Tulir Asokan
190c0de94f bridgev2/matrix: always clear mx_user_profile when deleting room 2025-08-01 10:51:00 +03:00
Tulir Asokan
10b26b507d client: fix updating state store in CreateRoom 2025-08-01 10:38:02 +03:00
Tulir Asokan
94f53c5853 bridgev2/cryptostore: add missing escape clause to not like
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-31 14:00:00 +03:00
Tulir Asokan
66e0ed47c0 bridgev2/portal: include error in event handling results 2025-07-31 13:40:18 +03:00
Tulir Asokan
91b2bcdb9f bridgev2/matrix: don't send connecting bridge states to cloud 2025-07-31 13:01:08 +03:00
Tulir Asokan
bcf92ba0e8 appservice/intent: don't download avatar before setting on hungry
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-29 17:42:04 +03:00
Tulir Asokan
3a28151780 client: log method/url when retrying requests 2025-07-29 17:41:51 +03:00
Tulir Asokan
7bd136196d format/htmlparser: don't add link suffix if plaintext is only missing protocol
Auto-linkification will add a protocol in the `href`, but usually won't touch
the text part. We want to undo the linkification here since it doesn't carry
any additional information.
2025-07-29 17:24:01 +03:00
Tulir Asokan
b4c7abd62b bridgev2,federation,mediaproxy: enable http access logging
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-29 17:10:50 +03:00
Tulir Asokan
26e66f293e bridgev2/portal: return event ignored result for type unknown 2025-07-29 16:15:36 +03:00
Tulir Asokan
f1da44490c bridgev2/provisioning: move login step checks into handler 2025-07-29 16:15:16 +03:00
Tulir Asokan
2e7ff3fedd all: fix trailing slash in subrouters
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-28 22:03:43 +03:00
Tulir Asokan
ae2c07fb86 appservice/websocket: close writer after sending 2025-07-28 17:34:28 +03:00
Tulir Asokan
74ab3b118e bridgev2/portal: add todo
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-28 15:53:17 +03:00
Tulir Asokan
83b4b71a16 appservice/websocket: switch from gorilla to coder 2025-07-28 14:56:09 +03:00
Tulir Asokan
62c03d093a bridgev2/status: take context and http client in checkpoint SendHTTP 2025-07-28 14:56:09 +03:00
Tulir Asokan
d5223cdc8f all: replace gorilla/mux with standard library 2025-07-28 14:56:09 +03:00
Tulir Asokan
5b55330b85 bridgev2: run PostStart in background
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-23 14:37:57 +03:00
Tulir Asokan
463d2ea6d0 bridgev2/portal: add bots to functional members in DMs
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-22 23:35:58 +03:00
Tulir Asokan
69a3d27c1c bridgev2: add interface for getting arbitrary state event 2025-07-22 22:50:26 +03:00
Tulir Asokan
cb80e5c63f bridgev2/portal: fix adding rooms to personal space on create
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-22 20:31:31 +03:00
Tulir Asokan
fcd7d9a525 bridgev2/commands: allow canceling qr login 2025-07-22 19:20:32 +03:00
Tulir Asokan
3fe5a7badc event: replace soft failed field in unsigned 2025-07-22 17:19:47 +03:00
Tulir Asokan
3ecdb886bf bridgev2/database: add method to mark backfill task as not done 2025-07-22 16:18:25 +03:00
Tulir Asokan
ea72271bad bridgev2/queue: run command handlers in background
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-21 11:15:23 +03:00
Tulir Asokan
65a64c8044 client: allow using custom http client for .well-known resolution
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-20 14:23:20 +03:00
Tulir Asokan
4866da5200 client: add custom room create ts field
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-18 23:59:28 +03:00
Tulir Asokan
96b07ad724 event: use full event type for stripped state for MSC4311 2025-07-18 23:59:28 +03:00
Tulir Asokan
0b62253d3b all: add support for creator power 2025-07-18 23:59:28 +03:00
Tulir Asokan
237ce1c64c client: remove redundant state store update in room create 2025-07-18 22:32:25 +03:00
Tulir Asokan
9a170d2669 bridgev2,appservice: add via to EnsureJoined and use it for tombstone handling
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-18 17:55:27 +03:00
Tulir Asokan
c7263bab40 bridgev2/portal: add support for following tombstones 2025-07-18 17:37:45 +03:00
Tulir Asokan
90a7dc3c75 bridgev2/portal: ignore delete for me in multi-user portals 2025-07-18 16:05:04 +03:00
Tulir Asokan
0508f02a9e bridgev2/disappear: make next check field atomic
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-17 17:36:16 +03:00
Tulir Asokan
5a9e20e451 bridgev2/disappear: always delete synchronously if limit is reached 2025-07-17 17:27:48 +03:00
Tulir Asokan
8efdbc029b bridgev2/disappear: reduce disappear loop interval when there are lots of messages 2025-07-17 17:20:28 +03:00
Tulir Asokan
7ffdbe8bfc bridgev2/disappear: add limit to getting messages from the db 2025-07-17 16:54:55 +03:00
Tulir Asokan
81a807a6c9 Bump version to v0.24.2
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-16 11:32:09 +03:00
Tulir Asokan
fcc72dc54b dependencies: update 2025-07-16 11:06:39 +03:00
Tulir Asokan
095c63a97e bridgev2/portal: add missing return
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-15 14:57:52 +03:00
Tulir Asokan
1ee29a47b6
bridgev2: add option to auto-reconnect after unknown error (#394) 2025-07-15 14:37:07 +03:00
Tulir Asokan
1d37430204 bridgev2/portal: block in queueEvent if buffer is full 2025-07-15 14:31:44 +03:00
Tulir Asokan
687717bd73 bridgev2: hardcode room v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:19:38 +03:00
Tulir Asokan
b74368ac23 commands: add safety to type check 2025-07-15 13:19:44 +03:00
Tulir Asokan
5e29bac3dd bridgev2/portal: adjust handleMatrixMessage return value for pending messages
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-10 16:19:37 +03:00
Tulir Asokan
4f8ff2a350 bridgev2/portal: merge MSS errors with handling result
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-10 15:04:57 +03:00
Tulir Asokan
40bb9637cd bridgev2/queue: add event handling result for matrix events 2025-07-10 14:48:54 +03:00
Tulir Asokan
22587e9159 bridgev2/portal: track event handler panics 2025-07-10 13:45:23 +03:00
Tulir Asokan
c80808439d bridgev2: add logger to background context 2025-07-10 13:45:11 +03:00
Tulir Asokan
0777c10028 bridgev2/networkinterface: add extra fields to reply metadata to allow unknown cross-room replies
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-09 16:35:14 +03:00
Tulir Asokan
44515616d4 bridgev2/portal: don't assume unknown reply events are cross-room 2025-07-09 16:28:02 +03:00
Tulir Asokan
b62535edaa bridgev2/portal: fix disappearing message notice for implicitly turning off timer
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-03 21:22:19 +03:00
Tulir Asokan
71b994b3fd appservice: remove unnecessary parameter in ping
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-07-01 23:29:43 +03:00
Tulir Asokan
6f370cc3bb bridgev2,appservice: move appservice ping loop to appservice package 2025-07-01 23:28:59 +03:00
Tulir Asokan
4f6d4d7c63 bridgev2/portal: add support for per-message profiles in relay mode
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-07-01 01:34:42 +03:00
Tulir Asokan
94950585c9 event: fix removing per-message profile fallback in edits 2025-07-01 01:15:24 +03:00
Tulir Asokan
7a7d7f70ef federation: fix base64 in generated signatures
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-29 19:11:27 +03:00
Matthias Kesler
3a135b6b15
id: fix ServerNameRegex not matching port correctly (#392)
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
fixes #391
2025-06-25 12:35:18 +02:00
Tulir Asokan
324be4ecb9 mediaproxy: fix closing data response readers
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-19 17:55:09 +02:00
Tulir Asokan
f3722ca31f mediaproxy: validate media IDs 2025-06-19 17:17:27 +02:00
Tulir Asokan
26da46dbbf
bridgev2/portal: return result of handling remote events (#389)
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-17 19:38:29 +03:00
Tulir Asokan
1878700a9d Bump version to v0.24.1
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-16 16:42:04 +03:00
Tulir Asokan
1143cfaa85 event: implement fallbacks for per-message profiles
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-14 18:19:42 +03:00
Tulir Asokan
c836dbafdf bridgev2/matrixinvite: clean up old portal room if user is not a member 2025-06-14 12:31:03 +03:00
Tulir Asokan
79969306e7 bridgev2/matrix: check stream upload size after writing file 2025-06-14 12:23:36 +03:00
Tulir Asokan
c888801751 bridgev2/matrixinvite: allow redirecting DM creations to another user 2025-06-14 12:21:05 +03:00
Tulir Asokan
c540f30ef9 dependencies: update
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-12 13:32:00 +03:00
Tulir Asokan
b8921397b8 event,requests: add MSC4293 redact events field to member events
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-11 19:10:19 +03:00
Tulir Asokan
15d0b63eb6 bridgev2/provisioning: check for nil steps in submit and wait calls
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-11 15:34:34 +03:00
Tulir Asokan
1038f6a73c bridgev2: fix more background contexts 2025-06-10 19:33:02 +03:00
Tulir Asokan
9c67d238d7 bridgev2/portal: check only for me flag in delete chat events 2025-06-10 18:53:56 +03:00
Tulir Asokan
12502e213a bridgev2/userlogin: never set client to nil
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-10 17:46:23 +03:00
Tulir Asokan
99cfa0b53a bridgev2/matrixinvite: save portal after setting mxid
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-10 15:07:25 +03:00
Tulir Asokan
72bacbb666 appservice/intent: ensure registered when sending own member state event
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-09 19:30:37 +03:00
Tulir Asokan
a154718b5d
bridgev2/portal: allow specifying extra fields for portal members (#386)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-09 14:54:46 +03:00
Tulir Asokan
05f371a480 event: add membership field to unsigned 2025-06-09 14:00:13 +03:00
Tulir Asokan
8fb41765e2 event: add custom soft fail fields 2025-06-09 13:52:24 +03:00
Tulir Asokan
07567f6f96 bridgev2/portal: include room id in cross-room replies
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-08 00:12:53 +03:00
Tulir Asokan
40fd8dfcbd event/relations: use unstable prefix for reply room ID field 2025-06-08 00:05:59 +03:00
Tulir Asokan
d296f7b660 bridgev2/provisioning: ensure that Start returns a non-nil first step
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-06-06 14:08:19 +03:00
Brad Murray
d04d524209
crypto/verificationhelper: add method to verification done callback (#385)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-05 13:38:19 -04:00
Toni Spets
d228995d71
bridgev2: Configurable disconnect timeout (#383)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Let the caller decide if they want to have a timeout or not.  For
standalone bridges using the Bridge struct the behavior is kept the same
by waiting for five seconds when UserLogin DisconnectWithTimeout() is
called.
2025-06-05 07:25:48 +03:00
Brad Murray
1e10d9460a
bridgev2/status: add RESTART UserAction (#384)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-04 11:37:00 -04:00
Tulir Asokan
baf4cc3ee4 bridgev2/portal: log start time when event handling takes long 2025-06-04 16:13:16 +03:00
Tulir Asokan
d804b5d961 client: add support for stable version of room summary endpoint 2025-06-04 14:48:22 +03:00
Tulir Asokan
522a373c68 id: validate server names in UserID.ParseAndValidate
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-03 00:32:07 +03:00
Tulir Asokan
8fb04d1806 id/matrixuri: fix parsing url-encoded matrix URIs
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-06-02 20:06:52 +03:00
Tulir Asokan
788621f7e0 bridgev2/crypto: fix ghost ID format in db queries
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-31 18:03:47 +03:00
Tulir Asokan
1b1b83298c client,bridgev2: use time.After instead of sleep 2025-05-31 18:03:47 +03:00
Nick Mills-Barrett
e859fd8333
bridgev2/bridgeconfig: add missing copy for session transfer config
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-30 15:09:43 +01:00
Tulir Asokan
3473f91864 bridgev2/portal: add some default log context fields for remote events
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-29 13:58:05 +03:00
Tulir Asokan
842f21b24f bridgev2/provisioning: add log when explicitly specified login ID is not found
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-28 22:25:17 +03:00
Tulir Asokan
f73480446c mediaproxy: remove deprecated custom ResponseError struct 2025-05-28 21:39:34 +03:00
Tulir Asokan
53d027c06f appservice: replace custom response utilities with RespError and exhttp 2025-05-28 21:34:46 +03:00
Tulir Asokan
64f55ac3a7 bridgev2/provisioning: use exhttp utilities for writing responses 2025-05-28 21:24:15 +03:00
Tulir Asokan
d89130ba76 bridgev2/provisioning: fix returning wait errors
Closes #382
2025-05-28 21:12:50 +03:00
Tulir Asokan
f5746ee0f6 event: add omitempty for mod policy entity
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
Only one of hash and entity should be set
2025-05-27 18:04:56 +03:00
Tulir Asokan
a3092e5195 bridgev2/portal: don't do initial backfill in background 2025-05-27 16:45:39 +03:00
Tulir Asokan
50cc3d4d47 bridgev2/queue: fix context used for queueing remote events 2025-05-27 16:37:51 +03:00
Tulir Asokan
0589b8757b synapseadmin: fix response structs again 2025-05-27 15:57:40 +03:00
Tulir Asokan
5c8ea2c269 synapseadmin: add wrapper for room delete status 2025-05-27 15:54:46 +03:00
Tulir Asokan
8a745c0d03 bridgev2/portal: allow always using deterministic ids for replies 2025-05-27 11:37:57 +03:00
Tulir Asokan
cdb99239d3
bridgev2: add interfaces for reading up to stream order (#379) 2025-05-27 11:35:40 +03:00
Tulir Asokan
140b20cab9 id: add utilities for validating server names
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-27 12:04:13 +05:30
Tulir Asokan
34afb98ef0 event: fix parsing some url preview responses 2025-05-27 12:04:13 +05:30
Tulir Asokan
e7322f04b8 bridgev2: fix handling some cases of context cancellation 2025-05-27 09:14:25 +03:00
nexy7574
c7fbfd150f
federation/serverauth: fix URI passed to signableRequest (#381)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-26 20:37:28 +01:00
Tulir Asokan
a3d5da315f federation: use errors in signature verification 2025-05-26 21:58:35 +03:00
Tulir Asokan
92311e5c98 federation/client: fix QueryKeys return format 2025-05-26 21:14:47 +03:00
Tulir Asokan
6ed660557b federation/signingkey: store raw response for validation 2025-05-26 21:14:37 +03:00
Tulir Asokan
c5ef0f9d90 bridgev2/userlogin: ensure Client is filled in NewLogin 2025-05-26 20:24:33 +03:00
Tulir Asokan
306b48bd68 bridgev2/ghost: ensure GetGhostByID can't return nil 2025-05-26 19:34:51 +03:00
Tulir Asokan
89fad2f462 commands: add reaction button system
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-24 16:33:36 +03:00
Tulir Asokan
da9e72e616 commands: add separate field for logger in event 2025-05-24 16:29:37 +03:00
Tulir Asokan
ec15b79493 commands: add event id to logger 2025-05-24 16:29:37 +03:00
Tulir Asokan
e9dfee45c0 event: add missing letter to docstring 2025-05-24 16:29:37 +03:00
Tulir Asokan
68565a1f18 client: add wrapper for /relations endpoints 2025-05-24 16:29:37 +03:00
Tulir Asokan
50f0b5fa7d synapseadmin: add support for synchronous room delete 2025-05-24 14:43:28 +03:00
Tulir Asokan
49d2f39183 format: add markdown link utilities 2025-05-24 14:35:00 +03:00
Tulir Asokan
ad8145c43b synapseadmin: don't embed mautrix.Client in admin client struct 2025-05-24 14:23:48 +03:00
Nick Mills-Barrett
203e402ebf
bridgev2/provisioning: correct field name
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-22 15:22:13 +01:00
Nick Mills-Barrett
a3efaa3632
bridgev2/provisioning: disconnect login before exporting credentials 2025-05-22 15:20:36 +01:00
Nick Mills-Barrett
487fc699fe
bridgev2/provisioning: add session transfer support
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
For connector logins that support it this will expose an API to transfer
credentials between bridge instances.

Currently does not do any extra validation beyond the usual provisioning
API request validation (so shared secret or matrix token). One future
improvement would be to require clients to sign incoming requests, and
to then validate a) the signature and b) the device is verified.
2025-05-22 11:28:58 +01:00
Nick Mills-Barrett
a205a77db4
bridgev2: add CredentialExportingNetworkAPI interface 2025-05-22 11:28:57 +01:00
Tulir Asokan
0a8e823016 Bump version to v0.24.0
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-16 08:13:16 +03:00
Tulir Asokan
978e0983ea dependencies: update
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-15 14:15:34 +03:00
Tulir Asokan
f23fc99ef4 crypto/cross_signing: allow json marshaling cross-signing key seeds
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-10 11:35:06 +03:00
Tulir Asokan
a0191c8f58 bridgev2: expose background context
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-09 15:16:14 +03:00
Tulir Asokan
23d91b64cb bridgev2: fall back to remote ID for state update notices 2025-05-09 14:25:43 +03:00
Tulir Asokan
376fa1f368 bridgev2: fix initializing background context
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-08 15:25:20 +03:00
Nick Mills-Barrett
27769dfc98
bridgev2: add shared event handling context
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
This context is then passed into the network connectors handlers and
message conversion functions which may require making network requests,
which before this would not be canceled on bridge stop.
2025-05-07 17:30:50 +01:00
Nick Mills-Barrett
4ffe1d23e9
client: don't attempt to make requests if the homeserver URL isn't set (#376)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
Quick guard for where the client is created without using the
`NewClient` method.
2025-05-07 14:19:01 +01:00
Tulir Asokan
c93d30a83c bridgev2: add option to deduplicate Matrix messages by event or transaction ID 2025-05-07 14:47:05 +03:00
Tulir Asokan
72f6229f40 crypto: fix key export test
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-06 23:18:23 +03:00
Tulir Asokan
0ffe3524f6 crypto/sql_store: ensure forwarding chains is always set instead of having fallback in getter 2025-05-06 22:55:23 +03:00
Tulir Asokan
bef23edaea crypto/keysharing: ensure forwarding chains is always set 2025-05-06 22:50:46 +03:00
Tulir Asokan
a7faac33c8 bridgev2/portal: add fallback if last receipt target is fake 2025-05-06 20:55:26 +03:00
Tulir Asokan
37d486dfcd bridgev2/portal: ignore fake mxids when bridging read receipts 2025-05-06 20:53:00 +03:00
Tulir Asokan
ba43e615f8 bridgev2/login: add wait_for_url_pattern field to cookie logins 2025-05-06 18:49:54 +03:00
Tulir Asokan
6eb4c7b17f crypto/keybackup: allow importing room keys without saving
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-04 14:09:06 +03:00
Tulir Asokan
5cd8ba8887 federation/serverauth: fix go 1.23 compatibility
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-04 01:14:44 +03:00
Tulir Asokan
b45dcd42fc federation/serverauth: fix get requests 2025-05-04 01:09:12 +03:00
Tulir Asokan
5c2bc3b1cf mediaproxy: add option to enforce federation auth 2025-05-04 01:04:40 +03:00
Tulir Asokan
63f35754c6 federation/serverauth: store verified origin in request context 2025-05-04 01:04:12 +03:00
Tulir Asokan
0a33bde865 federation/cache: expose noop cache as variable instead of type 2025-05-04 01:00:08 +03:00
Tulir Asokan
dec68fb4d7 federation/serverauth: don't unnecessarily export errors 2025-05-04 00:49:34 +03:00
Tulir Asokan
d145f00863 federation/serverauth: cache key querying errors 2025-05-04 00:39:43 +03:00
Tulir Asokan
9a02b6428d federation/serverauth: implement server side of request authentication 2025-05-04 00:08:09 +03:00
Tulir Asokan
2d1620ded3 federation/keyserver: add support for returning other servers keys 2025-05-04 00:07:55 +03:00
Tulir Asokan
9c3e1b5904 federation/signingkey: add support for roundtripping ServerKeyResponses 2025-05-04 00:07:55 +03:00
Tulir Asokan
b1f0b1732f federation/cache: add noop cache 2025-05-04 00:07:55 +03:00
Tulir Asokan
44de13a7de federation/keyserver: use shared utilities for writing responses 2025-05-04 00:07:52 +03:00
Tulir Asokan
66e7d834cc federation/resolution: parse cache-control headers for .well-known 2025-05-04 00:07:42 +03:00
Tulir Asokan
36781e7de4 federation: move server name cache to separate type 2025-05-04 00:07:02 +03:00
Tulir Asokan
441349efac synapseadmin: add SuspendAccount method
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-03 03:00:31 +03:00
Tulir Asokan
2b973cac00 commands: include handler chain in command events
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-05-02 02:05:11 +03:00
Tulir Asokan
e491e87309 commands: panic on duplicate registration 2025-05-02 01:56:46 +03:00
Tulir Asokan
5094eea718 bridgev2/networkinterface: allow clients to generate transaction IDs
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-05-01 16:28:21 +03:00
Tulir Asokan
5c9529606e crypto/keybackup: return wrapped errors in ImportRoomKeyFromBackup 2025-05-01 15:23:31 +03:00
Tulir Asokan
69a17c6a59 bridgev2/networkinterface: remove timeout from ViewingChat 2025-05-01 15:22:47 +03:00
Tulir Asokan
58e4d0f2cc bridgev2: stop disappearing message loop on shutdown
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-30 15:33:33 +03:00
Tulir Asokan
e0b1e9b0d3 commands/event: allow overriding mentions when replying
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-29 19:59:31 +03:00
Tulir Asokan
da25a87fc1 event: clear mentions in SetEdit 2025-04-29 19:58:47 +03:00
Tulir Asokan
6c9cd6da6b commands: return event ID to allow edits 2025-04-29 18:41:37 +03:00
Tulir Asokan
771424f86b commands: stop looking for subcommands if not found 2025-04-29 17:31:27 +03:00
Tulir Asokan
db62b9a1d8 commands: ignore notices
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-29 02:27:03 +03:00
Tulir Asokan
06a292e1cc commands: add pre func for subcommand parameters 2025-04-29 02:27:03 +03:00
Nick Mills-Barrett
bf33889eab
bridgev2/userlogin: delete disappearing messages when deleting portals (#374)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-28 14:10:57 +01:00
Tulir Asokan
a121a6101c format: accept any string-like type in SafeMarkdownCode
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-28 01:00:25 +03:00
Tulir Asokan
287899435d format: add method to quote string in markdown inline code 2025-04-28 00:59:06 +03:00
Tulir Asokan
9dc0b3cddf commands: make unknown command handler more generic 2025-04-28 00:34:21 +03:00
Tulir Asokan
3badb9b332 commands: add subcommand system 2025-04-28 00:34:21 +03:00
Nick Mills-Barrett
33f3ccd6ae
crypto/verification: add missing lock in AcceptVerification method
Some checks failed
Go / Lint (latest) (push) Has been cancelled
Go / Build (old, libolm) (push) Has been cancelled
Go / Build (latest, libolm) (push) Has been cancelled
Go / Build (old, goolm) (push) Has been cancelled
Go / Build (latest, goolm) (push) Has been cancelled
2025-04-23 16:46:58 +01:00
Nick Mills-Barrett
de171e38d5
crypto/verification: use consistent action log 2025-04-23 16:46:46 +01:00
Nick Mills-Barrett
931f89202b
crypto/verification: include the incorrect state in non-ready error message
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
2025-04-23 16:30:46 +01:00
Nick Mills-Barrett
19153e3638
client: return immediately if context canceled on external upload 2025-04-23 16:30:46 +01:00
Tulir Asokan
5f4bd44baa event/voip: omit empty version field in call events 2025-04-23 15:57:35 +03:00
Tulir Asokan
3698f139b6 crypto/helper: always update crypto store device ID 2025-04-23 15:47:22 +03:00
Tulir Asokan
f931c9972d crypto/decryptolm: don't try to parse content if there is none 2025-04-23 15:29:45 +03:00
Tulir Asokan
87ca9bef1c bridgev2/networkinterface: add viewing chat callback 2025-04-23 11:29:07 +03:00
Tulir Asokan
953334a0a0 client,federation: add wrappers for /publicRooms 2025-04-21 23:43:44 +03:00
Tulir Asokan
d3d20cbcf2 client: add context parameter for setting max retries 2025-04-21 23:43:23 +03:00
271 changed files with 16825 additions and 4046 deletions

View file

@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
name: Lint (latest)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.24"
go-version: "1.26"
cache: true
- name: Install libolm
@ -24,6 +24,7 @@ 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
@ -34,14 +35,14 @@ jobs:
strategy:
fail-fast: false
matrix:
go-version: ["1.23", "1.24"]
name: Build (${{ matrix.go-version == '1.24' && 'latest' || 'old' }}, libolm)
go-version: ["1.25", "1.26"]
name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, libolm)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
@ -60,28 +61,28 @@ jobs:
- name: Test
run: go test -json -v ./... 2>&1 | gotestfmt
- name: Test (jsonv2)
env:
GOEXPERIMENT: jsonv2
run: go test -json -v ./... 2>&1 | gotestfmt
build-goolm:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.23", "1.24"]
name: Build (${{ matrix.go-version == '1.24' && 'latest' || 'old' }}, goolm)
go-version: ["1.25", "1.26"]
name: Build (${{ matrix.go-version == '1.26' && 'latest' || 'old' }}, goolm)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Set up gotestfmt
uses: GoTestTools/gotestfmt-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build
run: |
rm -rf crypto/libolm

View file

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

View file

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

View file

@ -1,3 +1,318 @@
## 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.
@ -122,6 +437,7 @@
[MSC4156]: https://github.com/matrix-org/matrix-spec-proposals/pull/4156
[MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
[#288]: https://github.com/mautrix/go/pull/288
[@onestacked]: https://github.com/onestacked
## v0.22.0 (2024-11-16)

View file

@ -1,8 +1,9 @@
# 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://matrix.org/docs/projects/client/gomuks),
[go-neb](https://github.com/matrix-org/go-neb), [mautrix-whatsapp](https://github.com/mautrix/whatsapp)
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)
and others.
Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net)
@ -13,9 +14,10 @@ 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. interactive SAS verification)
* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc)
* High-level module for building puppeting bridges
* High-level module for building chat clients
* 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
* Structs for parsing event content
* Helpers for parsing and generating Matrix HTML

View file

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

View file

@ -1,4 +1,4 @@
// Copyright (c) 2023 Tulir Asokan
// 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
@ -17,8 +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"
@ -79,17 +79,9 @@ func (as *AppService) Stop() {
func (as *AppService) CheckServerToken(w http.ResponseWriter, r *http.Request) (isValid bool) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Missing access token",
}.Write(w)
mautrix.MMissingToken.WithMessage("Missing access token").Write(w)
} else if !exstrings.ConstantTimeEqual(authHeader[len("Bearer "):], as.Registration.ServerToken) {
Error{
ErrorCode: ErrUnknownToken,
HTTPStatus: http.StatusForbidden,
Message: "Incorrect access token",
}.Write(w)
mautrix.MUnknownToken.WithMessage("Invalid access token").Write(w)
} else {
isValid = true
}
@ -102,24 +94,15 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
txnID := vars["txnID"]
txnID := r.PathValue("txnID")
if len(txnID) == 0 {
Error{
ErrorCode: ErrNoTransactionID,
HTTPStatus: http.StatusBadRequest,
Message: "Missing transaction ID",
}.Write(w)
mautrix.MInvalidParam.WithMessage("Missing transaction ID").Write(w)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil || len(body) == 0 {
Error{
ErrorCode: ErrNotJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Missing request body",
}.Write(w)
mautrix.MNotJSON.WithMessage("Failed to read response body").Write(w)
return
}
log := as.Log.With().Str("transaction_id", txnID).Logger()
@ -128,7 +111,7 @@ func (as *AppService) PutTransaction(w http.ResponseWriter, r *http.Request) {
ctx = log.WithContext(ctx)
if as.txnIDC.IsProcessed(txnID) {
// Duplicate transaction ID: no-op
WriteBlankOK(w)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
log.Debug().Msg("Ignoring duplicate transaction")
return
}
@ -137,14 +120,10 @@ 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")
Error{
ErrorCode: ErrBadJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Failed to parse body JSON",
}.Write(w)
mautrix.MBadJSON.WithMessage("Failed to parse transaction content").Write(w)
} else {
as.handleTransaction(ctx, txnID, &txn)
WriteBlankOK(w)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
}
@ -222,7 +201,7 @@ func (as *AppService) handleEvents(ctx context.Context, evts []*event.Event, def
}
err := evt.Content.ParseRaw(evt.Type)
if errors.Is(err, event.ErrUnsupportedContentType) {
log.Debug().Str("event_id", evt.ID.String()).Msg("Not parsing content of unsupported event")
log.Debug().Stringer("event_id", evt.ID).Msg("Not parsing content of unsupported event")
} else if err != nil {
log.Warn().Err(err).
Str("event_id", evt.ID.String()).
@ -259,16 +238,12 @@ func (as *AppService) GetRoom(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
roomAlias := vars["roomAlias"]
roomAlias := id.RoomAlias(r.PathValue("roomAlias"))
ok := as.QueryHandler.QueryAlias(roomAlias)
if ok {
WriteBlankOK(w)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
} else {
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
mautrix.MNotFound.WithMessage("Alias not found").Write(w)
}
}
@ -278,16 +253,12 @@ func (as *AppService) GetUser(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
userID := id.UserID(vars["userID"])
userID := id.UserID(r.PathValue("userID"))
ok := as.QueryHandler.QueryUser(userID)
if ok {
WriteBlankOK(w)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
} else {
Error{
ErrorCode: ErrUnknown,
HTTPStatus: http.StatusNotFound,
}.Write(w)
mautrix.MNotFound.WithMessage("User not found").Write(w)
}
}
@ -297,11 +268,7 @@ 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) {
Error{
ErrorCode: ErrNotJSON,
HTTPStatus: http.StatusBadRequest,
Message: "Missing request body",
}.Write(w)
mautrix.MNotJSON.WithMessage("Invalid or missing request body").Write(w)
return
}
@ -309,27 +276,21 @@ 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")
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
func (as *AppService) GetLive(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
if as.Live {
w.WriteHeader(http.StatusOK)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
} else {
w.WriteHeader(http.StatusInternalServerError)
exhttp.WriteEmptyJSONResponse(w, 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 {
w.WriteHeader(http.StatusOK)
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
} else {
w.WriteHeader(http.StatusInternalServerError)
exhttp.WriteEmptyJSONResponse(w, http.StatusInternalServerError)
}
w.Write([]byte("{}"))
}

View file

@ -51,7 +51,7 @@ func (as *AppService) NewIntentAPI(localpart string) *IntentAPI {
}
func (intent *IntentAPI) Register(ctx context.Context) error {
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister{
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{
Username: intent.Localpart,
Type: mautrix.AuthTypeAppservice,
InhibitLogin: true,
@ -86,6 +86,7 @@ func (intent *IntentAPI) EnsureRegistered(ctx context.Context) error {
type EnsureJoinedParams struct {
IgnoreCache bool
BotOverride *mautrix.Client
Via []string
}
func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...EnsureJoinedParams) error {
@ -99,11 +100,17 @@ func (intent *IntentAPI) EnsureJoined(ctx context.Context, roomID id.RoomID, ext
return nil
}
if err := intent.EnsureRegistered(ctx); err != nil {
err := intent.EnsureRegistered(ctx)
if err != nil {
return fmt.Errorf("failed to ensure joined: %w", err)
}
resp, err := intent.JoinRoomByID(ctx, roomID)
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)
}
if err != nil {
bot := intent.bot
if params.BotOverride != nil {
@ -207,38 +214,45 @@ func (intent *IntentAPI) AddDoublePuppetValueWithTS(into any, ts int64) any {
}
}
func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
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 {
return nil, err
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...)
}
func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
func (intent *IntentAPI) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts)
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
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...)
}
func (intent *IntentAPI) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) (*mautrix.RespSendEvent, error) {
// 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) {
if eventType != event.StateMember || stateKey != string(intent.UserID) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON)
}
func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
} else if err := intent.EnsureRegistered(ctx); err != nil {
return nil, err
}
contentJSON = intent.AddDoublePuppetValueWithTS(contentJSON, ts)
return intent.Client.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, contentJSON, ts)
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, extra...)
}
// Deprecated: use SendStateEvent with mautrix.ReqSendEvent.Timestamp instead
func (intent *IntentAPI) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
return intent.SendStateEvent(ctx, roomID, eventType, stateKey, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})
}
func (intent *IntentAPI) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) error {
@ -297,7 +311,7 @@ func (intent *IntentAPI) SendCustomMembershipEvent(ctx context.Context, roomID i
func (intent *IntentAPI) JoinRoomByID(ctx context.Context, roomID id.RoomID, extraContent ...map[string]interface{}) (resp *mautrix.RespJoinRoom, err error) {
if intent.IsCustomPuppet || len(extraContent) > 0 {
_, err = intent.SendCustomMembershipEvent(ctx, roomID, intent.UserID, event.MembershipJoin, "", extraContent...)
return &mautrix.RespJoinRoom{}, err
return &mautrix.RespJoinRoom{RoomID: roomID}, err
}
return intent.Client.JoinRoomByID(ctx, roomID)
}
@ -366,6 +380,24 @@ func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id
return member
}
func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error {
if pl.CreateEvent != nil {
return nil
}
var err error
pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to get create event from cache: %w", err)
} else if pl.CreateEvent != nil {
return nil
}
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
if err != nil {
return fmt.Errorf("failed to get create event from server: %w", err)
}
return nil
}
func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) {
pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID)
if err != nil {
@ -375,6 +407,12 @@ func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl
if pl == nil {
pl = &event.PowerLevelsEventContent{}
err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl)
if err != nil {
return
}
}
if pl.CreateEvent == nil {
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
}
return
}
@ -389,8 +427,7 @@ func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, us
return nil, err
}
if pl.GetUserLevel(userID) != level {
pl.SetUserLevel(userID, level)
if pl.EnsureUserLevelAs(intent.UserID, userID, level) {
return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl)
}
return nil, nil
@ -479,7 +516,7 @@ func (intent *IntentAPI) SetAvatarURL(ctx context.Context, avatarURL id.ContentU
// No need to update
return nil
}
if !avatarURL.IsEmpty() {
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 {

68
appservice/ping.go Normal file
View file

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

View file

@ -1,4 +1,4 @@
// Copyright (c) 2023 Tulir Asokan
// 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
@ -11,26 +11,26 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"path"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/coder/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 interface{} `json:"data"`
Deadline time.Duration `json:"-"`
ReqID int `json:"id,omitempty"`
Command string `json:"command"`
Data any `json:"data"`
}
type WebsocketCommand struct {
@ -41,7 +41,7 @@ type WebsocketCommand struct {
Ctx context.Context `json:"-"`
}
func (wsc *WebsocketCommand) MakeResponse(ok bool, data interface{}) *WebsocketRequest {
func (wsc *WebsocketCommand) MakeResponse(ok bool, data any) *WebsocketRequest {
if wsc.ReqID == 0 || wsc.Command == "response" || wsc.Command == "error" {
return nil
}
@ -56,7 +56,7 @@ func (wsc *WebsocketCommand) MakeResponse(ok bool, data interface{}) *WebsocketR
var prefixMessage string
for unwrappedErr != nil {
errorData, jsonErr = json.Marshal(unwrappedErr)
if errorData != nil && len(errorData) > 2 && jsonErr == nil {
if 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 = 4001
WebsocketCloseTxnNotAcknowledged = 4002
WebsocketCloseConnReplaced websocket.StatusCode = 4001
WebsocketCloseTxnNotAcknowledged websocket.StatusCode = 4002
)
type MeowWebsocketCloseCode string
@ -133,7 +133,7 @@ func (mwcc MeowWebsocketCloseCode) String() string {
}
type CloseCommand struct {
Code int `json:"-"`
Code websocket.StatusCode `json:"-"`
Command string `json:"command"`
Status MeowWebsocketCloseCode `json:"status"`
}
@ -143,15 +143,15 @@ func (cc CloseCommand) Error() string {
}
func parseCloseError(err error) error {
closeError := &websocket.CloseError{}
var closeError websocket.CloseError
if !errors.As(err, &closeError) {
return err
}
var closeCommand CloseCommand
closeCommand.Code = closeError.Code
closeCommand.Command = "disconnect"
if len(closeError.Text) > 0 {
jsonErr := json.Unmarshal([]byte(closeError.Text), &closeCommand)
if len(closeError.Reason) > 0 {
jsonErr := json.Unmarshal([]byte(closeError.Reason), &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.CloseServiceRestart {
} else if closeCommand.Code == websocket.StatusServiceRestart {
closeCommand.Status = MeowServerShuttingDown
}
}
@ -170,20 +170,23 @@ func (as *AppService) HasWebsocket() bool {
return as.ws != nil
}
func (as *AppService) SendWebsocket(cmd *WebsocketRequest) error {
func (as *AppService) SendWebsocket(ctx context.Context, cmd *WebsocketRequest) error {
ws := as.ws
if cmd == nil {
return nil
} else if ws == nil {
return ErrWebsocketNotConnected
}
as.wsWriteLock.Lock()
defer as.wsWriteLock.Unlock()
if cmd.Deadline == 0 {
cmd.Deadline = 3 * time.Minute
wr, err := ws.Writer(ctx, websocket.MessageText)
if err != nil {
return err
}
_ = ws.SetWriteDeadline(time.Now().Add(cmd.Deadline))
return ws.WriteJSON(cmd)
err = json.NewEncoder(wr).Encode(cmd)
if err != nil {
_ = wr.Close()
return err
}
return wr.Close()
}
func (as *AppService) clearWebsocketResponseWaiters() {
@ -220,12 +223,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 interface{}) error {
func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketRequest, response any) 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(cmd)
err := as.SendWebsocket(ctx, cmd)
if err != nil {
return err
}
@ -254,7 +257,7 @@ func (as *AppService) RequestWebsocket(ctx context.Context, cmd *WebsocketReques
}
}
func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, interface{}) {
func (as *AppService) unknownCommandHandler(cmd WebsocketCommand) (bool, any) {
zerolog.Ctx(cmd.Ctx).Warn().Msg("No handler for websocket command")
return false, fmt.Errorf("unknown request type")
}
@ -278,14 +281,28 @@ func (as *AppService) defaultHandleWebsocketTransaction(ctx context.Context, msg
return true, &WebsocketTransactionResponse{TxnID: msg.TxnID}
}
func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn) {
func (as *AppService) consumeWebsocket(ctx context.Context, stopFunc func(error), ws *websocket.Conn) {
defer stopFunc(ErrWebsocketUnknownError)
ctx := context.Background()
for {
var msg WebsocketMessage
err := ws.ReadJSON(&msg)
msgType, reader, err := ws.Reader(ctx)
if err != nil {
as.Log.Debug().Err(err).Msg("Error reading from websocket")
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)
if err != nil {
as.Log.Debug().Err(err).Msg("Error parsing JSON received from websocket")
stopFunc(parseCloseError(err))
return
}
@ -296,11 +313,11 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn)
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(msg.MakeResponse(ok, resp))
err := as.SendWebsocket(ctx, msg.MakeResponse(ok, resp))
if err != nil {
log.Warn().Err(err).Msg("Failed to send response to websocket transaction")
} else {
@ -332,7 +349,7 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn)
}
go func() {
okResp, data := handler(msg.WebsocketCommand)
err := as.SendWebsocket(msg.MakeResponse(okResp, data))
err := as.SendWebsocket(ctx, msg.MakeResponse(okResp, data))
if err != nil {
log.Error().Err(err).Msg("Failed to send response to websocket command")
} else if okResp {
@ -345,7 +362,7 @@ func (as *AppService) consumeWebsocket(stopFunc func(error), ws *websocket.Conn)
}
}
func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error {
func (as *AppService) StartWebsocket(ctx context.Context, baseURL string, onConnect func()) error {
var parsed *url.URL
if baseURL != "" {
var err error
@ -357,26 +374,29 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error {
copiedURL := *as.hsURLForClient
parsed = &copiedURL
}
parsed.Path = filepath.Join(parsed.Path, "_matrix/client/unstable/fi.mau.as_sync")
parsed.Path = path.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.DefaultDialer.Dial(parsed.String(), http.Header{
"Authorization": []string{fmt.Sprintf("Bearer %s", as.Registration.AppToken)},
"User-Agent": []string{as.BotClient().UserAgent},
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},
"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 Error
var errResp mautrix.RespError
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.ErrorCode, resp.StatusCode, errResp.Message)
return fmt.Errorf("websocket request returned %s (HTTP %d): %s", errResp.ErrCode, resp.StatusCode, errResp.Err)
}
} else if err != nil {
return fmt.Errorf("failed to open websocket: %w", err)
@ -399,12 +419,13 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error {
}
})
}
ws.SetReadLimit(50 * 1024 * 1024)
as.ws = ws
as.StopWebsocket = stopFunc
as.PrepareWebsocket()
as.Log.Debug().Msg("Appservice transaction websocket opened")
go as.consumeWebsocket(stopFunc, ws)
go as.consumeWebsocket(ctx, stopFunc, ws)
var onConnectDone atomic.Bool
if onConnect != nil {
@ -426,12 +447,7 @@ func (as *AppService) StartWebsocket(baseURL string, onConnect func()) error {
as.ws = nil
}
_ = 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()
err = ws.Close(websocket.StatusGoingAway, "")
if err != nil {
as.Log.Warn().Err(err).Msg("Error closing websocket")
}

View file

@ -9,11 +9,14 @@ 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"
@ -51,9 +54,13 @@ type Bridge struct {
Background bool
ExternallyManagedDB bool
stopping atomic.Bool
wakeupBackfillQueue chan struct{}
stopBackfillQueue *exsync.Event
BackgroundCtx context.Context
cancelBackgroundCtx context.CancelFunc
}
func NewBridge(
@ -117,12 +124,13 @@ func (br *Bridge) Start(ctx context.Context) error {
if err != nil {
return err
}
br.PostStart(ctx)
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
@ -134,7 +142,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa
if err != nil {
return err
}
defer br.Stop()
defer br.StopWithTimeout(5 * time.Second)
select {
case <-time.After(20 * time.Second):
case <-ctx.Done():
@ -142,7 +150,7 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa
return nil
}
defer br.stop(true)
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)
@ -153,11 +161,12 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa
if !ok {
br.Log.Warn().Msg("Network connector doesn't implement background mode, using fallback mechanism for RunOnce")
login.Client.Connect(ctx)
defer login.Disconnect(nil)
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")
@ -167,6 +176,11 @@ func (br *Bridge) RunOnce(ctx context.Context, loginID networkid.UserLoginID, pa
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)
@ -175,7 +189,11 @@ func (br *Bridge) StartConnectors(ctx context.Context) error {
}
}
if !br.Background {
br.didSplitPortals = br.MigrateToSplitPortals(ctx)
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)
@ -264,20 +282,64 @@ func (br *Bridge) ResendBridgeInfo(ctx context.Context, resendInfo, resendCaps b
Msg("Resent bridge info to all portals")
}
func (br *Bridge) MigrateToSplitPortals(ctx context.Context) bool {
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
return false, nil
}
affected, err := br.DB.Portal.MigrateToSplitPortals(ctx)
if err != nil {
log.Err(err).Msg("Failed to migrate portals")
return false
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")
return affected > 0
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 {
@ -312,12 +374,58 @@ func (br *Bridge) StartLogins(ctx context.Context) error {
return nil
}
func (br *Bridge) Stop() {
br.stop(false)
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) stop(isRunOnce bool) {
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 {
@ -325,12 +433,18 @@ func (br *Bridge) stop(isRunOnce bool) {
var wg sync.WaitGroup
wg.Add(len(br.userLoginsByID))
for _, login := range br.userLoginsByID {
go login.Disconnect(wg.Done)
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()
}

View file

@ -34,10 +34,12 @@ type BackfillQueueConfig struct {
MaxBatchesOverride map[string]int `yaml:"max_batches_override"`
}
func (bqc *BackfillQueueConfig) GetOverride(name string) int {
override, ok := bqc.MaxBatchesOverride[name]
if !ok {
return bqc.MaxBatches
func (bqc *BackfillQueueConfig) GetOverride(names ...string) int {
for _, name := range names {
override, ok := bqc.MaxBatchesOverride[name]
if ok {
return override
}
}
return override
return bqc.MaxBatches
}

View file

@ -7,6 +7,8 @@
package bridgeconfig
import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/zeroconfig"
"gopkg.in/yaml.v3"
@ -31,6 +33,8 @@ type Config struct {
Encryption EncryptionConfig `yaml:"encryption"`
Logging zeroconfig.Config `yaml:"logging"`
EnvConfigPrefix string `yaml:"env_config_prefix"`
ManagementRoomTexts ManagementRoomTexts `yaml:"management_room_texts"`
}
@ -58,33 +62,40 @@ type CleanupOnLogouts struct {
}
type BridgeConfig struct {
CommandPrefix string `yaml:"command_prefix"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
AsyncEvents bool `yaml:"async_events"`
SplitPortals bool `yaml:"split_portals"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"`
BridgeStatusNotices string `yaml:"bridge_status_notices"`
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"`
OutgoingMessageReID bool `yaml:"outgoing_message_re_id"`
CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"`
Relay RelayConfig `yaml:"relay"`
Permissions PermissionConfig `yaml:"permissions"`
Backfill BackfillConfig `yaml:"backfill"`
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"`
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 {
@ -94,9 +105,9 @@ type AnalyticsConfig struct {
}
type ProvisioningConfig struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
EnableSessionTransfers bool `yaml:"enable_session_transfers"`
}
type DirectMediaConfig struct {
@ -106,10 +117,12 @@ type DirectMediaConfig struct {
}
type PublicMediaConfig struct {
Enabled bool `yaml:"enabled"`
SigningKey string `yaml:"signing_key"`
HashLength int `yaml:"hash_length"`
Expiry int `yaml:"expiry"`
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 {

View file

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

View file

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

View file

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

View file

@ -32,11 +32,17 @@ func doUpgrade(helper up.Helper) {
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")
@ -95,12 +101,12 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Bool, "matrix", "sync_direct_chat_list")
helper.Copy(up.Bool, "matrix", "federate_rooms")
helper.Copy(up.Int, "matrix", "upload_file_threshold")
helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info")
helper.Copy(up.Str|up.Null, "analytics", "token")
helper.Copy(up.Str|up.Null, "analytics", "url")
helper.Copy(up.Str|up.Null, "analytics", "user_id")
helper.Copy(up.Str, "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "provisioning", "shared_secret")
@ -108,6 +114,7 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Str, "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "provisioning", "debug_endpoints")
helper.Copy(up.Bool, "provisioning", "enable_session_transfers")
helper.Copy(up.Bool, "direct_media", "enabled")
helper.Copy(up.Str|up.Null, "direct_media", "media_id_prefix")
@ -129,6 +136,8 @@ func doUpgrade(helper up.Helper) {
}
helper.Copy(up.Int, "public_media", "expiry")
helper.Copy(up.Int, "public_media", "hash_length")
helper.Copy(up.Str|up.Null, "public_media", "path_prefix")
helper.Copy(up.Bool, "public_media", "use_database")
helper.Copy(up.Bool, "backfill", "enabled")
helper.Copy(up.Int, "backfill", "max_initial_messages")
@ -154,6 +163,8 @@ func doUpgrade(helper up.Helper) {
} 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")
@ -176,6 +187,8 @@ func doUpgrade(helper up.Helper) {
helper.Copy(up.Int, "encryption", "rotation", "messages")
helper.Copy(up.Bool, "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Str|up.Null, "env_config_prefix")
helper.Copy(up.Map, "logging")
}
@ -203,6 +216,7 @@ var SpacedBlocks = [][]string{
{"backfill"},
{"double_puppet"},
{"encryption"},
{"env_config_prefix"},
{"logging"},
}

View file

@ -9,16 +9,21 @@ 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
@ -26,6 +31,14 @@ type BridgeStateQueue struct {
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) {
@ -47,41 +60,85 @@ func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
func (br *Bridge) NewBridgeStateQueue(login *UserLogin) *BridgeStateQueue {
bsq := &BridgeStateQueue{
ch: make(chan status.BridgeState, 10),
bridge: br,
login: login,
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() {
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")
}
}()
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) sendNotice(ctx context.Context, state status.BridgeState) {
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
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)
@ -89,10 +146,17 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge
bsq.login.Log.Err(err).Msg("Failed to get management room")
return
}
message := fmt.Sprintf("State update for %s: `%s`", bsq.login.RemoteName, state.StateEvent)
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)
}
@ -113,6 +177,80 @@ func (bsq *BridgeStateQueue) sendNotice(ctx context.Context, state status.Bridge
}
}
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().
@ -120,9 +258,12 @@ func (bsq *BridgeStateQueue) immediateSendBridgeState(state status.BridgeState)
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)
bsq.sendNotice(ctx, state, false)
retryIn := 2
for {

View file

@ -7,10 +7,13 @@
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{
@ -59,3 +62,64 @@ var CommandRegisterPush = &FullHandler{
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

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

View file

@ -41,10 +41,11 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor {
}
proc.AddHandlers(
CommandHelp, CommandCancel,
CommandRegisterPush, CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom,
CommandRegisterPush, CommandSendAccountData, CommandResetNetwork,
CommandDeletePortal, CommandDeleteAllPortals, CommandSetManagementRoom,
CommandLogin, CommandRelogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin,
CommandSetRelay, CommandUnsetRelay,
CommandResolveIdentifier, CommandStartChat, CommandSearch,
CommandResolveIdentifier, CommandStartChat, CommandCreateGroup, CommandSearch, CommandSyncChat, CommandMute,
CommandSudo, CommandDoIn,
)
return proc

View file

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

View file

@ -1,4 +1,4 @@
// Copyright (c) 2024 Tulir Asokan
// 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
@ -8,13 +8,21 @@ 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"
)
@ -30,6 +38,35 @@ var CommandResolveIdentifier = &FullHandler{
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",
@ -43,9 +80,15 @@ var CommandStartChat = &FullHandler{
NetworkAPI: NetworkAPIImplements[bridgev2.IdentifierResolvingNetworkAPI],
}
func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Event, thing string) (*bridgev2.UserLogin, T, []string) {
remainingArgs := ce.Args[1:]
login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
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()
@ -57,24 +100,13 @@ func getClientForStartingChat[T bridgev2.IdentifierResolvingNetworkAPI](ce *Even
return login, api, remainingArgs
}
func formatResolveIdentifierResult(ctx context.Context, resp *bridgev2.ResolveIdentifierResponse) string {
var targetName string
var targetMXID id.UserID
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(ctx, resp.UserInfo)
}
targetName = resp.Ghost.Name
targetMXID = resp.Ghost.Intent.GetMXID()
} else if resp.UserInfo != nil && resp.UserInfo.Name != nil {
targetName = *resp.UserInfo.Name
}
if targetMXID != "" {
return fmt.Sprintf("`%s` / [%s](%s)", resp.UserID, targetName, targetMXID.URI().MatrixToURL())
} else if targetName != "" {
return fmt.Sprintf("`%s` / %s", resp.UserID, targetName)
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.UserID)
return fmt.Sprintf("`%s`", resp.ID)
}
}
@ -87,65 +119,137 @@ func fnResolveIdentifier(ce *Event) {
if api == nil {
return
}
allLogins := ce.User.GetUserLogins()
createChat := ce.Command == "start-chat" || ce.Command == "pm"
identifier := strings.Join(identifierParts, " ")
resp, err := api.ResolveIdentifier(ce.Ctx, identifier, createChat)
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.Log.Err(err).Msg("Failed to resolve identifier")
ce.Reply("Failed to resolve identifier: %v", err)
return
} else if resp == nil {
ce.ReplyAdvanced(fmt.Sprintf("Identifier <code>%s</code> not found", html.EscapeString(identifier)), false, true)
return
}
formattedName := formatResolveIdentifierResult(ce.Ctx, resp)
formattedName := formatResolveIdentifierResult(resp)
if createChat {
if resp.Chat == nil {
ce.Reply("Interface error: network connector did not return chat for create chat request")
return
name := resp.Portal.Name
if name == "" {
name = resp.Portal.MXID.String()
}
portal := resp.Chat.Portal
if portal == nil {
portal, err = ce.Bridge.GetPortalByKey(ce.Ctx, resp.Chat.PortalKey)
if err != nil {
ce.Log.Err(err).Msg("Failed to get portal")
ce.Reply("Failed to get portal: %v", err)
return
}
}
if resp.Chat.PortalInfo == nil {
resp.Chat.PortalInfo, err = api.GetChatInfo(ce.Ctx, portal)
if err != nil {
ce.Log.Err(err).Msg("Failed to get portal info")
ce.Reply("Failed to get portal info: %v", err)
return
}
}
if portal.MXID != "" {
name := portal.Name
if name == "" {
name = portal.MXID.String()
}
portal.UpdateInfo(ce.Ctx, resp.Chat.PortalInfo, login, nil, time.Time{})
ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL())
if !resp.JustCreated {
ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, resp.Portal.MXID.URI().MatrixToURL())
} else {
err = portal.CreateMatrixRoom(ce.Ctx, login, resp.Chat.PortalInfo)
if err != nil {
ce.Log.Err(err).Msg("Failed to create room")
ce.Reply("Failed to create room: %v", err)
return
}
name := portal.Name
if name == "" {
name = portal.MXID.String()
}
ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL())
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",
@ -163,35 +267,67 @@ func fnSearch(ce *Event) {
ce.Reply("Usage: `$cmdprefix search <query>`")
return
}
_, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users")
login, api, queryParts := getClientForStartingChat[bridgev2.UserSearchingNetworkAPI](ce, "searching users")
if api == nil {
return
}
results, err := api.SearchUsers(ce.Ctx, strings.Join(queryParts, " "))
resp, err := provisionutil.SearchUsers(ce.Ctx, login, strings.Join(queryParts, " "))
if err != nil {
ce.Log.Err(err).Msg("Failed to search for users")
ce.Reply("Failed to search for users: %v", err)
return
}
resultsString := make([]string, len(results))
for i, res := range results {
formattedName := formatResolveIdentifierResult(ce.Ctx, res)
resultsString := make([]string, len(resp.Results))
for i, res := range resp.Results {
formattedName := formatResolveIdentifierResult(res)
resultsString[i] = fmt.Sprintf("* %s", formattedName)
if res.Chat != nil {
if res.Chat.Portal == nil {
res.Chat.Portal, err = ce.Bridge.GetExistingPortalByKey(ce.Ctx, res.Chat.PortalKey)
if err != nil {
ce.Log.Err(err).Object("portal_key", res.Chat.PortalKey).Msg("Failed to get DM portal")
}
}
if res.Chat.Portal != nil && res.Chat.Portal.MXID != "" {
portalName := res.Chat.Portal.Name
if portalName == "" {
portalName = res.Chat.Portal.MXID.String()
}
resultsString[i] = fmt.Sprintf("%s - DM portal: [%s](%s)", resultsString[i], portalName, res.Chat.Portal.MXID.URI().MatrixToURL())
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

@ -78,6 +78,11 @@ const (
dispatched_at=$9, completed_at=$10, next_dispatch_min_ts=$11
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3
`
markBackfillTaskNotDoneQuery = `
UPDATE backfill_task
SET is_done = false
WHERE bridge_id = $1 AND portal_id = $2 AND portal_receiver = $3 AND user_login_id = $4
`
getNextBackfillQuery = `
SELECT
bridge_id, portal_id, portal_receiver, user_login_id, batch_count, is_done,
@ -127,6 +132,10 @@ func (btq *BackfillTaskQuery) Update(ctx context.Context, bq *BackfillTask) erro
return btq.Exec(ctx, updateBackfillQueueQuery, bq.sqlVariables()...)
}
func (btq *BackfillTaskQuery) MarkNotDone(ctx context.Context, portalKey networkid.PortalKey, userLoginID networkid.UserLoginID) error {
return btq.Exec(ctx, markBackfillTaskNotDoneQuery, btq.BridgeID, portalKey.ID, portalKey.Receiver, userLoginID)
}
func (btq *BackfillTaskQuery) GetNext(ctx context.Context) (*BackfillTask, error) {
return btq.QueryOne(ctx, getNextBackfillQuery, btq.BridgeID, time.Now().UnixNano())
}

View file

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

View file

@ -12,56 +12,94 @@ import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// DisappearingType represents the type of a disappearing message timer.
type DisappearingType string
// Deprecated: use [event.DisappearingType]
type DisappearingType = event.DisappearingType
// Deprecated: use constants in event package
const (
DisappearingTypeNone DisappearingType = ""
DisappearingTypeAfterRead DisappearingType = "after_read"
DisappearingTypeAfterSend DisappearingType = "after_send"
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 DisappearingType
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
BridgeID networkid.BridgeID
RoomID id.RoomID
EventID id.EventID
Timestamp time.Time
DisappearingSetting
}
const (
upsertDisappearingMessageQuery = `
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
VALUES ($1, $2, $3, $4, $5, $6)
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'
RETURNING bridge_id, mx_room, mxid, type, timer, disappear_at
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, type, timer, disappear_at
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
ORDER BY disappear_at LIMIT $3
`
deleteDisappearingMessageQuery = `
DELETE FROM disappearing_message WHERE bridge_id=$1 AND mxid=$2
@ -73,12 +111,12 @@ func (dmq *DisappearingMessageQuery) Put(ctx context.Context, dm *DisappearingMe
return dmq.Exec(ctx, upsertDisappearingMessageQuery, dm.sqlVariables()...)
}
func (dmq *DisappearingMessageQuery) StartAll(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, startDisappearingMessagesQuery, time.Now().UnixNano(), dmq.BridgeID, roomID)
func (dmq *DisappearingMessageQuery) 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) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getUpcomingDisappearingMessagesQuery, dmq.BridgeID, time.Now().Add(duration).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 {
@ -86,17 +124,19 @@ func (dmq *DisappearingMessageQuery) Delete(ctx context.Context, eventID id.Even
}
func (d *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
var timestamp int64
var disappearAt sql.NullInt64
err := row.Scan(&d.BridgeID, &d.RoomID, &d.EventID, &d.Type, &d.Timer, &disappearAt)
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.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)}
return []any{d.BridgeID, d.RoomID, d.EventID, d.Timestamp.UnixNano(), d.Type, d.Timer, dbutil.ConvertedPtr(d.DisappearAt, time.Time.UnixNano)}
}

View file

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

View file

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

View file

@ -11,9 +11,12 @@ import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
@ -24,6 +27,7 @@ type MessageQuery struct {
BridgeID networkid.BridgeID
MetaType MetaTypeCreator
*dbutil.QueryHelper[*Message]
chunkDeleteLock sync.Mutex
}
type Message struct {
@ -43,28 +47,33 @@ type Message struct {
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, metadata
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 timestamp ASC, part_id ASC LIMIT 1`
getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp DESC, part_id DESC LIMIT 1`
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`
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
@ -73,16 +82,17 @@ const (
insertMessageQuery = `
INSERT INTO message (
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid,
timestamp, edit_count, double_puppeted, thread_root_id, reply_to_id, reply_to_part_id, metadata
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)
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, metadata=$15
WHERE bridge_id=$1 AND rowid=$16
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
@ -90,6 +100,10 @@ const (
deleteMessagePartByRowIDQuery = `
DELETE FROM message WHERE bridge_id=$1 AND rowid=$2
`
deleteMessageChunkQuery = `
DELETE FROM message WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND rowid > $4 AND rowid <= $5
`
getMaxMessageRowIDQuery = `SELECT MAX(rowid) FROM message WHERE bridge_id=$1`
)
func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) ([]*Message, error) {
@ -104,6 +118,10 @@ func (mq *MessageQuery) GetPartByMXID(ctx context.Context, mxid id.EventID) (*Me
return mq.QueryOne(ctx, getMessageByMXIDQuery, mq.BridgeID, mxid)
}
func (mq *MessageQuery) GetPartByTxnID(ctx context.Context, receiver networkid.UserLoginID, mxid id.EventID, txnID networkid.RawTransactionID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByTxnIDQuery, mq.BridgeID, receiver, mxid, txnID)
}
func (mq *MessageQuery) GetLastPartByID(ctx context.Context, receiver networkid.UserLoginID, id networkid.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getLastMessagePartByIDQuery, mq.BridgeID, receiver, id)
}
@ -128,6 +146,10 @@ func (mq *MessageQuery) GetLastPartAtOrBeforeTime(ctx context.Context, portal ne
return mq.QueryOne(ctx, getLastMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano())
}
func (mq *MessageQuery) GetLastNonFakePartAtOrBeforeTime(ctx context.Context, portal networkid.PortalKey, maxTS time.Time) (*Message, error) {
return mq.QueryOne(ctx, getLastNonFakeMessagePartAtOrBeforeTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, maxTS.UnixNano())
}
func (mq *MessageQuery) GetMessagesBetweenTimeQuery(ctx context.Context, portal networkid.PortalKey, start, end time.Time) ([]*Message, error) {
return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, mq.BridgeID, portal.ID, portal.Receiver, start.UnixNano(), end.UnixNano())
}
@ -166,6 +188,85 @@ 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
@ -173,11 +274,12 @@ func (mq *MessageQuery) CountMessagesInPortal(ctx context.Context, key networkid
func (m *Message) Scan(row dbutil.Scannable) (*Message, error) {
var timestamp int64
var threadRootID, replyToID, replyToPartID sql.NullString
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, dbutil.JSON{Data: m.Metadata},
&timestamp, &m.EditCount, &doublePuppeted, &threadRootID, &replyToID, &replyToPartID, &sendTxnID,
dbutil.JSON{Data: m.Metadata},
)
if err != nil {
return nil, err
@ -191,6 +293,9 @@ func (m *Message) Scan(row dbutil.Scannable) (*Message, error) {
m.ReplyTo.PartID = (*networkid.PartID)(&replyToPartID.String)
}
}
if sendTxnID.Valid {
m.SendTxnID = networkid.RawTransactionID(sendTxnID.String)
}
return m, nil
}
@ -205,7 +310,8 @@ 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.JSON{Data: m.Metadata},
dbutil.StrPtr(m.ReplyTo.MessageID), m.ReplyTo.PartID, dbutil.StrPtr(m.SendTxnID),
dbutil.JSON{Data: m.Metadata},
}
}
@ -214,6 +320,9 @@ func (m *Message) updateSQLVariables() []any {
}
const FakeMXIDPrefix = "~fake:"
const TxnMXIDPrefix = "~txn:"
const NetworkTxnMXIDPrefix = TxnMXIDPrefix + "network:"
const RandomTxnMXIDPrefix = TxnMXIDPrefix + "random:"
func (m *Message) SetFakeMXID() {
hash := sha256.Sum256([]byte(m.ID))

View file

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

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

View file

@ -1,4 +1,4 @@
-- v0 -> v20 (compatible with v9+): Latest revision
-- v0 -> v27 (compatible with v9+): Latest revision
CREATE TABLE "user" (
bridge_id TEXT NOT NULL,
mxid TEXT NOT NULL,
@ -48,6 +48,7 @@ CREATE TABLE portal (
topic_set BOOLEAN NOT NULL,
name_is_custom BOOLEAN NOT NULL DEFAULT false,
in_space BOOLEAN NOT NULL,
message_request BOOLEAN NOT NULL DEFAULT false,
room_type TEXT NOT NULL,
disappear_type TEXT,
disappear_timer BIGINT,
@ -63,6 +64,8 @@ CREATE TABLE portal (
REFERENCES user_login (bridge_id, id)
ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX portal_bridge_mxid_idx ON portal (bridge_id, mxid);
CREATE INDEX portal_parent_idx ON portal (bridge_id, parent_id, parent_receiver);
CREATE TABLE ghost (
bridge_id TEXT NOT NULL,
@ -77,6 +80,7 @@ CREATE TABLE ghost (
contact_info_set BOOLEAN NOT NULL,
is_bot BOOLEAN NOT NULL,
identifiers jsonb NOT NULL,
extra_profile jsonb,
metadata jsonb NOT NULL,
PRIMARY KEY (bridge_id, id)
@ -107,6 +111,7 @@ CREATE TABLE message (
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)
@ -116,7 +121,8 @@ CREATE TABLE message (
REFERENCES ghost (bridge_id, id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT message_real_pkey UNIQUE (bridge_id, room_receiver, id, part_id),
CONSTRAINT message_mxid_unique UNIQUE (bridge_id, mxid)
CONSTRAINT message_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);
@ -124,12 +130,18 @@ 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)
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,
@ -208,3 +220,14 @@ CREATE TABLE kv_store (
PRIMARY KEY (bridge_id, key)
);
CREATE TABLE public_media (
bridge_id TEXT NOT NULL,
public_id TEXT NOT NULL,
mxc TEXT NOT NULL,
keys jsonb,
mimetype TEXT,
expiry BIGINT,
PRIMARY KEY (bridge_id, public_id)
);

View file

@ -0,0 +1,8 @@
-- 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

@ -0,0 +1,24 @@
-- 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

@ -0,0 +1,6 @@
-- 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

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

View file

@ -0,0 +1,11 @@
-- 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

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

View file

@ -0,0 +1,3 @@
-- 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

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

View file

@ -116,7 +116,7 @@ func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin {
func (u *UserLogin) sqlVariables() []any {
var remoteProfile dbutil.JSON
if !u.RemoteProfile.IsEmpty() {
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import (
"fmt"
"os"
"runtime/debug"
"strings"
"sync"
"time"
@ -23,6 +24,7 @@ import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/event"
@ -36,9 +38,9 @@ func init() {
var _ crypto.StateStore = (*sqlstatestore.SQLStateStore)(nil)
var NoSessionFound = crypto.NoSessionFound
var DuplicateMessageIndex = crypto.DuplicateMessageIndex
var UnknownMessageIndex = olm.UnknownMessageIndex
var NoSessionFound = crypto.ErrNoSessionFound
var DuplicateMessageIndex = crypto.ErrDuplicateMessageIndex
var UnknownMessageIndex = olm.ErrUnknownMessageIndex
type CryptoHelper struct {
bridge *Connector
@ -77,7 +79,7 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()),
string(helper.bridge.Bridge.ID),
helper.bridge.AS.BotMXID(),
fmt.Sprintf("@%s:%s", helper.bridge.Config.AppService.FormatUsername("%"), helper.bridge.AS.HomeserverDomain),
fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain),
helper.bridge.Config.Encryption.PickleKey,
)
@ -134,7 +136,19 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
return err
}
if isExistingDevice {
helper.verifyKeysAreOnServer(ctx)
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)
}
}
go helper.resyncEncryptionInfo(context.TODO())
@ -142,6 +156,46 @@ func (helper *CryptoHelper) Init(ctx context.Context) error {
return nil
}
func (helper *CryptoHelper) doSelfSign(ctx context.Context) bool {
log := zerolog.Ctx(ctx)
hasKeys, isVerified, err := helper.mach.GetOwnVerificationStatus(ctx)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check verification status")
return false
}
log.Debug().Bool("has_keys", hasKeys).Bool("is_verified", isVerified).Msg("Checked verification status")
keyInDB := helper.bridge.Bridge.DB.KV.Get(ctx, database.KeyRecoveryKey)
if !hasKeys || keyInDB == "overwrite" {
if keyInDB != "" && keyInDB != "overwrite" {
log.WithLevel(zerolog.FatalLevel).
Msg("No keys on server, but database already has recovery key. Delete `recovery_key` from `kv_store` manually to continue.")
return false
}
recoveryKey, err := helper.mach.GenerateAndVerifyWithRecoveryKey(ctx)
if recoveryKey != "" {
helper.bridge.Bridge.DB.KV.Set(ctx, database.KeyRecoveryKey, recoveryKey)
}
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to generate recovery key and self-sign")
return false
}
log.Info().Msg("Generated new recovery key and self-signed bot device")
} else if !isVerified {
if keyInDB == "" {
log.WithLevel(zerolog.FatalLevel).
Msg("Server already has cross-signing keys, but no key in database. Add `recovery_key` to `kv_store`, or set it to `overwrite` to generate new keys.")
return false
}
err = helper.mach.VerifyWithRecoveryKey(ctx, keyInDB)
if err != nil {
log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to verify with recovery key")
return false
}
log.Info().Msg("Verified bot device with existing recovery key")
}
return true
}
func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
log := helper.log.With().Str("action", "resync encryption event").Logger()
rows, err := helper.store.DB.Query(ctx, `SELECT room_id FROM mx_room_state WHERE encryption='{"resync":true}'`)
@ -156,12 +210,12 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
var evt event.EncryptionEventContent
err = helper.client.StateEvent(ctx, roomID, event.StateEncryption, "", &evt)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to get encryption event")
log.Err(err).Stringer("room_id", roomID).Msg("Failed to get encryption event")
_, err = helper.store.DB.Exec(ctx, `
UPDATE mx_room_state SET encryption=NULL WHERE room_id=$1 AND encryption='{"resync":true}'
`, roomID)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to unmark room for resync after failed sync")
log.Err(err).Stringer("room_id", roomID).Msg("Failed to unmark room for resync after failed sync")
}
} else {
maxAge := evt.RotationPeriodMillis
@ -184,9 +238,9 @@ func (helper *CryptoHelper) resyncEncryptionInfo(ctx context.Context) {
WHERE room_id=$3 AND max_age IS NULL AND max_messages IS NULL
`, maxAge, maxMessages, roomID)
if err != nil {
log.Err(err).Str("room_id", roomID.String()).Msg("Failed to update megolm session table")
log.Err(err).Stringer("room_id", roomID).Msg("Failed to update megolm session table")
} else {
log.Debug().Str("room_id", roomID.String()).Msg("Updated megolm session table")
log.Debug().Stringer("room_id", roomID).Msg("Updated megolm session table")
}
}
}
@ -232,7 +286,7 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool
if err != nil {
return nil, false, fmt.Errorf("failed to find existing device ID: %w", err)
} else if len(deviceID) > 0 {
helper.log.Debug().Str("device_id", deviceID.String()).Msg("Found existing device ID for bot in database")
helper.log.Debug().Stringer("device_id", deviceID).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.
@ -273,7 +327,7 @@ func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool
return client, deviceID != "", nil
}
func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) {
func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) bool {
helper.log.Debug().Msg("Making sure keys are still on server")
resp, err := helper.client.QueryKeys(ctx, &mautrix.ReqQueryKeys{
DeviceKeys: map[id.UserID]mautrix.DeviceIDList{
@ -286,10 +340,11 @@ func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) {
}
device, ok := resp.DeviceKeys[helper.client.UserID][helper.client.DeviceID]
if ok && len(device.Keys) > 0 {
return
return true
}
helper.log.Warn().Msg("Existing device doesn't have keys on server, resetting crypto")
helper.Reset(ctx, false)
return false
}
func (helper *CryptoHelper) Start() {
@ -384,7 +439,7 @@ func (helper *CryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtTy
var encrypted *event.EncryptedEventContent
encrypted, err = helper.mach.EncryptMegolmEvent(ctx, roomID, evtType, content)
if err != nil {
if !errors.Is(err, crypto.SessionExpired) && !errors.Is(err, crypto.SessionNotShared) && !errors.Is(err, crypto.NoGroupSession) {
if !errors.Is(err, crypto.ErrSessionExpired) && !errors.Is(err, crypto.ErrSessionNotShared) && !errors.Is(err, crypto.ErrNoGroupSession) {
return
}
helper.log.Debug().Err(err).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -135,7 +135,10 @@ func (br *BridgeMain) CheckLegacyDB(
}
var dbVersion int
err = br.DB.QueryRow(ctx, "SELECT version FROM version").Scan(&dbVersion)
if dbVersion < expectedVersion {
if err != nil {
log.Fatal().Err(err).Msg("Failed to get database version")
return
} else if dbVersion < expectedVersion {
log.Fatal().
Int("expected_version", expectedVersion).
Int("version", dbVersion).

View file

@ -26,6 +26,7 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/progver"
"gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag"
@ -62,6 +63,9 @@ type BridgeMain struct {
// git tag to see if the built version is the release or a dev build.
// You can either bump this right after a release or right before, as long as it matches on the release commit.
Version string
// SemCalVer defines whether this bridge uses a mix of semantic and calendar versioning,
// such that the Version field is YY.0M.patch, while git tags are major.YY0M.patch.
SemCalVer bool
// PostInit is a function that will be called after the bridge has been initialized but before it is started.
PostInit func()
@ -86,11 +90,7 @@ type BridgeMain struct {
RegistrationPath string
SaveConfig bool
baseVersion string
commit string
LinkifiedVersion string
VersionDesc string
BuildTime time.Time
ver progver.ProgramVersion
AdditionalShortFlags string
AdditionalLongFlags string
@ -99,14 +99,7 @@ type BridgeMain struct {
}
type VersionJSONOutput struct {
Name string
URL string
Version string
IsRelease bool
Commit string
FormattedVersion string
BuildTime time.Time
progver.ProgramVersion
OS string
Arch string
@ -147,18 +140,11 @@ func (br *BridgeMain) PreInit() {
flag.PrintHelp()
os.Exit(0)
} else if *version {
fmt.Println(br.VersionDesc)
fmt.Println(br.ver.VersionDescription)
os.Exit(0)
} 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,
ProgramVersion: br.ver,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
@ -240,8 +226,8 @@ func (br *BridgeMain) Init() {
br.Log.Info().
Str("name", br.Name).
Str("version", br.Version).
Time("built_at", br.BuildTime).
Str("version", br.ver.FormattedVersion).
Time("built_at", br.ver.BuildTime).
Str("go_version", runtime.Version()).
Msg("Initializing bridge")
@ -255,7 +241,7 @@ func (br *BridgeMain) Init() {
br.Matrix.AS.DoublePuppetValue = br.Name
br.Bridge.Commands.(*commands.Processor).AddHandler(&commands.FullHandler{
Func: func(ce *commands.Event) {
ce.Reply("[%s](%s) %s (%s)", br.Name, br.URL, br.LinkifiedVersion, br.BuildTime.Format(time.RFC1123))
ce.Reply(br.ver.MarkdownDescription())
},
Name: "version",
Help: commands.HelpMeta{
@ -368,6 +354,13 @@ func (br *BridgeMain) LoadConfig() {
}
}
cfg.Bridge.Backfill = cfg.Backfill
if cfg.EnvConfigPrefix != "" {
err = UpdateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
os.Exit(10)
}
}
br.Config = &cfg
}
@ -421,7 +414,7 @@ func (br *BridgeMain) TriggerStop(exitCode int) {
// Stop cleanly stops the bridge. This is called by [Run] and does not need to be called manually.
func (br *BridgeMain) Stop() {
br.Bridge.Stop()
br.Bridge.StopWithTimeout(5 * time.Second)
}
// InitVersion formats the bridge version and build time nicely for things like
@ -446,42 +439,12 @@ func (br *BridgeMain) Stop() {
//
// (to use both at the same time, simply merge the ldflags into one, `-ldflags "-X '...' -X ..."`)
func (br *BridgeMain) InitVersion(tag, commit, rawBuildTime string) {
br.baseVersion = br.Version
if len(tag) > 0 && tag[0] == 'v' {
tag = tag[1:]
}
if tag != br.Version {
suffix := ""
if !strings.HasSuffix(br.Version, "+dev") {
suffix = "+dev"
}
if len(commit) > 8 {
br.Version = fmt.Sprintf("%s%s.%s", br.Version, suffix, commit[:8])
} else {
br.Version = fmt.Sprintf("%s%s.unknown", br.Version, suffix)
}
}
br.LinkifiedVersion = fmt.Sprintf("v%s", br.Version)
if tag == br.Version {
br.LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", br.Version, br.URL, tag)
} else if len(commit) > 8 {
br.LinkifiedVersion = strings.Replace(br.LinkifiedVersion, commit[:8], fmt.Sprintf("[%s](%s/commit/%s)", commit[:8], br.URL, commit), 1)
}
var buildTime time.Time
if rawBuildTime != "unknown" {
buildTime, _ = time.Parse(time.RFC3339, rawBuildTime)
}
var builtWith string
if buildTime.IsZero() {
rawBuildTime = "unknown"
builtWith = runtime.Version()
} else {
rawBuildTime = buildTime.Format(time.RFC1123)
builtWith = fmt.Sprintf("built at %s with %s", rawBuildTime, runtime.Version())
}
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.Version, mautrix.DefaultUserAgent)
br.VersionDesc = fmt.Sprintf("%s %s (%s)", br.Name, br.Version, builtWith)
br.commit = commit
br.BuildTime = buildTime
br.ver = progver.ProgramVersion{
Name: br.Name,
URL: br.URL,
BaseVersion: br.Version,
SemCalVer: br.SemCalVer,
}.Init(tag, commit, rawBuildTime)
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", br.Name, br.ver.FormattedVersion, mautrix.DefaultUserAgent)
br.Version = br.ver.FormattedVersion
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
// Copyright (c) 2024 Tulir Asokan
// 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
@ -10,10 +10,11 @@ import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2/database"
@ -24,8 +25,10 @@ import (
)
type MatrixCapabilities struct {
AutoJoinInvites bool
BatchSending bool
AutoJoinInvites bool
BatchSending bool
ArbitraryMemberChange bool
ExtraProfileMeta bool
}
type MatrixConnector interface {
@ -58,32 +61,55 @@ type MatrixConnector interface {
ServerName() string
}
type MatrixConnectorWithArbitraryRoomState interface {
MatrixConnector
GetStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string) (*event.Event, error)
}
type MatrixConnectorWithServer interface {
MatrixConnector
GetPublicAddress() string
GetRouter() *mux.Router
GetRouter() *http.ServeMux
}
type IProvisioningAPI interface {
GetRouter() *http.ServeMux
GetUser(r *http.Request) *User
}
type MatrixConnectorWithProvisioning interface {
MatrixConnector
GetProvisioning() IProvisioningAPI
}
type MatrixConnectorWithPublicMedia interface {
MatrixConnector
GetPublicMediaAddress(contentURI id.ContentURIString) string
GetPublicMediaAddressForEvent(ctx context.Context, evt *event.MessageEventContent) (string, error)
}
type MatrixConnectorWithNameDisambiguation interface {
MatrixConnector
IsConfusableName(ctx context.Context, roomID id.RoomID, userID id.UserID, name string) ([]id.UserID, error)
}
type MatrixConnectorWithBridgeIdentifier interface {
MatrixConnector
GetUniqueBridgeID() string
}
type MatrixConnectorWithURLPreviews interface {
MatrixConnector
GetURLPreview(ctx context.Context, url string) (*event.LinkPreview, error)
}
type MatrixConnectorWithPostRoomBridgeHandling interface {
MatrixConnector
HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomID) error
}
type MatrixConnectorWithAnalytics interface {
MatrixConnector
TrackAnalytics(userID id.UserID, event string, properties map[string]any)
}
@ -98,9 +124,15 @@ type DirectNotificationData struct {
}
type MatrixConnectorWithNotifications interface {
MatrixConnector
DisplayNotification(ctx context.Context, data *DirectNotificationData)
}
type MatrixConnectorWithHTTPSettings interface {
MatrixConnector
GetHTTPClientSettings() exhttp.ClientSettings
}
type MatrixSendExtra struct {
Timestamp time.Time
MessageMeta *database.Message
@ -144,6 +176,10 @@ func (ce CallbackError) Unwrap() error {
return ce.Wrapped
}
type EnsureJoinedParams struct {
Via []string
}
type MatrixAPI interface {
GetMXID() id.UserID
IsDoublePuppet() bool
@ -164,13 +200,26 @@ type MatrixAPI interface {
CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error)
DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error
EnsureJoined(ctx context.Context, roomID id.RoomID) error
EnsureJoined(ctx context.Context, roomID id.RoomID, params ...EnsureJoinedParams) error
EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error
TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error
MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error
GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error)
}
type StreamOrderReadingMatrixAPI interface {
MatrixAPI
MarkStreamOrderRead(ctx context.Context, roomID id.RoomID, streamOrder int64, ts time.Time) error
}
type MarkAsDMMatrixAPI interface {
MatrixAPI
MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error
}
type EphemeralSendingMatrixAPI interface {
MatrixAPI
BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error)
}

View file

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

View file

@ -20,6 +20,7 @@ import (
type MessageStatusEventInfo struct {
RoomID id.RoomID
TransactionID string
SourceEventID id.EventID
NewEventID id.EventID
EventType event.Type
@ -41,6 +42,7 @@ func StatusEventInfoFromEvent(evt *event.Event) *MessageStatusEventInfo {
return &MessageStatusEventInfo{
RoomID: evt.RoomID,
TransactionID: evt.Unsigned.TransactionID,
SourceEventID: evt.ID,
EventType: evt.Type,
MessageType: evt.Content.AsMessage().MsgType,
@ -182,9 +184,10 @@ func (ms *MessageStatus) ToMSSEvent(evt *MessageStatusEventInfo) *event.BeeperMe
Type: event.RelReference,
EventID: evt.SourceEventID,
},
Status: ms.Status,
Reason: ms.ErrorReason,
Message: ms.Message,
TargetTxnID: evt.TransactionID,
Status: ms.Status,
Reason: ms.ErrorReason,
Message: ms.Message,
}
if ms.InternalError != nil {
content.InternalError = ms.InternalError.Error()

View file

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

View file

@ -16,7 +16,9 @@ import (
"github.com/rs/zerolog"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
@ -77,8 +79,28 @@ type EventSender struct {
ForceDMUser bool
}
func (es EventSender) MarshalZerologObject(evt *zerolog.Event) {
evt.Str("user_id", string(es.Sender))
if string(es.SenderLogin) != string(es.Sender) {
evt.Str("sender_login", string(es.SenderLogin))
}
if es.IsFromMe {
evt.Bool("is_from_me", true)
}
if es.ForceDMUser {
evt.Bool("force_dm_user", true)
}
}
type ConvertedMessage struct {
ReplyTo *networkid.MessageOptionalPartID
ReplyTo *networkid.MessageOptionalPartID
// Optional additional info about the reply. This is only used when backfilling messages
// on Beeper, where replies may target messages that haven't been bridged yet.
// Standard Matrix servers can't backwards backfill, so these are never used.
ReplyToRoom networkid.PortalKey
ReplyToUser networkid.UserID
ReplyToLogin networkid.UserLoginID
ThreadRoot *networkid.MessageID
Parts []*ConvertedMessagePart
Disappear database.DisappearingSetting
@ -97,11 +119,15 @@ func MergeCaption(textPart, mediaPart *ConvertedMessagePart) *ConvertedMessagePa
mediaPart.Content.EnsureHasHTML()
mediaPart.Content.Body += "\n\n" + textPart.Content.Body
mediaPart.Content.FormattedBody += "<br><br>" + textPart.Content.FormattedBody
mediaPart.Content.Mentions = mediaPart.Content.Mentions.Merge(textPart.Content.Mentions)
mediaPart.Content.BeeperLinkPreviews = append(mediaPart.Content.BeeperLinkPreviews, textPart.Content.BeeperLinkPreviews...)
} else {
mediaPart.Content.FileName = mediaPart.Content.Body
mediaPart.Content.Body = textPart.Content.Body
mediaPart.Content.Format = textPart.Content.Format
mediaPart.Content.FormattedBody = textPart.Content.FormattedBody
mediaPart.Content.Mentions = textPart.Content.Mentions
mediaPart.Content.BeeperLinkPreviews = textPart.Content.BeeperLinkPreviews
}
if metaMerger, ok := mediaPart.DBMetadata.(database.MetaMerger); ok {
metaMerger.CopyFrom(textPart.DBMetadata)
@ -235,6 +261,7 @@ type NetworkConnector interface {
}
type StoppableNetwork interface {
NetworkConnector
// Stop is called when the bridge is stopping, after all network clients have been disconnected.
Stop()
}
@ -259,6 +286,11 @@ type IdentifierValidatingNetwork interface {
ValidateUserID(id networkid.UserID) bool
}
type TransactionIDGeneratingNetwork interface {
NetworkConnector
GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID
}
type PortalBridgeInfoFillingNetwork interface {
NetworkConnector
FillPortalBridgeInfo(portal *Portal, content *event.BridgeEventContent)
@ -286,6 +318,16 @@ type MaxFileSizeingNetwork interface {
SetMaxFileSize(maxSize int64)
}
type NetworkResettingNetwork interface {
NetworkConnector
// ResetHTTPTransport should recreate the HTTP client used by the bridge.
// It should refetch settings from the Matrix connector using GetHTTPClientSettings if applicable.
ResetHTTPTransport()
// ResetNetworkConnections should forcefully disconnect and restart any persistent network connections.
// ResetHTTPTransport will usually be called before this, so resetting the transport is not necessary here.
ResetNetworkConnections()
}
type RemoteEchoHandler func(RemoteMessage, *database.Message) (bool, error)
type MatrixMessageResponse struct {
@ -317,10 +359,16 @@ type NetworkGeneralCapabilities struct {
// Should the bridge re-request user info on incoming messages even if the ghost already has info?
// By default, info is only requested for ghosts with no name, and other updating is left to events.
AggressiveUpdateInfo bool
// Should the bridge call HandleMatrixReadReceipt with fake data when receiving a new message?
// This should be enabled if the network requires each message to be marked as read independently,
// and doesn't automatically do it when sending a message.
ImplicitReadReceipts bool
// If the bridge uses the pending message mechanism ([MatrixMessage.AddPendingToSave])
// to handle asynchronous message responses, this field can be set to enable
// automatic timeout errors in case the asynchronous response never arrives.
OutgoingMessageTimeouts *OutgoingTimeoutConfig
// Capabilities related to the provisioning API.
Provisioning ProvisioningCapabilities
}
// NetworkAPI is an interface representing a remote network client for a single user login.
@ -382,6 +430,13 @@ type BackgroundSyncingNetworkAPI interface {
ConnectBackground(ctx context.Context, params *ConnectBackgroundParams) error
}
// CredentialExportingNetworkAPI is an optional interface that networks connectors can implement to support export of
// the credentials associated with that login. Credential type is bridge specific.
type CredentialExportingNetworkAPI interface {
NetworkAPI
ExportCredentials(ctx context.Context) any
}
// FetchMessagesParams contains the parameters for a message history pagination request.
type FetchMessagesParams struct {
// The portal to fetch messages in. Always present.
@ -583,6 +638,16 @@ type ReadReceiptHandlingNetworkAPI interface {
HandleMatrixReadReceipt(ctx context.Context, msg *MatrixReadReceipt) error
}
// ChatViewingNetworkAPI is an optional interface that network connectors can implement to handle viewing chat status.
type ChatViewingNetworkAPI interface {
NetworkAPI
// HandleMatrixViewingChat is called when the user opens a portal room.
// This will never be called by the standard appservice connector,
// as Matrix doesn't have any standard way of signaling chat open status.
// Clients are expected to call this every 5 seconds. There is no signal for closing a chat.
HandleMatrixViewingChat(ctx context.Context, msg *MatrixViewingChat) error
}
// TypingHandlingNetworkAPI is an optional interface that network connectors can implement to handle typing events.
type TypingHandlingNetworkAPI interface {
NetworkAPI
@ -637,6 +702,35 @@ type RoomTopicHandlingNetworkAPI interface {
HandleMatrixRoomTopic(ctx context.Context, msg *MatrixRoomTopic) (bool, error)
}
type DisappearTimerChangingNetworkAPI interface {
NetworkAPI
// HandleMatrixDisappearingTimer is called when the disappearing timer of a portal room is changed.
// This method should update the Disappear field of the Portal with the new timer and return true
// if the change was successful. If the change is not successful, then the field should not be updated.
HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error)
}
// DeleteChatHandlingNetworkAPI is an optional interface that network connectors
// can implement to delete a chat from the remote network.
type DeleteChatHandlingNetworkAPI interface {
NetworkAPI
// HandleMatrixDeleteChat is called when the user explicitly deletes a chat.
HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error
}
// MessageRequestAcceptingNetworkAPI is an optional interface that network connectors
// can implement to accept message requests from the remote network.
type MessageRequestAcceptingNetworkAPI interface {
NetworkAPI
// HandleMatrixAcceptMessageRequest is called when the user accepts a message request.
HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error
}
type BeeperAIStreamHandlingNetworkAPI interface {
NetworkAPI
HandleMatrixBeeperAIStream(ctx context.Context, msg *MatrixBeeperAIStream) error
}
type ResolveIdentifierResponse struct {
// Ghost is the ghost of the user that the identifier resolves to.
// This field should be set whenever possible. However, it is not required,
@ -656,11 +750,27 @@ type ResolveIdentifierResponse struct {
Chat *CreateChatResponse
}
var SpecialValueDMRedirectedToBot = networkid.UserID("__fi.mau.bridgev2.dm_redirected_to_bot::" + random.String(10))
type CreateChatResponse struct {
PortalKey networkid.PortalKey
// Portal and PortalInfo are not required, the caller will fetch them automatically based on PortalKey if necessary.
Portal *Portal
PortalInfo *ChatInfo
// If a start DM request (CreateChatWithGhost or ResolveIdentifier) returns the DM to a different user,
// this field should have the user ID of said different user.
DMRedirectedTo networkid.UserID
FailedParticipants map[networkid.UserID]*CreateChatFailedParticipant
}
type CreateChatFailedParticipant struct {
Reason string `json:"reason"`
InviteEventType string `json:"invite_event_type,omitempty"`
InviteContent *event.Content `json:"invite_content,omitempty"`
UserMXID id.UserID `json:"user_mxid,omitempty"`
DMRoomMXID id.RoomID `json:"dm_room_mxid,omitempty"`
}
// IdentifierResolvingNetworkAPI is an optional interface that network connectors can implement to support starting new direct chats.
@ -695,7 +805,83 @@ type UserSearchingNetworkAPI interface {
type GroupCreatingNetworkAPI interface {
IdentifierResolvingNetworkAPI
CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error)
CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error)
}
type PersonalFilteringCustomizingNetworkAPI interface {
NetworkAPI
CustomizePersonalFilteringSpace(req *mautrix.ReqCreateRoom)
}
type ProvisioningCapabilities struct {
ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"`
GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"`
}
type ResolveIdentifierCapabilities struct {
// Can DMs be created after resolving an identifier?
CreateDM bool `json:"create_dm"`
// Can users be looked up by phone number?
LookupPhone bool `json:"lookup_phone"`
// Can users be looked up by email address?
LookupEmail bool `json:"lookup_email"`
// Can users be looked up by network-specific username?
LookupUsername bool `json:"lookup_username"`
// Can any phone number be contacted without having to validate it via lookup first?
AnyPhone bool `json:"any_phone"`
// Can a contact list be retrieved from the bridge?
ContactList bool `json:"contact_list"`
// Can users be searched by name on the remote network?
Search bool `json:"search"`
}
type GroupTypeCapabilities struct {
TypeDescription string `json:"type_description"`
Name GroupFieldCapability `json:"name"`
Username GroupFieldCapability `json:"username"`
Avatar GroupFieldCapability `json:"avatar"`
Topic GroupFieldCapability `json:"topic"`
Disappear GroupFieldCapability `json:"disappear"`
Participants GroupFieldCapability `json:"participants"`
Parent GroupFieldCapability `json:"parent"`
}
type GroupFieldCapability struct {
// Is setting this field allowed at all in the create request?
// Even if false, the network connector should attempt to set the metadata after group creation,
// as the allowed flag can't be enforced properly when creating a group for an existing Matrix room.
Allowed bool `json:"allowed"`
// Is setting this field mandatory for the creation to succeed?
Required bool `json:"required,omitempty"`
// The minimum/maximum length of the field, if applicable.
// For members, length means the number of members excluding the creator.
MinLength int `json:"min_length,omitempty"`
MaxLength int `json:"max_length,omitempty"`
// Only for the disappear field: allowed disappearing settings
DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"`
// This can be used to tell provisionutil not to call ValidateUserID on each participant.
// It only meant to allow hacks where ResolveIdentifier returns a fake ID that isn't actually valid for MXIDs.
SkipIdentifierValidation bool `json:"-"`
}
type GroupCreateParams struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
// Clients may also provide MXIDs here, but provisionutil will normalize them, so bridges only need to handle network IDs
Participants []networkid.UserID `json:"participants,omitempty"`
Parent *networkid.PortalKey `json:"parent,omitempty"`
Name *event.RoomNameEventContent `json:"name,omitempty"`
Avatar *event.RoomAvatarEventContent `json:"avatar,omitempty"`
Topic *event.TopicEventContent `json:"topic,omitempty"`
Disappear *event.BeeperDisappearingTimer `json:"disappear,omitempty"`
// An existing room ID to bridge to. If unset, a new room will be created.
RoomID id.RoomID `json:"room_id,omitempty"`
}
type MembershipChangeType struct {
@ -735,16 +921,15 @@ type MatrixMembershipChange struct {
MatrixRoomMeta[*event.MemberEventContent]
Target GhostOrUserLogin
Type MembershipChangeType
}
// Deprecated: Use Target instead
TargetGhost *Ghost
// Deprecated: Use Target instead
TargetUserLogin *UserLogin
type MatrixMembershipResult struct {
RedirectTo networkid.UserID
}
type MembershipHandlingNetworkAPI interface {
NetworkAPI
HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (bool, error)
HandleMatrixMembership(ctx context.Context, msg *MatrixMembershipChange) (*MatrixMembershipResult, error)
}
type SinglePowerLevelChange struct {
@ -983,6 +1168,11 @@ type RemoteChatDelete interface {
RemoteDeleteOnlyForMe
}
type RemoteChatDeleteWithChildren interface {
RemoteChatDelete
DeleteChildren() bool
}
type RemoteEventThatMayCreatePortal interface {
RemoteEvent
ShouldCreatePortal() bool
@ -1102,6 +1292,11 @@ type RemoteReadReceipt interface {
GetReadUpTo() time.Time
}
type RemoteReadReceiptWithStreamOrder interface {
RemoteReadReceipt
GetReadUpToStreamOrder() int64
}
type RemoteDeliveryReceipt interface {
RemoteEvent
GetReceiptTargets() []networkid.MessageID
@ -1137,6 +1332,7 @@ type OrigSender struct {
RequiresDisambiguation bool
DisambiguatedName string
FormattedName string
PerMessageProfile event.BeeperPerMessageProfile
event.MemberEventContent
}
@ -1151,6 +1347,8 @@ type MatrixEventBase[ContentType any] struct {
// The original sender user ID. Only present in case the event is being relayed (and Sender is not the same user).
OrigSender *OrigSender
InputTransactionID networkid.RawTransactionID
}
type MatrixMessage struct {
@ -1207,12 +1405,14 @@ type MatrixMessageRemove struct {
type MatrixRoomMeta[ContentType any] struct {
MatrixEventBase[ContentType]
PrevContent ContentType
PrevContent ContentType
IsStateRequest bool
}
type MatrixRoomName = MatrixRoomMeta[*event.RoomNameEventContent]
type MatrixRoomAvatar = MatrixRoomMeta[*event.RoomAvatarEventContent]
type MatrixRoomTopic = MatrixRoomMeta[*event.TopicEventContent]
type MatrixDisappearingTimer = MatrixRoomMeta[*event.BeeperDisappearingTimer]
type MatrixReadReceipt struct {
Portal *Portal
@ -1227,6 +1427,8 @@ type MatrixReadReceipt struct {
LastRead time.Time
// The receipt metadata.
Receipt event.ReadReceipt
// Whether the receipt is implicit, i.e. triggered by an incoming timeline event rather than an explicit receipt.
Implicit bool
}
type MatrixTyping struct {
@ -1235,6 +1437,14 @@ type MatrixTyping struct {
Type TypingType
}
type MatrixViewingChat struct {
// The portal that the user is viewing. This will be nil when the user switches to a chat from a different bridge.
Portal *Portal
}
type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent]
type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent]
type MatrixBeeperAIStream = MatrixEventBase[*event.BeeperAIStreamEventContent]
type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent]
type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent]
type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent]

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,98 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package provisionutil
import (
"context"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
)
type RespGetContactList struct {
Contacts []*RespResolveIdentifier `json:"contacts"`
}
type RespSearchUsers struct {
Results []*RespResolveIdentifier `json:"results"`
}
func GetContactList(ctx context.Context, login *bridgev2.UserLogin) (*RespGetContactList, error) {
api, ok := login.Client.(bridgev2.ContactListingNetworkAPI)
if !ok {
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support listing contacts"))
}
resp, err := api.GetContactList(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list")
return nil, err
}
return &RespGetContactList{
Contacts: processResolveIdentifiers(ctx, login.Bridge, resp, false),
}, nil
}
func SearchUsers(ctx context.Context, login *bridgev2.UserLogin, query string) (*RespSearchUsers, error) {
api, ok := login.Client.(bridgev2.UserSearchingNetworkAPI)
if !ok {
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support searching for users"))
}
resp, err := api.SearchUsers(ctx, query)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get contact list")
return nil, err
}
return &RespSearchUsers{
Results: processResolveIdentifiers(ctx, login.Bridge, resp, true),
}, nil
}
func processResolveIdentifiers(ctx context.Context, br *bridgev2.Bridge, resp []*bridgev2.ResolveIdentifierResponse, syncInfo bool) (apiResp []*RespResolveIdentifier) {
apiResp = make([]*RespResolveIdentifier, len(resp))
for i, contact := range resp {
apiContact := &RespResolveIdentifier{
ID: contact.UserID,
}
apiResp[i] = apiContact
if contact.UserInfo != nil {
if contact.UserInfo.Name != nil {
apiContact.Name = *contact.UserInfo.Name
}
if contact.UserInfo.Identifiers != nil {
apiContact.Identifiers = contact.UserInfo.Identifiers
}
}
if contact.Ghost != nil {
if syncInfo && contact.UserInfo != nil {
contact.Ghost.UpdateInfo(ctx, contact.UserInfo)
}
if contact.Ghost.Name != "" {
apiContact.Name = contact.Ghost.Name
}
if len(contact.Ghost.Identifiers) >= len(apiContact.Identifiers) {
apiContact.Identifiers = contact.Ghost.Identifiers
}
apiContact.AvatarURL = contact.Ghost.AvatarMXC
apiContact.MXID = contact.Ghost.Intent.GetMXID()
}
if contact.Chat != nil {
if contact.Chat.Portal == nil {
var err error
contact.Chat.Portal, err = br.GetPortalByKey(ctx, contact.Chat.PortalKey)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal")
}
}
if contact.Chat.Portal != nil {
apiContact.DMRoomID = contact.Chat.Portal.MXID
}
}
}
return
}

View file

@ -0,0 +1,125 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package provisionutil
import (
"context"
"errors"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
)
type RespResolveIdentifier struct {
ID networkid.UserID `json:"id"`
Name string `json:"name,omitempty"`
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
Identifiers []string `json:"identifiers,omitempty"`
MXID id.UserID `json:"mxid,omitempty"`
DMRoomID id.RoomID `json:"dm_room_mxid,omitempty"`
Portal *bridgev2.Portal `json:"-"`
Ghost *bridgev2.Ghost `json:"-"`
JustCreated bool `json:"-"`
}
var ErrNoPortalKey = errors.New("network API didn't return portal key for createChat request")
func ResolveIdentifier(
ctx context.Context,
login *bridgev2.UserLogin,
identifier string,
createChat bool,
) (*RespResolveIdentifier, error) {
api, ok := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
if !ok {
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support resolving identifiers"))
}
var resp *bridgev2.ResolveIdentifierResponse
parsedUserID, ok := login.Bridge.Matrix.ParseGhostMXID(id.UserID(identifier))
validator, vOK := login.Bridge.Network.(bridgev2.IdentifierValidatingNetwork)
if ok && (!vOK || validator.ValidateUserID(parsedUserID)) {
ghost, err := login.Bridge.GetGhostByID(ctx, parsedUserID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost by ID")
return nil, err
}
resp = &bridgev2.ResolveIdentifierResponse{
Ghost: ghost,
UserID: parsedUserID,
}
gdcAPI, ok := api.(bridgev2.GhostDMCreatingNetworkAPI)
if ok && createChat {
resp.Chat, err = gdcAPI.CreateChatWithGhost(ctx, ghost)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create chat")
return nil, err
}
} else if createChat || ghost.Name == "" {
zerolog.Ctx(ctx).Debug().
Bool("create_chat", createChat).
Bool("has_name", ghost.Name != "").
Msg("Falling back to resolving identifier")
resp = nil
identifier = string(parsedUserID)
}
}
if resp == nil {
var err error
resp, err = api.ResolveIdentifier(ctx, identifier, createChat)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to resolve identifier")
return nil, err
} else if resp == nil {
return nil, nil
}
}
apiResp := &RespResolveIdentifier{
ID: resp.UserID,
Ghost: resp.Ghost,
}
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(ctx, resp.UserInfo)
}
apiResp.Name = resp.Ghost.Name
apiResp.AvatarURL = resp.Ghost.AvatarMXC
apiResp.Identifiers = resp.Ghost.Identifiers
apiResp.MXID = resp.Ghost.Intent.GetMXID()
} else if resp.UserInfo != nil && resp.UserInfo.Name != nil {
apiResp.Name = *resp.UserInfo.Name
}
if resp.Chat != nil {
if resp.Chat.PortalKey.IsEmpty() {
return nil, ErrNoPortalKey
}
if resp.Chat.Portal == nil {
var err error
resp.Chat.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.Chat.PortalKey)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal")
return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal"))
}
}
resp.Chat.Portal.CleanupOrphanedDM(ctx, login.UserMXID)
if createChat && resp.Chat.Portal.MXID == "" {
apiResp.JustCreated = true
err := resp.Chat.Portal.CreateMatrixRoom(ctx, login, resp.Chat.PortalInfo)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room")
return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room"))
}
}
apiResp.Portal = resp.Chat.Portal
apiResp.DMRoomID = resp.Chat.Portal.MXID
}
return apiResp, nil
}

View file

@ -63,7 +63,14 @@ func (br *Bridge) rejectInviteOnNoPermission(ctx context.Context, evt *event.Eve
return true
}
func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) {
var (
ErrEventSenderUserNotFound = WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage()
ErrNoPermissionToInteract = WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()
ErrNoPermissionForCommands = WrapErrorInStatus(WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage())
ErrCantRelayStateRequest = WrapErrorInStatus(errors.New("relayed users can't use beeper state requests")).WithIsCertain(true).WithErrorAsMessage()
)
func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) EventHandlingResult {
// TODO maybe HandleMatrixEvent would be more appropriate as this also handles bot invites and commands
log := zerolog.Ctx(ctx)
@ -75,37 +82,34 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) {
log.Err(err).Msg("Failed to get sender user for incoming Matrix event")
status := WrapErrorInStatus(fmt.Errorf("%w: failed to get sender user: %w", ErrDatabaseError, err))
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return
return EventHandlingResultFailed
} else if sender == nil {
log.Error().Msg("Couldn't get sender for incoming non-ephemeral Matrix event")
status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage()
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return
br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt))
return EventHandlingResultFailed
} else if !sender.Permissions.SendEvents {
if !br.rejectInviteOnNoPermission(ctx, evt, "interact with") {
status := WrapErrorInStatus(errors.New("you don't have permission to send messages")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionToInteract, StatusEventInfoFromEvent(evt))
}
return
return EventHandlingResultIgnored
} else if !sender.Permissions.Commands && br.rejectInviteOnNoPermission(ctx, evt, "send commands to") {
return
return EventHandlingResultIgnored
}
} else if evt.Type.Class != event.EphemeralEventType {
log.Error().Msg("Missing sender for incoming non-ephemeral Matrix event")
status := WrapErrorInStatus(errors.New("sender not found for event")).WithIsCertain(true).WithErrorAsMessage()
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return
br.Matrix.SendMessageStatus(ctx, &ErrEventSenderUserNotFound, StatusEventInfoFromEvent(evt))
return EventHandlingResultIgnored
}
if evt.Type == event.EventMessage && sender != nil {
msg := evt.Content.AsMessage()
msg.RemoveReplyFallback()
msg.RemovePerMessageProfileFallback()
if strings.HasPrefix(msg.Body, br.Config.CommandPrefix) || evt.RoomID == sender.ManagementRoom {
if !sender.Permissions.Commands {
status := WrapErrorInStatus(errors.New("you don't have permission to use commands")).WithIsCertain(true).WithSendNotice(false).WithErrorAsMessage()
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return
br.Matrix.SendMessageStatus(ctx, &ErrNoPermissionForCommands, StatusEventInfoFromEvent(evt))
return EventHandlingResultIgnored
}
br.Commands.Handle(
go br.Commands.Handle(
ctx,
evt.RoomID,
evt.ID,
@ -113,50 +117,112 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) {
strings.TrimPrefix(msg.Body, br.Config.CommandPrefix+" "),
msg.RelatesTo.GetReplyTo(),
)
return
return EventHandlingResultQueued
}
}
if evt.Type == event.StateMember && evt.GetStateKey() == br.Bot.GetMXID().String() && evt.Content.AsMember().Membership == event.MembershipInvite && sender != nil {
br.handleBotInvite(ctx, evt, sender)
return
return br.handleBotInvite(ctx, evt, sender)
} else if sender != nil && evt.RoomID == sender.ManagementRoom {
if evt.Type == event.StateMember && evt.Content.AsMember().Membership == event.MembershipLeave && (evt.GetStateKey() == br.Bot.GetMXID().String() || evt.GetStateKey() == sender.MXID.String()) {
sender.ManagementRoom = ""
err := br.DB.User.Update(ctx, sender.User)
if err != nil {
log.Err(err).Msg("Failed to clear user's management room in database")
return EventHandlingResultFailed
} else {
log.Debug().Msg("Cleared user's management room due to leave event")
}
}
return
return EventHandlingResultSuccess
}
portal, err := br.GetPortalByMXID(ctx, evt.RoomID)
if err != nil {
log.Err(err).Msg("Failed to get portal for incoming Matrix event")
status := WrapErrorInStatus(fmt.Errorf("%w: failed to get portal: %w", ErrDatabaseError, err))
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return
return EventHandlingResultFailed
} else if portal != nil {
portal.queueEvent(ctx, &portalMatrixEvent{
return portal.queueEvent(ctx, &portalMatrixEvent{
evt: evt,
sender: sender,
})
} else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite && evt.Content.AsMember().IsDirect {
br.handleGhostDMInvite(ctx, evt, sender)
return br.handleGhostDMInvite(ctx, evt, sender)
} else {
status := WrapErrorInStatus(ErrNoPortal)
br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))
return EventHandlingResultIgnored
}
}
func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) {
ul.Bridge.QueueRemoteEvent(ul, evt)
type EventHandlingResult struct {
Success bool
Ignored bool
Queued bool
SkipStateEcho bool
// Error is an optional reason for failure. It is not required, Success may be false even without a specific error.
Error error
// Whether the Error should be sent as a MSS event.
SendMSS bool
// EventID from the network
EventID id.EventID
// Stream order from the network
StreamOrder int64
}
func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) {
func (ehr EventHandlingResult) WithEventID(id id.EventID) EventHandlingResult {
ehr.EventID = id
return ehr
}
func (ehr EventHandlingResult) WithStreamOrder(order int64) EventHandlingResult {
ehr.StreamOrder = order
return ehr
}
func (ehr EventHandlingResult) WithError(err error) EventHandlingResult {
if err == nil {
return ehr
}
ehr.Error = err
ehr.Success = false
return ehr
}
func (ehr EventHandlingResult) WithMSS() EventHandlingResult {
ehr.SendMSS = true
return ehr
}
func (ehr EventHandlingResult) WithSkipStateEcho(skip bool) EventHandlingResult {
ehr.SkipStateEcho = skip
return ehr
}
func (ehr EventHandlingResult) WithMSSError(err error) EventHandlingResult {
if err == nil {
return ehr
}
return ehr.WithError(err).WithMSS()
}
var (
EventHandlingResultFailed = EventHandlingResult{}
EventHandlingResultQueued = EventHandlingResult{Success: true, Queued: true}
EventHandlingResultSuccess = EventHandlingResult{Success: true}
EventHandlingResultIgnored = EventHandlingResult{Success: true, Ignored: true}
)
func (ul *UserLogin) QueueRemoteEvent(evt RemoteEvent) EventHandlingResult {
return ul.Bridge.QueueRemoteEvent(ul, evt)
}
func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) EventHandlingResult {
log := login.Log
ctx := log.WithContext(context.TODO())
ctx := log.WithContext(br.BackgroundCtx)
maybeUncertain, ok := evt.(RemoteEventWithUncertainPortalReceiver)
isUncertain := ok && maybeUncertain.PortalReceiverIsUncertain()
key := evt.GetPortalKey()
@ -170,18 +236,18 @@ func (br *Bridge) QueueRemoteEvent(login *UserLogin, evt RemoteEvent) {
if err != nil {
log.Err(err).Object("portal_key", key).Bool("uncertain_receiver", isUncertain).
Msg("Failed to get portal to handle remote event")
return
return EventHandlingResultFailed.WithError(fmt.Errorf("failed to get portal: %w", err))
} else if portal == nil {
log.Warn().
Stringer("event_type", evt.GetType()).
Object("portal_key", key).
Bool("uncertain_receiver", isUncertain).
Msg("Portal not found to handle remote event")
return
return EventHandlingResultFailed.WithError(ErrPortalNotFoundInEventHandler)
}
// TODO put this in a better place, and maybe cache to avoid constant db queries
login.MarkInPortal(ctx, portal)
portal.queueEvent(ctx, &portalRemoteEvent{
return portal.queueEvent(ctx, &portalRemoteEvent{
evt: evt,
source: login,
})

View file

@ -65,14 +65,19 @@ func (evt *ChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal)
type ChatDelete struct {
EventMeta
OnlyForMe bool
Children bool
}
var _ bridgev2.RemoteChatDelete = (*ChatDelete)(nil)
var _ bridgev2.RemoteChatDeleteWithChildren = (*ChatDelete)(nil)
func (evt *ChatDelete) DeleteOnlyForMe() bool {
return evt.OnlyForMe
}
func (evt *ChatDelete) DeleteChildren() bool {
return evt.Children
}
// ChatInfoChange is a simple implementation of [bridgev2.RemoteChatInfoChange].
type ChatInfoChange struct {
EventMeta

View file

@ -59,6 +59,41 @@ func (evt *Message[T]) GetTransactionID() networkid.TransactionID {
return evt.TransactionID
}
// PreConvertedMessage is a simple implementation of [bridgev2.RemoteMessage] with pre-converted data.
type PreConvertedMessage struct {
EventMeta
Data *bridgev2.ConvertedMessage
ID networkid.MessageID
TransactionID networkid.TransactionID
HandleExistingFunc func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error)
}
var (
_ bridgev2.RemoteMessage = (*PreConvertedMessage)(nil)
_ bridgev2.RemoteMessageUpsert = (*PreConvertedMessage)(nil)
_ bridgev2.RemoteMessageWithTransactionID = (*PreConvertedMessage)(nil)
)
func (evt *PreConvertedMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return evt.Data, nil
}
func (evt *PreConvertedMessage) GetID() networkid.MessageID {
return evt.ID
}
func (evt *PreConvertedMessage) GetTransactionID() networkid.TransactionID {
return evt.TransactionID
}
func (evt *PreConvertedMessage) HandleExisting(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (bridgev2.UpsertResult, error) {
if evt.HandleExistingFunc == nil {
return bridgev2.UpsertResult{}, nil
}
return evt.HandleExistingFunc(ctx, portal, intent, existing)
}
type MessageRemove struct {
EventMeta

View file

@ -101,6 +101,18 @@ func (evt EventMeta) WithLogContext(f func(c zerolog.Context) zerolog.Context) E
return evt
}
func (evt EventMeta) WithMoreLogContext(f func(c zerolog.Context) zerolog.Context) EventMeta {
origFunc := evt.LogContext
if origFunc == nil {
evt.LogContext = f
return evt
}
evt.LogContext = func(c zerolog.Context) zerolog.Context {
return f(origFunc(c))
}
return evt
}
func (evt EventMeta) WithPortalKey(p networkid.PortalKey) EventMeta {
evt.PortalKey = p
return evt

View file

@ -19,6 +19,8 @@ type Receipt struct {
LastTarget networkid.MessageID
Targets []networkid.MessageID
ReadUpTo time.Time
ReadUpToStreamOrder int64
}
var (
@ -38,6 +40,10 @@ func (evt *Receipt) GetReadUpTo() time.Time {
return evt.ReadUpTo
}
func (evt *Receipt) GetReadUpToStreamOrder() int64 {
return evt.ReadUpToStreamOrder
}
type MarkUnread struct {
EventMeta
Unread bool

View file

@ -171,6 +171,10 @@ func (ul *UserLogin) GetSpaceRoom(ctx context.Context) (id.RoomID, error) {
// TODO remove this after initial_members is supported in hungryserv
req.BeeperAutoJoinInvites = true
}
pfc, ok := ul.Client.(PersonalFilteringCustomizingNetworkAPI)
if ok {
pfc.CustomizePersonalFilteringSpace(req)
}
ul.SpaceRoom, err = ul.Bridge.Bot.CreateRoom(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to create space room: %w", err)

View file

@ -19,9 +19,10 @@ import (
"github.com/tidwall/sjson"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -78,6 +79,7 @@ type BridgeStateUserAction string
const (
UserActionOpenNative BridgeStateUserAction = "OPEN_NATIVE"
UserActionRelogin BridgeStateUserAction = "RELOGIN"
UserActionRestart BridgeStateUserAction = "RESTART"
)
type RemoteProfile struct {
@ -86,6 +88,8 @@ type RemoteProfile struct {
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 {
@ -101,11 +105,14 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile {
other.Username = coalesce(rp.Username, other.Username)
other.Name = coalesce(rp.Name, other.Name)
other.Avatar = coalesce(rp.Avatar, other.Avatar)
if rp.AvatarFile != nil {
other.AvatarFile = rp.AvatarFile
}
return other
}
func (rp *RemoteProfile) IsEmpty() bool {
return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "")
func (rp *RemoteProfile) IsZero() bool {
return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil)
}
type BridgeState struct {
@ -119,10 +126,10 @@ type BridgeState struct {
UserAction BridgeStateUserAction `json:"user_action,omitempty"`
UserID id.UserID `json:"user_id,omitempty"`
RemoteID string `json:"remote_id,omitempty"`
RemoteName string `json:"remote_name,omitempty"`
RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"`
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"`
Reason string `json:"reason,omitempty"`
Info map[string]interface{} `json:"info,omitempty"`
@ -202,7 +209,7 @@ func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool {
pong.StateEvent == newPong.StateEvent &&
pong.RemoteName == newPong.RemoteName &&
pong.UserAction == newPong.UserAction &&
ptr.Val(pong.RemoteProfile) == ptr.Val(newPong.RemoteProfile) &&
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(endpoint string, token string) error {
func (cj *CheckpointsJSON) SendHTTP(ctx context.Context, cli *http.Client, 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(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
if err != nil {
@ -186,7 +186,10 @@ func (cj *CheckpointsJSON) SendHTTP(endpoint string, token string) error {
req.Header.Set("User-Agent", mautrix.DefaultUserAgent+" (checkpoint sender)")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if cli == nil {
cli = http.DefaultClient
}
resp, err := cli.Do(req)
if err != nil {
return mautrix.HTTPError{
Request: req,

View file

@ -176,6 +176,10 @@ func (user *User) GetUserLogins() []*UserLogin {
return maps.Values(user.logins)
}
func (user *User) HasTooManyLogins() bool {
return user.Permissions.MaxLogins > 0 && len(user.GetUserLoginIDs()) >= user.Permissions.MaxLogins
}
func (user *User) GetFormattedUserLogins() string {
user.Bridge.cacheLock.Lock()
logins := make([]string, len(user.logins))

View file

@ -10,6 +10,7 @@ import (
"cmp"
"context"
"fmt"
"maps"
"slices"
"sync"
"time"
@ -37,6 +38,7 @@ type UserLogin struct {
spaceCreateLock sync.Mutex
deleteLock sync.Mutex
disconnectOnce sync.Once
}
func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *database.UserLogin) (*UserLogin, error) {
@ -49,6 +51,8 @@ func (br *Bridge) loadUserLogin(ctx context.Context, user *User, dbUserLogin *da
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// TODO if loading the user caused the provided userlogin to be loaded, cancel here?
// Currently this will double-load it
}
userLogin := &UserLogin{
UserLogin: dbUserLogin,
@ -139,6 +143,12 @@ func (br *Bridge) GetCachedUserLoginByID(id networkid.UserLoginID) *UserLogin {
return br.userLoginsByID[id]
}
func (br *Bridge) GetAllCachedUserLogins() (logins []*UserLogin) {
br.cacheLock.Lock()
defer br.cacheLock.Unlock()
return slices.Collect(maps.Values(br.userLoginsByID))
}
func (br *Bridge) GetCurrentBridgeStates() (states []status.BridgeState) {
br.cacheLock.Lock()
defer br.cacheLock.Unlock()
@ -227,19 +237,23 @@ func (user *User) NewLogin(ctx context.Context, data *database.UserLogin, params
}
ul.BridgeState = user.Bridge.NewBridgeStateQueue(ul)
}
err = params.LoadUserLogin(ul.Log.WithContext(context.Background()), ul)
noCancelCtx := ul.Log.WithContext(user.Bridge.BackgroundCtx)
err = params.LoadUserLogin(noCancelCtx, ul)
if err != nil {
return nil, err
} else if ul.Client == nil {
ul.Log.Error().Msg("LoadUserLogin didn't fill Client in NewLogin")
return nil, fmt.Errorf("client not filled by LoadUserLogin")
}
if doInsert {
err = user.Bridge.DB.UserLogin.Insert(ctx, ul.UserLogin)
err = user.Bridge.DB.UserLogin.Insert(noCancelCtx, ul.UserLogin)
if err != nil {
return nil, err
}
user.Bridge.userLoginsByID[ul.ID] = ul
user.logins[ul.ID] = ul
} else {
err = ul.Save(ctx)
err = ul.Save(noCancelCtx)
if err != nil {
return nil, err
}
@ -276,7 +290,8 @@ func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts
if opts.LogoutRemote {
ul.Client.LogoutRemote(ctx)
} else {
ul.Disconnect(nil)
// we probably shouldn't delete the login if disconnect isn't finished
ul.Disconnect()
}
var portals []*database.UserPortal
var err error
@ -298,7 +313,7 @@ func (ul *UserLogin) Delete(ctx context.Context, state status.BridgeState, opts
if !opts.unlocked {
ul.Bridge.cacheLock.Unlock()
}
backgroundCtx := context.WithoutCancel(ctx)
backgroundCtx := zerolog.Ctx(ctx).WithContext(ul.Bridge.BackgroundCtx)
if !opts.BlockingCleanup {
go ul.deleteSpace(backgroundCtx)
} else {
@ -495,9 +510,9 @@ var _ status.BridgeStateFiller = (*UserLogin)(nil)
func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeState {
state.UserID = ul.UserMXID
state.RemoteID = string(ul.ID)
state.RemoteID = ul.ID
state.RemoteName = ul.RemoteName
state.RemoteProfile = &ul.RemoteProfile
state.RemoteProfile = ul.RemoteProfile
filler, ok := ul.Client.(status.BridgeStateFiller)
if ok {
return filler.FillBridgeState(state)
@ -505,22 +520,52 @@ func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeStat
return state
}
func (ul *UserLogin) Disconnect(done func()) {
if done != nil {
defer done()
func (ul *UserLogin) Disconnect() {
ul.DisconnectWithTimeout(0)
}
func (ul *UserLogin) DisconnectWithTimeout(timeout time.Duration) {
ul.disconnectOnce.Do(func() {
ul.disconnectInternal(timeout)
})
}
func (ul *UserLogin) disconnectInternal(timeout time.Duration) {
ul.BridgeState.StopUnknownErrorReconnect()
disconnected := make(chan struct{})
go func() {
ul.Client.Disconnect()
close(disconnected)
}()
var timeoutC <-chan time.Time
if timeout > 0 {
timeoutC = time.After(timeout)
}
client := ul.Client
if client != nil {
ul.Client = nil
disconnected := make(chan struct{})
go func() {
client.Disconnect()
close(disconnected)
}()
for {
select {
case <-disconnected:
case <-time.After(5 * time.Second):
ul.Log.Warn().Msg("Client disconnection timed out")
return
case <-time.After(2 * time.Second):
ul.Log.Warn().Msg("Client disconnection taking long")
case <-timeoutC:
ul.Log.Error().Msg("Client disconnection timed out")
return
}
}
}
func (ul *UserLogin) recreateClient(ctx context.Context) error {
oldClient := ul.Client
err := ul.Bridge.Network.LoadUserLogin(ctx, ul)
if err != nil {
return err
}
if ul.Client == oldClient {
zerolog.Ctx(ctx).Warn().Msg("LoadUserLogin didn't update client")
} else {
zerolog.Ctx(ctx).Debug().Msg("Recreated user login client")
}
ul.disconnectOnce = sync.Once{}
return nil
}

614
client.go

File diff suppressed because it is too large Load diff

158
client_ephemeral_test.go Normal file
View file

@ -0,0 +1,158 @@
// Copyright (c) 2026 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 mautrix_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
func TestClient_SendEphemeralEvent_UsesUnstablePathTxnAndTS(t *testing.T) {
roomID := id.RoomID("!room:example.com")
evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}
txnID := "txn-123"
var gotPath string
var gotQueryTS string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQueryTS = r.URL.Query().Get("ts")
assert.Equal(t, http.MethodPut, r.Method)
_, _ = w.Write([]byte(`{"event_id":"$evt"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
roomID,
evtType,
map[string]any{"foo": "bar"},
mautrix.ReqSendEvent{TransactionID: txnID, Timestamp: 1234},
)
require.NoError(t, err)
assert.True(t, strings.Contains(gotPath, "/_matrix/client/unstable/com.beeper.ephemeral/rooms/"))
assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/com.example.ephemeral/"+txnID))
assert.Equal(t, "1234", gotQueryTS)
}
func TestClient_SendEphemeralEvent_UnsupportedReturnsMUnrecognized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"errcode":"M_UNRECOGNIZED","error":"Unrecognized endpoint"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
id.RoomID("!room:example.com"),
event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType},
map[string]any{"foo": "bar"},
)
require.Error(t, err)
assert.True(t, errors.Is(err, mautrix.MUnrecognized))
}
func TestClient_SendEphemeralEvent_EncryptsInEncryptedRooms(t *testing.T) {
roomID := id.RoomID("!room:example.com")
evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}
txnID := "txn-encrypted"
stateStore := mautrix.NewMemoryStateStore()
err := stateStore.SetEncryptionEvent(context.Background(), roomID, &event.EncryptionEventContent{
Algorithm: id.AlgorithmMegolmV1,
})
require.NoError(t, err)
fakeCrypto := &fakeCryptoHelper{
encryptedContent: &event.EncryptedEventContent{
Algorithm: id.AlgorithmMegolmV1,
MegolmCiphertext: []byte("ciphertext"),
},
}
var gotPath string
var gotBody map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
assert.Equal(t, http.MethodPut, r.Method)
err := json.NewDecoder(r.Body).Decode(&gotBody)
require.NoError(t, err)
_, _ = w.Write([]byte(`{"event_id":"$evt"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
cli.StateStore = stateStore
cli.Crypto = fakeCrypto
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
roomID,
evtType,
map[string]any{"foo": "bar"},
mautrix.ReqSendEvent{TransactionID: txnID},
)
require.NoError(t, err)
assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/m.room.encrypted/"+txnID))
assert.Equal(t, string(id.AlgorithmMegolmV1), gotBody["algorithm"])
assert.Equal(t, 1, fakeCrypto.encryptCalls)
assert.Equal(t, roomID, fakeCrypto.lastRoomID)
assert.Equal(t, evtType, fakeCrypto.lastEventType)
}
type fakeCryptoHelper struct {
encryptCalls int
lastRoomID id.RoomID
lastEventType event.Type
lastEncryptInput any
encryptedContent *event.EncryptedEventContent
}
func (f *fakeCryptoHelper) Encrypt(_ context.Context, roomID id.RoomID, eventType event.Type, content any) (*event.EncryptedEventContent, error) {
f.encryptCalls++
f.lastRoomID = roomID
f.lastEventType = eventType
f.lastEncryptInput = content
return f.encryptedContent, nil
}
func (f *fakeCryptoHelper) Decrypt(context.Context, *event.Event) (*event.Event, error) {
return nil, nil
}
func (f *fakeCryptoHelper) WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool {
return false
}
func (f *fakeCryptoHelper) RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) {
}
func (f *fakeCryptoHelper) Init(context.Context) error {
return nil
}

133
commands/container.go Normal file
View file

@ -0,0 +1,133 @@
// Copyright (c) 2026 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"
"slices"
"strings"
"sync"
"go.mau.fi/util/exmaps"
"maunium.net/go/mautrix/event/cmdschema"
)
type CommandContainer[MetaType any] struct {
commands map[string]*Handler[MetaType]
aliases map[string]string
lock sync.RWMutex
parent *Handler[MetaType]
}
func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] {
return &CommandContainer[MetaType]{
commands: make(map[string]*Handler[MetaType]),
aliases: make(map[string]string),
}
}
func (cont *CommandContainer[MetaType]) AllSpecs() []*cmdschema.EventContent {
data := make(exmaps.Set[*Handler[MetaType]])
cont.collectHandlers(data)
specs := make([]*cmdschema.EventContent, 0, data.Size())
for handler := range data.Iter() {
if handler.Parameters != nil {
specs = append(specs, handler.Spec())
}
}
return specs
}
func (cont *CommandContainer[MetaType]) collectHandlers(into exmaps.Set[*Handler[MetaType]]) {
cont.lock.RLock()
defer cont.lock.RUnlock()
for _, handler := range cont.commands {
into.Add(handler)
if handler.subcommandContainer != nil {
handler.subcommandContainer.collectHandlers(into)
}
}
}
// Register registers the given command handlers.
func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) {
if cont == nil {
return
}
cont.lock.Lock()
defer cont.lock.Unlock()
for i, handler := range handlers {
if handler == nil {
panic(fmt.Errorf("handler #%d is nil", i+1))
}
cont.registerOne(handler)
}
}
func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType]) {
if strings.ToLower(handler.Name) != handler.Name {
panic(fmt.Errorf("command %q is not lowercase", handler.Name))
} else if val, alreadyExists := cont.commands[handler.Name]; alreadyExists && val != handler {
panic(fmt.Errorf("tried to register command %q, but it's already registered", handler.Name))
} else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists {
panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget))
}
if !slices.Contains(handler.parents, cont.parent) {
handler.parents = append(handler.parents, cont.parent)
handler.nestedNameCache = nil
}
cont.commands[handler.Name] = handler
for _, alias := range handler.Aliases {
if strings.ToLower(alias) != alias {
panic(fmt.Errorf("alias %q is not lowercase", alias))
} else if val, alreadyExists := cont.aliases[alias]; alreadyExists && val != handler.Name {
panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered for %q", alias, handler.Name, cont.aliases[alias]))
} else if _, alreadyExists = cont.commands[alias]; alreadyExists {
panic(fmt.Errorf("tried to register alias %q for %q, but it's already registered as a command", alias, handler.Name))
}
cont.aliases[alias] = handler.Name
}
handler.initSubcommandContainer()
}
func (cont *CommandContainer[MetaType]) Unregister(handlers ...*Handler[MetaType]) {
if cont == nil {
return
}
cont.lock.Lock()
defer cont.lock.Unlock()
for _, handler := range handlers {
cont.unregisterOne(handler)
}
}
func (cont *CommandContainer[MetaType]) unregisterOne(handler *Handler[MetaType]) {
delete(cont.commands, handler.Name)
for _, alias := range handler.Aliases {
if cont.aliases[alias] == handler.Name {
delete(cont.aliases, alias)
}
}
}
func (cont *CommandContainer[MetaType]) GetHandler(name string) *Handler[MetaType] {
if cont == nil {
return nil
}
cont.lock.RLock()
defer cont.lock.RUnlock()
alias, ok := cont.aliases[name]
if ok {
name = alias
}
handler, ok := cont.commands[name]
if !ok {
handler = cont.commands[UnknownCommandName]
}
return handler
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -8,6 +8,7 @@ package commands
import (
"context"
"encoding/json"
"fmt"
"strings"
@ -15,6 +16,7 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
// Event contains the data of a single command event.
@ -23,6 +25,10 @@ type Event[MetaType any] struct {
*event.Event
// RawInput is the entire message before splitting into command and arguments.
RawInput string
// ParentCommands is the chain of commands leading up to this command.
// This is only set if the command is a subcommand.
ParentCommands []string
ParentHandlers []*Handler[MetaType]
// Command is the lowercased first word of the message.
Command string
// Args are the rest of the message split by whitespace ([strings.Fields]).
@ -30,10 +36,15 @@ type Event[MetaType any] struct {
// RawArgs is the same as args, but without the splitting by whitespace.
RawArgs string
StructuredArgs json.RawMessage
Ctx context.Context
Log *zerolog.Logger
Proc *Processor[MetaType]
Handler *Handler[MetaType]
Meta MetaType
redactedBy id.EventID
}
var IDHTMLParser = &format.HTMLParser{
@ -53,39 +64,78 @@ var IDHTMLParser = &format.HTMLParser{
}
// ParseEvent parses a message into a command event struct.
func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] {
content := evt.Content.Parsed.(*event.MessageEventContent)
func (proc *Processor[MetaType]) ParseEvent(ctx context.Context, evt *event.Event) *Event[MetaType] {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" {
return nil
}
text := content.Body
if content.Format == event.FormatHTML {
text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx))
}
if content.MSC4391BotCommand != nil {
if !content.Mentions.Has(proc.Client.UserID) || len(content.Mentions.UserIDs) != 1 {
return nil
}
wrapped := StructuredCommandToEvent[MetaType](ctx, evt, content.MSC4391BotCommand)
wrapped.RawInput = text
return wrapped
}
if len(text) == 0 {
return nil
}
return RawTextToEvent[MetaType](ctx, evt, text)
}
func StructuredCommandToEvent[MetaType any](ctx context.Context, evt *event.Event, content *event.MSC4391BotCommandInput) *Event[MetaType] {
commandParts := strings.Split(content.Command, " ")
return &Event[MetaType]{
Event: evt,
// Fake a command and args to let the subcommand finder in Process work.
Command: commandParts[0],
Args: commandParts[1:],
Ctx: ctx,
Log: zerolog.Ctx(ctx),
StructuredArgs: content.Arguments,
}
}
func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] {
parts := strings.Fields(text)
if len(parts) == 0 {
parts = []string{""}
}
return &Event[MetaType]{
Event: evt,
RawInput: text,
Command: strings.ToLower(parts[0]),
Args: parts[1:],
RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "),
Log: zerolog.Ctx(ctx),
Ctx: ctx,
}
}
type ReplyOpts struct {
AllowHTML bool
AllowMarkdown bool
Reply bool
Thread bool
SendAsText bool
AllowHTML bool
AllowMarkdown bool
Reply bool
Thread bool
SendAsText bool
Edit id.EventID
OverrideMentions *event.Mentions
Extra map[string]any
}
func (evt *Event[MetaType]) Reply(msg string, args ...any) {
func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true})
return evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true})
}
func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) {
func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID {
content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML)
if opts.Thread {
content.SetThread(evt.Event)
@ -96,24 +146,47 @@ func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) {
if !opts.SendAsText {
content.MsgType = event.MsgNotice
}
_, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content)
if opts.Edit != "" {
content.SetEdit(opts.Edit)
}
if opts.OverrideMentions != nil {
content.Mentions = opts.OverrideMentions
}
var wrapped any = &content
if opts.Extra != nil {
wrapped = &event.Content{
Parsed: &content,
Raw: opts.Extra,
}
}
resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, wrapped)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply")
return ""
}
return resp.EventID
}
func (evt *Event[MetaType]) React(emoji string) {
_, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji)
func (evt *Event[MetaType]) React(emoji string) id.EventID {
resp, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction")
return ""
}
return resp.EventID
}
func (evt *Event[MetaType]) Redact() {
_, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID)
func (evt *Event[MetaType]) Redact() id.EventID {
if evt.redactedBy != "" {
return evt.redactedBy
}
resp, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command")
return ""
}
evt.redactedBy = resp.EventID
return resp.EventID
}
func (evt *Event[MetaType]) MarkRead() {
@ -122,3 +195,43 @@ func (evt *Event[MetaType]) MarkRead() {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt")
}
}
// ShiftArg removes the first argument from the Args list and RawArgs data and returns it.
// RawInput will not be modified.
func (evt *Event[MetaType]) ShiftArg() string {
if len(evt.Args) == 0 {
return ""
}
firstArg := evt.Args[0]
evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ")
evt.Args = evt.Args[1:]
return firstArg
}
// UnshiftArg reverses ShiftArg by adding the given value to the beginning of the Args list and RawArgs data.
func (evt *Event[MetaType]) UnshiftArg(arg string) {
evt.RawArgs = arg + " " + evt.RawArgs
evt.Args = append([]string{arg}, evt.Args...)
}
func (evt *Event[MetaType]) ParseArgs(into any) error {
return json.Unmarshal(evt.StructuredArgs, into)
}
func ParseArgs[T, MetaType any](evt *Event[MetaType]) (into T, err error) {
err = evt.ParseArgs(&into)
return
}
func WithParsedArgs[T, MetaType any](fn func(*Event[MetaType], T)) func(*Event[MetaType]) {
return func(evt *Event[MetaType]) {
parsed, err := ParseArgs[T, MetaType](evt)
if err != nil {
evt.Log.Debug().Err(err).Msg("Failed to parse structured args into struct")
// TODO better error, usage info? deduplicate with Process
evt.Reply("Failed to parse arguments: %v", err)
return
}
fn(evt, parsed)
}
}

105
commands/handler.go Normal file
View file

@ -0,0 +1,105 @@
// Copyright (c) 2026 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/event"
"maunium.net/go/mautrix/event/cmdschema"
)
type Handler[MetaType any] struct {
// Func is the function that is called when the command is executed.
Func func(ce *Event[MetaType])
// Name is the primary name of the command. It must be lowercase.
Name string
// Aliases are alternative names for the command. They must be lowercase.
Aliases []string
// Subcommands are subcommands of this command.
Subcommands []*Handler[MetaType]
// PreFunc is a function that is called before checking subcommands.
// It can be used to have parameters between subcommands (e.g. `!rooms <room ID> <command>`).
// Event.ShiftArg will likely be useful for implementing such parameters.
PreFunc func(ce *Event[MetaType])
// Description is a short description of the command.
Description *event.ExtensibleTextContainer
// Parameters is a description of structured command parameters.
// If set, the StructuredArgs field of Event will be populated.
Parameters []*cmdschema.Parameter
TailParam string
parents []*Handler[MetaType]
nestedNameCache []string
subcommandContainer *CommandContainer[MetaType]
}
func (h *Handler[MetaType]) NestedNames() []string {
if h.nestedNameCache != nil {
return h.nestedNameCache
}
nestedNames := make([]string, 0, (1+len(h.Aliases))*len(h.parents))
for _, parent := range h.parents {
if parent == nil {
nestedNames = append(nestedNames, h.Name)
nestedNames = append(nestedNames, h.Aliases...)
} else {
for _, parentName := range parent.NestedNames() {
nestedNames = append(nestedNames, parentName+" "+h.Name)
for _, alias := range h.Aliases {
nestedNames = append(nestedNames, parentName+" "+alias)
}
}
}
}
h.nestedNameCache = nestedNames
return nestedNames
}
func (h *Handler[MetaType]) Spec() *cmdschema.EventContent {
names := h.NestedNames()
return &cmdschema.EventContent{
Command: names[0],
Aliases: names[1:],
Parameters: h.Parameters,
Description: h.Description,
TailParam: h.TailParam,
}
}
func (h *Handler[MetaType]) CopyFrom(other *Handler[MetaType]) {
if h.Parameters == nil {
h.Parameters = other.Parameters
h.TailParam = other.TailParam
}
h.Func = other.Func
}
func (h *Handler[MetaType]) initSubcommandContainer() {
if len(h.Subcommands) > 0 {
h.subcommandContainer = NewCommandContainer[MetaType]()
h.subcommandContainer.parent = h
h.subcommandContainer.Register(h.Subcommands...)
} else {
h.subcommandContainer = nil
}
}
func MakeUnknownCommandHandler[MetaType any](prefix string) *Handler[MetaType] {
return &Handler[MetaType]{
Name: UnknownCommandName,
Func: func(ce *Event[MetaType]) {
if len(ce.ParentCommands) == 0 {
ce.Reply("Unknown command `%s%s`", prefix, ce.Command)
} else {
ce.Reply("Unknown subcommand `%s%s %s`", prefix, strings.Join(ce.ParentCommands, " "), ce.Command)
}
},
}
}

View file

@ -61,9 +61,7 @@ func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool {
func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] {
return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool {
if ce.Command == prefix && len(ce.Args) > 0 {
ce.Command = strings.ToLower(ce.Args[0])
ce.RawArgs = strings.TrimLeft(strings.TrimPrefix(ce.RawArgs, ce.Args[0]), " ")
ce.Args = ce.Args[1:]
ce.Command = strings.ToLower(ce.ShiftArg())
return true
}
return false

View file

@ -1,4 +1,4 @@
// Copyright (c) 2025 Tulir Asokan
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
@ -8,10 +8,8 @@ package commands
import (
"context"
"fmt"
"runtime/debug"
"strings"
"sync"
"github.com/rs/zerolog"
@ -22,85 +20,36 @@ import (
// Processor implements boilerplate code for splitting messages into a command and arguments,
// and finding the appropriate handler for the command.
type Processor[MetaType any] struct {
*CommandContainer[MetaType]
Client *mautrix.Client
LogArgs bool
PreValidator PreValidator[MetaType]
Meta MetaType
commands map[string]*Handler[MetaType]
aliases map[string]string
lock sync.RWMutex
}
type Handler[MetaType any] struct {
Func func(ce *Event[MetaType])
// Name is the primary name of the command. It must be lowercase.
Name string
// Aliases are alternative names for the command. They must be lowercase.
Aliases []string
ReactionCommandPrefix string
}
// UnknownCommandName is the name of the fallback handler which is used if no other handler is found.
// If even the unknown command handler is not found, the command is ignored.
const UnknownCommandName = "unknown-command"
const UnknownCommandName = "__unknown-command__"
func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] {
proc := &Processor[MetaType]{
Client: cli,
PreValidator: ValidatePrefixSubstring[MetaType]("!"),
commands: make(map[string]*Handler[MetaType]),
aliases: make(map[string]string),
CommandContainer: NewCommandContainer[MetaType](),
Client: cli,
PreValidator: ValidatePrefixSubstring[MetaType]("!"),
}
proc.Register(&Handler[MetaType]{
Name: UnknownCommandName,
Func: func(ce *Event[MetaType]) {
ce.Reply("Unknown command")
},
})
proc.Register(MakeUnknownCommandHandler[MetaType]("!"))
return proc
}
// Register registers the given command handlers.
func (proc *Processor[MetaType]) Register(handlers ...*Handler[MetaType]) {
proc.lock.Lock()
defer proc.lock.Unlock()
for _, handler := range handlers {
proc.registerOne(handler)
}
}
func (proc *Processor[MetaType]) registerOne(handler *Handler[MetaType]) {
if strings.ToLower(handler.Name) != handler.Name {
panic(fmt.Errorf("command %q is not lowercase", handler.Name))
}
proc.commands[handler.Name] = handler
for _, alias := range handler.Aliases {
if strings.ToLower(alias) != alias {
panic(fmt.Errorf("alias %q is not lowercase", alias))
}
proc.aliases[alias] = handler.Name
}
}
func (proc *Processor[MetaType]) Unregister(handlers ...*Handler[MetaType]) {
proc.lock.Lock()
defer proc.lock.Unlock()
for _, handler := range handlers {
proc.unregisterOne(handler)
}
}
func (proc *Processor[MetaType]) unregisterOne(handler *Handler[MetaType]) {
delete(proc.commands, handler.Name)
for _, alias := range handler.Aliases {
if proc.aliases[alias] == handler.Name {
delete(proc.aliases, alias)
}
}
}
func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) {
log := *zerolog.Ctx(ctx)
log := zerolog.Ctx(ctx).With().
Stringer("sender", evt.Sender).
Stringer("room_id", evt.RoomID).
Stringer("event_id", evt.ID).
Logger()
defer func() {
panicErr := recover()
if panicErr != nil {
@ -118,38 +67,85 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event)
}
}
}()
parsed := ParseEvent[MetaType](ctx, evt)
if !proc.PreValidator.Validate(parsed) {
var parsed *Event[MetaType]
switch evt.Type {
case event.EventReaction:
parsed = proc.ParseReaction(ctx, evt)
case event.EventMessage:
parsed = proc.ParseEvent(ctx, evt)
}
if parsed == nil || (!proc.PreValidator.Validate(parsed) && parsed.StructuredArgs == nil) {
return
}
parsed.Proc = proc
parsed.Meta = proc.Meta
parsed.Ctx = ctx
realCommand := parsed.Command
proc.lock.RLock()
alias, ok := proc.aliases[realCommand]
if ok {
realCommand = alias
handler := proc.GetHandler(parsed.Command)
if handler == nil {
return
}
handler, ok := proc.commands[realCommand]
if !ok {
handler, ok = proc.commands[UnknownCommandName]
parsed.Handler = handler
if handler.PreFunc != nil {
handler.PreFunc(parsed)
}
proc.lock.RUnlock()
if !ok {
handlerChain := zerolog.Arr()
handlerChain.Str(handler.Name)
for handler.subcommandContainer != nil && len(parsed.Args) > 0 {
subHandler := handler.subcommandContainer.GetHandler(strings.ToLower(parsed.Args[0]))
if subHandler != nil {
parsed.ParentCommands = append(parsed.ParentCommands, parsed.Command)
parsed.ParentHandlers = append(parsed.ParentHandlers, handler)
handler = subHandler
handlerChain.Str(subHandler.Name)
parsed.Command = strings.ToLower(parsed.ShiftArg())
parsed.Handler = subHandler
if subHandler.PreFunc != nil {
subHandler.PreFunc(parsed)
}
} else {
break
}
}
if parsed.StructuredArgs != nil && len(parsed.Args) > 0 {
// TODO allow unknown command handlers to be called?
// The client sent MSC4391 data, but the target command wasn't found
log.Debug().Msg("Didn't find handler for MSC4391 command")
return
}
logWith := log.With().
Str("command", realCommand).
Stringer("sender", evt.Sender).
Stringer("room_id", evt.RoomID)
Str("command", parsed.Command).
Array("handler", handlerChain)
if len(parsed.ParentCommands) > 0 {
logWith = logWith.Strs("parent_commands", parsed.ParentCommands)
}
if proc.LogArgs {
logWith = logWith.Strs("args", parsed.Args)
if parsed.StructuredArgs != nil {
logWith = logWith.RawJSON("structured_args", parsed.StructuredArgs)
}
}
log = logWith.Logger()
parsed.Ctx = log.WithContext(ctx)
parsed.Handler = handler
parsed.Proc = proc
parsed.Meta = proc.Meta
parsed.Log = &log
if handler.Parameters != nil && parsed.StructuredArgs == nil {
// The handler wants structured parameters, but the client didn't send MSC4391 data
var err error
parsed.StructuredArgs, err = handler.Spec().ParseArguments(parsed.RawArgs)
if err != nil {
log.Debug().Err(err).Msg("Failed to parse structured arguments")
// TODO better error, usage info? deduplicate with WithParsedArgs
parsed.Reply("Failed to parse arguments: %v", err)
return
}
if proc.LogArgs {
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.RawJSON("structured_args", parsed.StructuredArgs)
})
}
}
log.Debug().Msg("Processing command")
handler.Func(parsed)

143
commands/reactions.go Normal file
View file

@ -0,0 +1,143 @@
// Copyright (c) 2026 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"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
const ReactionCommandsKey = "fi.mau.reaction_commands"
const ReactionMultiUseKey = "fi.mau.reaction_multi_use"
type ReactionCommandData struct {
Command string `json:"command"`
Args any `json:"args,omitempty"`
}
func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] {
content, ok := evt.Content.Parsed.(*event.ReactionEventContent)
if !ok {
return nil
}
evtID := content.RelatesTo.EventID
if evtID == "" || !strings.HasPrefix(content.RelatesTo.Key, proc.ReactionCommandPrefix) {
return nil
}
targetEvt, err := proc.Client.GetEvent(ctx, evt.RoomID, evtID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", evtID).Msg("Failed to get target event for reaction")
return nil
} else if targetEvt.Sender != proc.Client.UserID || targetEvt.Unsigned.RedactedBecause != nil {
return nil
}
if targetEvt.Type == event.EventEncrypted {
if proc.Client.Crypto == nil {
zerolog.Ctx(ctx).Warn().
Stringer("target_event_id", evtID).
Msg("Received reaction to encrypted event, but don't have crypto helper in client")
return nil
}
_ = targetEvt.Content.ParseRaw(targetEvt.Type)
targetEvt, err = proc.Client.Crypto.Decrypt(ctx, targetEvt)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("target_event_id", evtID).
Msg("Failed to decrypt target event for reaction")
return nil
}
}
reactionCommands, ok := targetEvt.Content.Raw[ReactionCommandsKey].(map[string]any)
if !ok {
zerolog.Ctx(ctx).Trace().
Stringer("target_event_id", evtID).
Msg("Reaction target event doesn't have commands key")
return nil
}
isMultiUse, _ := targetEvt.Content.Raw[ReactionMultiUseKey].(bool)
rawCmd, ok := reactionCommands[content.RelatesTo.Key]
if !ok {
zerolog.Ctx(ctx).Debug().
Stringer("target_event_id", evtID).
Str("reaction_key", content.RelatesTo.Key).
Msg("Reaction command not found in target event")
return nil
}
var wrappedEvt *Event[MetaType]
switch typedCmd := rawCmd.(type) {
case string:
wrappedEvt = RawTextToEvent[MetaType](ctx, evt, typedCmd)
case map[string]any:
var input event.MSC4391BotCommandInput
if marshaled, err := json.Marshal(typedCmd); err != nil {
} else if err = json.Unmarshal(marshaled, &input); err != nil {
} else {
wrappedEvt = StructuredCommandToEvent[MetaType](ctx, evt, &input)
}
}
if wrappedEvt == nil {
zerolog.Ctx(ctx).Debug().
Stringer("target_event_id", evtID).
Str("reaction_key", content.RelatesTo.Key).
Msg("Reaction command data is invalid")
return nil
}
wrappedEvt.Proc = proc
wrappedEvt.Redact()
if !isMultiUse {
DeleteAllReactions(ctx, proc.Client, evt)
}
if wrappedEvt.Command == "" {
return nil
}
return wrappedEvt
}
func DeleteAllReactionsCommandFunc[MetaType any](ce *Event[MetaType]) {
DeleteAllReactions(ce.Ctx, ce.Proc.Client, ce.Event)
}
func DeleteAllReactions(ctx context.Context, client *mautrix.Client, evt *event.Event) {
rel, ok := evt.Content.Parsed.(event.Relatable)
if !ok {
return
}
relation := rel.OptionalGetRelatesTo()
if relation == nil {
return
}
targetEvt := relation.GetReplyTo()
if targetEvt == "" {
targetEvt = relation.GetAnnotationID()
}
if targetEvt == "" {
return
}
relations, err := client.GetRelations(ctx, evt.RoomID, targetEvt, &mautrix.ReqGetRelations{
RelationType: event.RelAnnotation,
EventType: event.EventReaction,
Limit: 20,
})
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get reactions to delete")
return
}
for _, relEvt := range relations.Chunk {
_, err = client.RedactEvent(ctx, relEvt.RoomID, relEvt.ID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("event_id", relEvt.ID).Msg("Failed to redact reaction event")
}
}
}

View file

@ -7,11 +7,13 @@
package aescbc_test
import (
"bytes"
"crypto/aes"
"crypto/rand"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maunium.net/go/mautrix/crypto/aescbc"
)
@ -22,32 +24,23 @@ func TestAESCBC(t *testing.T) {
// The key length can be 32, 24, 16 bytes (OR in bits: 128, 192 or 256)
key := make([]byte, 32)
_, err = rand.Read(key)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
iv := make([]byte, aes.BlockSize)
_, err = rand.Read(iv)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
plaintext = []byte("secret message for testing")
//increase to next block size
for len(plaintext)%8 != 0 {
plaintext = append(plaintext, []byte("-")...)
}
if ciphertext, err = aescbc.Encrypt(key, iv, plaintext); err != nil {
t.Fatal(err)
}
ciphertext, err = aescbc.Encrypt(key, iv, plaintext)
require.NoError(t, err)
resultPlainText, err := aescbc.Decrypt(key, iv, ciphertext)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if string(resultPlainText) != string(plaintext) {
t.Fatalf("message '%s' (length %d) != '%s'", resultPlainText, len(resultPlainText), plaintext)
}
assert.Equal(t, string(resultPlainText), string(plaintext))
}
func TestAESCBCCase1(t *testing.T) {
@ -61,18 +54,10 @@ func TestAESCBCCase1(t *testing.T) {
key := make([]byte, 32)
iv := make([]byte, aes.BlockSize)
encrypted, err := aescbc.Encrypt(key, iv, input)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(expected, encrypted) {
t.Fatalf("encrypted did not match expected:\n%v\n%v\n", encrypted, expected)
}
require.NoError(t, err)
assert.Equal(t, expected, encrypted, "encrypted output does not match expected")
decrypted, err := aescbc.Decrypt(key, iv, encrypted)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(input, decrypted) {
t.Fatalf("decrypted did not match expected:\n%v\n%v\n", decrypted, input)
}
require.NoError(t, err)
assert.Equal(t, input, decrypted, "decrypted output does not match input")
}

View file

@ -9,6 +9,7 @@ package attachment
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
@ -20,13 +21,24 @@ import (
)
var (
HashMismatch = errors.New("mismatching SHA-256 digest")
UnsupportedVersion = errors.New("unsupported Matrix file encryption version")
UnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm")
InvalidKey = errors.New("failed to decode key")
InvalidInitVector = errors.New("failed to decode initialization vector")
InvalidHash = errors.New("failed to decode SHA-256 hash")
ReaderClosed = errors.New("encrypting reader was already closed")
ErrHashMismatch = errors.New("mismatching SHA-256 digest")
ErrUnsupportedVersion = errors.New("unsupported Matrix file encryption version")
ErrUnsupportedAlgorithm = errors.New("unsupported JWK encryption algorithm")
ErrInvalidKey = errors.New("failed to decode key")
ErrInvalidInitVector = errors.New("failed to decode initialization vector")
ErrInvalidHash = errors.New("failed to decode SHA-256 hash")
ErrReaderClosed = errors.New("encrypting reader was already closed")
)
// Deprecated: use variables prefixed with Err
var (
HashMismatch = ErrHashMismatch
UnsupportedVersion = ErrUnsupportedVersion
UnsupportedAlgorithm = ErrUnsupportedAlgorithm
InvalidKey = ErrInvalidKey
InvalidInitVector = ErrInvalidInitVector
InvalidHash = ErrInvalidHash
ReaderClosed = ErrReaderClosed
)
var (
@ -84,25 +96,25 @@ func (ef *EncryptedFile) decodeKeys(includeHash bool) error {
if ef.decoded != nil {
return nil
} else if len(ef.Key.Key) != keyBase64Length {
return InvalidKey
return ErrInvalidKey
} else if len(ef.InitVector) != ivBase64Length {
return InvalidInitVector
return ErrInvalidInitVector
} else if includeHash && len(ef.Hashes.SHA256) != hashBase64Length {
return InvalidHash
return ErrInvalidHash
}
ef.decoded = &decodedKeys{}
_, err := base64.RawURLEncoding.Decode(ef.decoded.key[:], []byte(ef.Key.Key))
if err != nil {
return InvalidKey
return ErrInvalidKey
}
_, err = base64.RawStdEncoding.Decode(ef.decoded.iv[:], []byte(ef.InitVector))
if err != nil {
return InvalidInitVector
return ErrInvalidInitVector
}
if includeHash {
_, err = base64.RawStdEncoding.Decode(ef.decoded.sha256[:], []byte(ef.Hashes.SHA256))
if err != nil {
return InvalidHash
return ErrInvalidHash
}
}
return nil
@ -178,7 +190,7 @@ var _ io.ReadSeekCloser = (*encryptingReader)(nil)
func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) {
if r.closed {
return 0, ReaderClosed
return 0, ErrReaderClosed
}
if offset != 0 || whence != io.SeekStart {
return 0, fmt.Errorf("attachments.EncryptStream: only seeking to the beginning is supported")
@ -199,15 +211,20 @@ func (r *encryptingReader) Seek(offset int64, whence int) (int64, error) {
func (r *encryptingReader) Read(dst []byte) (n int, err error) {
if r.closed {
return 0, ReaderClosed
return 0, ErrReaderClosed
} else if r.isDecrypting && r.file.decoded == nil {
if err = r.file.PrepareForDecryption(); err != nil {
return
}
}
n, err = r.source.Read(dst)
if r.isDecrypting {
r.hash.Write(dst[:n])
}
r.stream.XORKeyStream(dst[:n], dst[:n])
r.hash.Write(dst[:n])
if !r.isDecrypting {
r.hash.Write(dst[:n])
}
return
}
@ -217,10 +234,8 @@ func (r *encryptingReader) Close() (err error) {
err = closer.Close()
}
if r.isDecrypting {
var downloadedChecksum [utils.SHAHashLength]byte
r.hash.Sum(downloadedChecksum[:])
if downloadedChecksum != r.file.decoded.sha256 {
return HashMismatch
if !hmac.Equal(r.hash.Sum(nil), r.file.decoded.sha256[:]) {
return ErrHashMismatch
}
} else {
r.file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString(r.hash.Sum(nil))
@ -261,9 +276,9 @@ func (ef *EncryptedFile) Decrypt(ciphertext []byte) ([]byte, error) {
// DecryptInPlace will always call this automatically, so calling this manually is not necessary when using that function.
func (ef *EncryptedFile) PrepareForDecryption() error {
if ef.Version != "v2" {
return UnsupportedVersion
return ErrUnsupportedVersion
} else if ef.Key.Algorithm != "A256CTR" {
return UnsupportedAlgorithm
return ErrUnsupportedAlgorithm
} else if err := ef.decodeKeys(true); err != nil {
return err
}
@ -274,12 +289,13 @@ func (ef *EncryptedFile) PrepareForDecryption() error {
func (ef *EncryptedFile) DecryptInPlace(data []byte) error {
if err := ef.PrepareForDecryption(); err != nil {
return err
} else if ef.decoded.sha256 != sha256.Sum256(data) {
return HashMismatch
} else {
utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv)
return nil
}
dataHash := sha256.Sum256(data)
if !hmac.Equal(ef.decoded.sha256[:], dataHash[:]) {
return ErrHashMismatch
}
utils.XorA256CTR(data, ef.decoded.key, ef.decoded.iv)
return nil
}
// DecryptStream wraps the given io.Reader in order to decrypt the data.
@ -292,9 +308,10 @@ func (ef *EncryptedFile) DecryptInPlace(data []byte) error {
func (ef *EncryptedFile) DecryptStream(reader io.Reader) io.ReadSeekCloser {
block, _ := aes.NewCipher(ef.decoded.key[:])
return &encryptingReader{
stream: cipher.NewCTR(block, ef.decoded.iv[:]),
hash: sha256.New(),
source: reader,
file: ef,
isDecrypting: true,
stream: cipher.NewCTR(block, ef.decoded.iv[:]),
hash: sha256.New(),
source: reader,
file: ef,
}
}

View file

@ -53,33 +53,33 @@ func TestUnsupportedVersion(t *testing.T) {
file := parseHelloWorld()
file.Version = "foo"
err := file.DecryptInPlace([]byte(helloWorldCiphertext))
assert.ErrorIs(t, err, UnsupportedVersion)
assert.ErrorIs(t, err, ErrUnsupportedVersion)
}
func TestUnsupportedAlgorithm(t *testing.T) {
file := parseHelloWorld()
file.Key.Algorithm = "bar"
err := file.DecryptInPlace([]byte(helloWorldCiphertext))
assert.ErrorIs(t, err, UnsupportedAlgorithm)
assert.ErrorIs(t, err, ErrUnsupportedAlgorithm)
}
func TestHashMismatch(t *testing.T) {
file := parseHelloWorld()
file.Hashes.SHA256 = base64.RawStdEncoding.EncodeToString([]byte(random32Bytes))
err := file.DecryptInPlace([]byte(helloWorldCiphertext))
assert.ErrorIs(t, err, HashMismatch)
assert.ErrorIs(t, err, ErrHashMismatch)
}
func TestTooLongHash(t *testing.T) {
file := parseHelloWorld()
file.Hashes.SHA256 = "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVlciBhZGlwaXNjaW5nIGVsaXQuIFNlZCBwb3N1ZXJlIGludGVyZHVtIHNlbS4gUXVpc3F1ZSBsaWd1bGEgZXJvcyB1bGxhbWNvcnBlciBxdWlzLCBsYWNpbmlhIHF1aXMgZmFjaWxpc2lzIHNlZCBzYXBpZW4uCg"
err := file.DecryptInPlace([]byte(helloWorldCiphertext))
assert.ErrorIs(t, err, InvalidHash)
assert.ErrorIs(t, err, ErrInvalidHash)
}
func TestTooShortHash(t *testing.T) {
file := parseHelloWorld()
file.Hashes.SHA256 = "5/Gy1JftyyQ"
err := file.DecryptInPlace([]byte(helloWorldCiphertext))
assert.ErrorIs(t, err, InvalidHash)
assert.ErrorIs(t, err, ErrInvalidHash)
}

View file

@ -68,6 +68,10 @@ func calculateCompatMAC(macKey []byte) []byte {
//
// [Section 11.12.3.2.2 of the Spec]: https://spec.matrix.org/v1.9/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2
func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*EncryptedSessionData[T], error) {
return EncryptSessionDataWithPubkey(backupKey.PublicKey(), sessionData)
}
func EncryptSessionDataWithPubkey[T any](pubkey *ecdh.PublicKey, sessionData T) (*EncryptedSessionData[T], error) {
sessionJSON, err := json.Marshal(sessionData)
if err != nil {
return nil, err
@ -78,7 +82,7 @@ func EncryptSessionData[T any](backupKey *MegolmBackupKey, sessionData T) (*Encr
return nil, err
}
sharedSecret, err := ephemeralKey.ECDH(backupKey.PublicKey())
sharedSecret, err := ephemeralKey.ECDH(pubkey)
if err != nil {
return nil, err
}

View file

@ -17,31 +17,43 @@ package canonicaljson
import (
"testing"
"github.com/stretchr/testify/assert"
)
func testSortJSON(t *testing.T, input, want string) {
got := SortJSON([]byte(input), nil)
// Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace.
if string(CompactJSON(got, nil)) != want {
t.Errorf("SortJSON(%q): want %q got %q", input, want, got)
}
}
func TestSortJSON(t *testing.T) {
testSortJSON(t, `[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`)
testSortJSON(t, `{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`,
`{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`)
testSortJSON(t, `[true,false,null]`, `[true,false,null]`)
testSortJSON(t, `[9007199254740991]`, `[9007199254740991]`)
testSortJSON(t, "\t\n[9007199254740991]", `[9007199254740991]`)
var tests = []struct {
input string
want string
}{
{"{}", "{}"},
{`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`},
{`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`},
{`[true,false,null]`, `[true,false,null]`},
{`[9007199254740991]`, `[9007199254740991]`},
{"\t\n[9007199254740991]", `[9007199254740991]`},
{`[true,false,null]`, `[true,false,null]`},
{`[{"b":"two","a":1}]`, `[{"a":1,"b":"two"}]`},
{`{"B":{"4":4,"3":3},"A":{"1":1,"2":2}}`, `{"A":{"1":1,"2":2},"B":{"3":3,"4":4}}`},
{`[true,false,null]`, `[true,false,null]`},
{`[9007199254740991]`, `[9007199254740991]`},
{"\t\n[9007199254740991]", `[9007199254740991]`},
{`[true,false,null]`, `[true,false,null]`},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
got := SortJSON([]byte(test.input), nil)
// Squash out the whitespace before comparing the JSON in case SortJSON had inserted whitespace.
assert.EqualValues(t, test.want, string(CompactJSON(got, nil)))
})
}
}
func testCompactJSON(t *testing.T, input, want string) {
t.Helper()
got := string(CompactJSON([]byte(input), nil))
if got != want {
t.Errorf("CompactJSON(%q): want %q got %q", input, want, got)
}
assert.EqualValues(t, want, got)
}
func TestCompactJSON(t *testing.T) {
@ -74,18 +86,23 @@ func TestCompactJSON(t *testing.T) {
testCompactJSON(t, `["\"\\\/"]`, `["\"\\/"]`)
}
func testReadHex(t *testing.T, input string, want uint32) {
got := readHexDigits([]byte(input))
if want != got {
t.Errorf("readHexDigits(%q): want 0x%x got 0x%x", input, want, got)
func TestReadHex(t *testing.T) {
tests := []struct {
input string
want uint32
}{
{"0123", 0x0123},
{"4567", 0x4567},
{"89AB", 0x89AB},
{"CDEF", 0xCDEF},
{"89ab", 0x89AB},
{"cdef", 0xCDEF},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
got := readHexDigits([]byte(test.input))
assert.Equal(t, test.want, got)
})
}
}
func TestReadHex(t *testing.T) {
testReadHex(t, "0123", 0x0123)
testReadHex(t, "4567", 0x4567)
testReadHex(t, "89AB", 0x89AB)
testReadHex(t, "CDEF", 0xCDEF)
testReadHex(t, "89ab", 0x89AB)
testReadHex(t, "cdef", 0xCDEF)
}

View file

@ -11,6 +11,8 @@ import (
"context"
"fmt"
"go.mau.fi/util/jsonbytes"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/crypto/signatures"
@ -33,9 +35,9 @@ func (cskc *CrossSigningKeysCache) PublicKeys() *CrossSigningPublicKeysCache {
}
type CrossSigningSeeds struct {
MasterKey []byte
SelfSigningKey []byte
UserSigningKey []byte
MasterKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.master"`
SelfSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.self_signing"`
UserSigningKey jsonbytes.UnpaddedURLBytes `json:"m.cross_signing.user_signing"`
}
func (mach *OlmMachine) ExportCrossSigningKeys() CrossSigningSeeds {
@ -133,7 +135,7 @@ func (mach *OlmMachine) PublishCrossSigningKeys(ctx context.Context, keys *Cross
}
userKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), userSig)
err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq{
err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq[any]{
Master: masterKey,
SelfSigning: selfKey,
UserSigning: userKey,

View file

@ -20,6 +20,20 @@ type CrossSigningPublicKeysCache struct {
UserSigningKey id.Ed25519
}
func (mach *OlmMachine) GetOwnVerificationStatus(ctx context.Context) (hasKeys, isVerified bool, err error) {
pubkeys := mach.GetOwnCrossSigningPublicKeys(ctx)
if pubkeys != nil {
hasKeys = true
isVerified, err = mach.CryptoStore.IsKeySignedBy(
ctx, mach.Client.UserID, mach.GetAccount().SigningKey(), mach.Client.UserID, pubkeys.SelfSigningKey,
)
if err != nil {
err = fmt.Errorf("failed to check if current device is signed by own self-signing key: %w", err)
}
}
return
}
func (mach *OlmMachine) GetOwnCrossSigningPublicKeys(ctx context.Context) *CrossSigningPublicKeysCache {
if mach.crossSigningPubkeys != nil {
return mach.crossSigningPubkeys
@ -49,8 +63,8 @@ func (mach *OlmMachine) GetCrossSigningPublicKeys(ctx context.Context, userID id
if len(dbKeys) > 0 {
masterKey, ok := dbKeys[id.XSUsageMaster]
if ok {
selfSigning, _ := dbKeys[id.XSUsageSelfSigning]
userSigning, _ := dbKeys[id.XSUsageUserSigning]
selfSigning := dbKeys[id.XSUsageSelfSigning]
userSigning := dbKeys[id.XSUsageUserSigning]
return &CrossSigningPublicKeysCache{
MasterKey: masterKey.Key,
SelfSigningKey: selfSigning.Key,

View file

@ -8,6 +8,7 @@ package crypto
import (
"context"
"errors"
"fmt"
"maunium.net/go/mautrix"
@ -71,6 +72,46 @@ func (mach *OlmMachine) GenerateAndUploadCrossSigningKeysWithPassword(ctx contex
}, passphrase)
}
func (mach *OlmMachine) VerifyWithRecoveryKey(ctx context.Context, recoveryKey string) error {
keyID, keyData, err := mach.SSSS.GetDefaultKeyData(ctx)
if err != nil {
return fmt.Errorf("failed to get default SSSS key data: %w", err)
}
key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey)
if errors.Is(err, ssss.ErrUnverifiableKey) {
mach.machOrContextLog(ctx).Warn().
Str("key_id", keyID).
Msg("SSSS key is unverifiable, trying to use without verifying")
} else if err != nil {
return err
}
err = mach.FetchCrossSigningKeysFromSSSS(ctx, key)
if err != nil {
return fmt.Errorf("failed to fetch cross-signing keys from SSSS: %w", err)
}
err = mach.SignOwnDevice(ctx, mach.OwnIdentity())
if err != nil {
return fmt.Errorf("failed to sign own device: %w", err)
}
err = mach.SignOwnMasterKey(ctx)
if err != nil {
return fmt.Errorf("failed to sign own master key: %w", err)
}
return nil
}
func (mach *OlmMachine) GenerateAndVerifyWithRecoveryKey(ctx context.Context) (recoveryKey string, err error) {
recoveryKey, _, err = mach.GenerateAndUploadCrossSigningKeys(ctx, nil, "")
if err != nil {
err = fmt.Errorf("failed to generate and upload cross-signing keys: %w", err)
} else if err = mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
err = fmt.Errorf("failed to sign own device: %w", err)
} else if err = mach.SignOwnMasterKey(ctx); err != nil {
err = fmt.Errorf("failed to sign own master key: %w", err)
}
return
}
// GenerateAndUploadCrossSigningKeys generates a new key with all corresponding cross-signing keys.
//
// A passphrase can be provided to generate the SSSS key. If the passphrase is empty, a random key
@ -97,12 +138,12 @@ func (mach *OlmMachine) GenerateAndUploadCrossSigningKeys(ctx context.Context, u
// Publish cross-signing keys
err = mach.PublishCrossSigningKeys(ctx, keysCache, uiaCallback)
if err != nil {
return "", nil, fmt.Errorf("failed to publish cross-signing keys: %w", err)
return key.RecoveryKey(), keysCache, fmt.Errorf("failed to publish cross-signing keys: %w", err)
}
err = mach.SSSS.SetDefaultKeyID(ctx, key.ID)
if err != nil {
return "", nil, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err)
return key.RecoveryKey(), keysCache, fmt.Errorf("failed to mark %s as the default key: %w", key.ID, err)
}
return key.RecoveryKey(), keysCache, nil

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