Skip to content

Let a Matrix client take part in a room's call - #25

Merged
TroyHernandez merged 11 commits into
mainfrom
matrixrtc-calls
Oct 8, 2026
Merged

TroyHernandez merged 11 commits into
mainfrom
matrixrtc-calls

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

Adds the signaling side of a MatrixRTC call to the Matrix adapter, so a bot can join the call a room's members started (Element Call, FluffyChat 2.10) while something else handles the media.

Surface

  • chat_call_join(client, channel, intent = "voice") announces the client as a call member, obtains the media server's address and token, and starts exchanging per-member media keys over Olm. Returns a chat_call.
  • chat_call_media(call): url, jwt, identity, own key, peers' keys. What a media side needs to connect.
  • chat_call_updates(call): peer keys, own rotated key, members, ended, since the last look. Cleared on read.
  • chat_call_leave(client, call).
  • Capability flag calls: TRUE on an e2ee Matrix client with mx.client >= 0.2.1.1, FALSE on loopback, IRC, Slack and Telegram, whose generics stop with the usual "not supported ... Check chat_capabilities()$calls".

What the adapter owns (R/matrix-call.R)

mx.client does the protocol (mx_call_join / mx_call_handle / mx_call_leave). The adapter supplies what those need and would otherwise be copied out of it:

  • the live credentials: every operation gets client\$env\$mx at the point of use, since a call outlives relogins;
  • the Olm sessions: a key send opens sessions with the peers' devices, and they come back into the adapter's crypto state, or the adapter's next save would overwrite them;
  • the decrypted to-device events: matrix_crypto_decrypt() keeps this poll's on the context, so the call reads peers' keys without decrypting the sync a second time (which would advance the ratchets twice).

The hook runs in chat_poll() after the cursor commit; a call that cannot be advanced warns and the next poll retries, rather than costing the poll its messages. Calls live on the identity's interned crypto context rather than the client object, because a consumer that builds a client per poll (corteza) would otherwise lose them.

Also

  • mx.client Suggests floor 0.2.1 -> 0.2.1.1 (the version with the call API).
  • .call constructor seam for tests; inst/tinytest/test_matrix_call.R (65 assertions, no mx.client needed). Full suite 1473/0 locally.
  • Version 0.1.0 -> 0.1.0.1.

CI note: mx.client 0.2.1.1 is not on CRAN yet (CRAN has 0.2.1), and the workflow installs mx.client from CRAN, so the "Verify adapter dependencies" step will fail on the floor until it is released or the workflow takes mx.client from the drat (which has 0.2.1.1) for the interim.

Not yet exercised against a real homeserver and SFU; the consumer is cornball-ai/corteza's livekit-call branch.

chat_call_join() announces the client as a MatrixRTC member, obtains
the media server's address and token, and exchanges per-member media
keys over Olm for as long as the client polls; chat_call_media() hands
a media side what it needs to connect; chat_call_updates() reports the
keys and membership that arrived since the last look; chat_call_leave()
withdraws. The media itself is the caller's, typically in another
process.

mx.client does the protocol. The adapter owns what the protocol needs
and would otherwise be copied out of it: each operation is handed the
live credentials (a call outlives relogins) and the adapter's Olm
sessions, and the sessions a key send opens come back into the
adapter's crypto state, or its next save would write an older set over
them. The call reads the to-device events the adapter already
decrypted for the poll rather than decrypting the sync again. A call
that cannot be advanced is a warning, not an error: the cursor has
moved, and the next poll retries.

Needs an e2ee client and mx.client 0.2.1.1 (the floor in Suggests).
The capability flag is calls, FALSE on every other adapter.
A consumer that builds a chat client per poll (corteza does) lost its
calls with each client. The crypto context is interned per identity and
store and holds the Olm sessions the call's keys live in, so the calls
live there too: any client for the identity advances them.
The call surface's mx.client floor is 0.2.1.1, released on the drat and
not yet on CRAN (0.2.1); mx.client 0.2.1.1 in turn imports mx.api
0.3.1.1, also drat-only for now. With the drat listed first,
available.packages() takes its builds for both. The drat entry can go
once CRAN carries these versions.
chat_poll() on an adapter with the calls capability returns `calls`:
one notice per channel where someone other than the client announced
or withdrew a call membership in this poll, with the identities now in
the call (mx.client's reading of the events) and those who left. A bot
that joins calls on demand acts on these; the call button in a Matrix
client posts a membership event and nothing else, so this is the only
signal a bot gets that a call started.
FluffyChat sends its call key as a room event rather than to-device,
so the call needs the poll's decrypted room events beside its to-device
events; they go to mx_call_handle() as processed$events. While a call
is on, each poll logs what arrived by type (raw and decrypted to-device
events, call keys in a room timeline), which is what told the first live
call's missing key apart from a parse failure.
mx.client now hands back every decrypted room event, and a call's key
or a ring notification has no body; one of those became a chat_message
with an empty body, which a consumer's message loop tripped over
("argument is of length zero" in corteza's bot, once per call). Also
chat_call_media() reports the identities the client's key reached at
join, since the first send happens inside the join, before any poll.
A bot's top level never returns, so a deferred warning about an Olm
message that could not be decrypted is never printed; a peer's media
key dying that way left no trace. During a call those warnings are
messages.
A call trims an interrupted reply by editing the message it posted to
the room, and calls run in encrypted rooms, where chat_edit refused:
an edit carries its replacement text in m.new_content, and the plain
path would put that on the homeserver in the clear. But mx_send_encrypted
takes the whole event content, so the relation and m.new_content can
ride inside the Megolm ciphertext like any other field. The crypto send
path gains an extra-content argument, chat_edit routes through it when
the room is encrypted, and chat_capabilities()$edits is now TRUE on an
e2ee client.
@TroyHernandez
TroyHernandez merged commit 5c8093a into main Oct 8, 2026
2 checks passed
@TroyHernandez
TroyHernandez deleted the matrixrtc-calls branch October 8, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant