Skip to content

feat(channels): add Matrix channel integration#500

Open
penso wants to merge 1 commit intomainfrom
feat/matrix-channel
Open

feat(channels): add Matrix channel integration#500
penso wants to merge 1 commit intomainfrom
feat/matrix-channel

Conversation

@penso
Copy link
Copy Markdown
Collaborator

@penso penso commented Mar 28, 2026

Summary

Cherry-picked from #331 (which contained multiple unrelated features).

  • New moltis-matrix crate using matrix-sdk 0.16 (official Rust Matrix SDK, powers Element X)
  • Supports DM and room messaging with allowlist/OTP gating, streaming via m.replace edit-in-place, reactions, typing indicators, and invite auto-join
  • Feature-gated in gateway and CLI (matrix feature, default-enabled)
  • Follows the same crate structure and patterns as moltis-discord

What's included

File What
crates/channels/src/plugin.rs ChannelType::Matrix variant + descriptor
crates/matrix/src/plugin.rs ChannelPlugin impl — session restore, sync loop, cancellation
crates/matrix/src/outbound.rs ChannelOutbound + ChannelStreamOutbound — text, html, media, reactions, streaming
crates/matrix/src/handler.rs Inbound message dispatch, OTP challenge, invite auto-join
crates/matrix/src/access.rs DM/room policy enforcement, user/room allowlists
crates/matrix/src/config.rs MatrixAccountConfig with ChannelConfigView
crates/matrix/src/state.rs AccountState with Client, CancellationToken, OTP
crates/matrix/src/error.rs Error → ChannelError conversion

Validation

Completed

  • cargo check -p moltis-matrix
  • cargo test -p moltis-matrix
  • Rust fmt check

Remaining

  • ./scripts/local-validate.sh
  • Manual: configure Matrix bot, start moltis, send DM, verify response
  • Manual: verify streaming edit-in-place in Element

Manual QA

  1. Add Matrix config to moltis.toml with a test homeserver
  2. Start moltis gateway
  3. Send a DM to the bot user
  4. Verify response is received
  5. Verify streaming shows as edit-in-place updates

Supersedes the Matrix portion of #331.

New `moltis-matrix` crate using matrix-sdk 0.16 (official Rust Matrix SDK).
Supports DM and room messaging with allowlist/OTP gating, streaming via
m.replace edit-in-place, reactions, typing indicators, and invite auto-join.

- `MatrixPlugin` implementing `ChannelPlugin` with session restore and
  persistent sync loop via `CancellationToken`
- `ChannelOutbound`: send_text (markdown), send_html, send_media,
  send_typing, send_interactive (text fallback), add_reaction
- `ChannelStreamOutbound`: edit-in-place streaming with 500ms throttle
- Access control: DM/room policies, user/room allowlists, OTP challenge
- `ChannelType::Matrix` variant with descriptor (GatewayLoop, streaming,
  threads, reactions, OTP)
- Feature-gated in gateway and CLI (`matrix` feature, default-enabled)
- matrix-sdk default-features disabled to avoid libsqlite3-sys conflict
  with sqlx (same pattern as WhatsApp crate)
- 11 tests: config round-trip, access control, descriptor coherence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 28, 2026

Greptile Summary

This PR adds a new moltis-matrix crate implementing a Matrix/Element channel integration. It follows the established moltis-discord crate structure and implements ChannelPlugin, ChannelOutbound, ChannelStreamOutbound, and ChannelThreadContext. The streaming implementation using m.replace edit-in-place, OTP self-approval flow, and invite auto-join are well-structured. However, several correctness issues were found that should be resolved before merging:

  • Access token exposed in serialized JSON (config.rs): The custom serde serializer writes the raw Matrix access token into the JSON returned by account_config_json(). Any admin endpoint or log that captures that value will leak the credential.
  • E2EE config is silently non-functional (Cargo.toml): MatrixAccountConfig.e2ee defaults to true, but matrix-sdk is compiled without the e2ee feature — encryption is never performed, giving users a false sense of security.
  • DM detection via member count is unreliable (access.rs): is_dm_room(member_count <= 2) misclassifies 2-person group rooms as DMs (potential policy bypass) and multi-participant DMs as group rooms. Matrix exposes room.is_direct() for this purpose.
  • unwrap() on client.user_id() (plugin.rs): Panics the gateway process if the SDK doesn't populate the session user_id after session restore.
  • Hardcoded OTP expiry (handler.rs): The expires_at emitted in OtpChallenge events is always unix_now() + 300 rather than using otp_cooldown_secs from config.

Confidence Score: 4/5

Not safe to merge as-is — plaintext token serialization and silent E2EE misconfiguration are real security defects that should be resolved first.

Four P1 findings block merge: (1) Matrix access token written in plaintext into account_config_json output; (2) e2ee defaults to true but the SDK feature is absent, providing no encryption silently; (3) DM detection by member count can misclassify rooms, enabling an access-control bypass; (4) unwrap() on user_id can panic the gateway process. The crate structure, sync-loop cancellation, OTP flow, and streaming are solid — fixing these four points would make this ready to merge.

crates/matrix/src/config.rs (token serialization), crates/matrix/Cargo.toml (e2ee feature), crates/matrix/src/access.rs (DM detection), crates/matrix/src/plugin.rs (unwrap on user_id)

Important Files Changed

Filename Overview
crates/matrix/src/config.rs MatrixAccountConfig — access token serialized in plaintext; e2ee field silently non-functional
crates/matrix/Cargo.toml matrix-sdk dependency lacks the e2ee feature, making the e2ee config field silently non-functional despite defaulting to true
crates/matrix/src/access.rs DM detection uses member count heuristic instead of room.is_direct() — can misclassify rooms and create policy bypass
crates/matrix/src/plugin.rs ChannelPlugin impl — unwrap() on user_id after session restore could panic the gateway process
crates/matrix/src/handler.rs Inbound message dispatch with OTP and invite auto-join — OTP expiry hardcoded to 300s, ignoring otp_cooldown_secs
crates/matrix/src/outbound.rs ChannelOutbound + streaming — remove_reaction and media upload silently unimplemented; html_to_plain misses many tags
crates/matrix/src/state.rs AccountState struct — clean, correct use of Mutex for OTP and Arc for the shared map
crates/matrix/src/error.rs Error type with From for ChannelError conversions — straightforward and correct
crates/channels/src/plugin.rs Added ChannelType::Matrix variant and descriptor — cleanly integrated alongside existing channel types
crates/gateway/src/server.rs Gateway wires up MatrixPlugin behind the matrix feature flag — follows the same pattern as moltis-discord

Sequence Diagram

sequenceDiagram
    participant User as Matrix User
    participant SDK as matrix-sdk (sync loop)
    participant Handler as handler.rs
    participant Access as access.rs
    participant OTP as OtpState
    participant Sink as ChannelEventSink
    participant Outbound as MatrixOutbound

    User->>SDK: sends room message
    SDK->>Handler: handle_room_message(ev, room)
    Handler->>Handler: skip if sender == bot_user_id
    Handler->>Access: check_access(config, chat_type, sender, room)
    alt Access denied + OTP eligible
        Handler->>OTP: verify(sender, code) or initiate(sender)
        OTP-->>Handler: OtpVerifyResult / OtpInitResult
        Handler->>User: send OTP prompt or result via send_text()
        Handler->>Sink: emit OtpChallenge / OtpResolved
    else Access granted
        Handler->>Sink: emit InboundMessage
        Handler->>Sink: dispatch_to_chat(body, reply_to, meta)
        Sink->>Outbound: send_stream() or send_text()
        alt Streaming
            Outbound->>User: initial message (>=30 chars buffered)
            loop every 500ms while tokens arrive
                Outbound->>User: m.replace edit-in-place
            end
            Outbound->>User: final edit (StreamEvent::Done)
        else Non-streaming
            Outbound->>User: RoomMessageEventContent::text_markdown
        end
    end

    User->>SDK: room invite received
    SDK->>Handler: handle_invite(ev, room)
    alt auto_join = true
        Handler->>SDK: room.join()
    end
Loading

Reviews (1): Last reviewed commit: "feat(channels): add Matrix channel integ..." | Re-trigger Greptile

Comment on lines +139 to +144
fn serialize_secret<S: serde::Serializer>(
secret: &Secret<String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(secret.expose_secret())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Access token written in plaintext during serialization

The custom serializer on lines 139–144 calls expose_secret() and writes the raw access token into the JSON output. Because account_config_json() in plugin.rs (line 252–256) calls serde_json::to_value(&s.config), any call to that method — from an admin API, config dump, or log statement — will expose the plaintext credential.

The serializer should redact the value instead of exposing it:

serializer.serialize_str("[REDACTED]")

Config persistence that genuinely needs the raw token should use a separate, explicitly gated serialization path rather than the general-purpose account_config_json method.

version.workspace = true

[dependencies]
matrix-sdk = { default-features = false, features = ["markdown", "native-tls"], version = "0.16" }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 e2ee config field is silently non-functional

The MatrixAccountConfig exposes an e2ee field that defaults to true (config.rs line 85), but matrix-sdk is compiled without the e2ee feature here. As a result, the SDK will never perform end-to-end encryption regardless of the config value — users who rely on e2ee = true for privacy will get plaintext traffic without any warning.

Either add the e2ee feature to the dependency:

matrix-sdk = { default-features = false, features = ["markdown", "native-tls", "e2ee"], version = "0.16" }

Or remove the e2ee field from MatrixAccountConfig and document that E2EE is not yet supported.

Comment on lines +55 to +61
pub fn is_dm_room(joined_member_count: u64) -> ChatType {
if joined_member_count <= 2 {
ChatType::Dm
} else {
ChatType::Group
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Member-count heuristic for DM detection is unreliable

is_dm_room classifies any room with ≤ 2 members as a DM. This creates two correctness problems:

  1. False DM (security bypass): A 2-person group chat (the bot + one other user) is classified as a DM. If dm_policy is Open but room_policy is Allowlist, a user in an unlisted group chat gains access because the room is wrongly treated as a DM.
  2. False group: A bridged or federated DM can easily have 3 participants, causing it to be treated as a group room and rejected under a restrictive room_policy.

Matrix exposes a proper is_direct() method on Room (backed by m.direct account data). The handler should pass room.is_direct() to the access layer rather than using member count.

// In handler.rs, replace:
let member_count = room.joined_members_count();
let chat_type = is_dm_room(member_count);

// With:
let chat_type = if room.is_direct().await.unwrap_or(false) {
    ChatType::Dm
} else {
    ChatType::Group
};

And remove the is_dm_room helper from access.rs.

.await
.map_err(|e| ChannelError::external("matrix session restore", e))?;

let bot_user_id = client.user_id().unwrap().to_owned();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Panic on missing user_id after session restore

client.user_id() returns Option<&UserId>. Calling .unwrap() here panics the entire gateway process if the SDK doesn't populate the user_id — which can happen if restore_session silently fails to set session metadata in an edge case.

Since the error is recoverable, prefer propagating it:

let bot_user_id = client
    .user_id()
    .ok_or_else(|| ChannelError::external("matrix session restore", "user_id not set after restore_session"))?
    .to_owned();


match result {
OtpInitResult::Created(code) => {
let expires_at = unix_now() + 300;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OTP expiry hardcoded, ignores otp_cooldown_secs

expires_at is always unix_now() + 300 (5 minutes), even though MatrixAccountConfig has an otp_cooldown_secs field. The OTP challenge event emitted to the sink will have an expiry that diverges from what OtpState actually enforces, potentially confusing admin UIs that display when a code expires.

let expires_at = unix_now() + config.otp_cooldown_secs as i64;

Comment on lines +159 to +171
async fn remove_reaction(
&self,
account_id: &str,
_channel_id: &str,
message_id: &str,
emoji: &str,
) -> ChannelResult<()> {
debug!(
account_id,
message_id, emoji, "remove_reaction not yet implemented for Matrix"
);
Ok(())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 remove_reaction silently succeeds without doing anything

The method returns Ok(()) after a debug! log, so callers get no indication that the reaction was not removed. This can cause stale reactions to remain visible in the client. Consider at minimum a warn! log:

warn!(
    account_id,
    message_id, emoji, "remove_reaction not yet implemented for Matrix — reaction will remain visible"
);

Comment on lines +299 to +315
fn html_to_plain(html: &str) -> String {
html.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n")
.replace("<p>", "")
.replace("</p>", "\n")
.replace("<b>", "**")
.replace("</b>", "**")
.replace("<strong>", "**")
.replace("</strong>", "**")
.replace("<i>", "_")
.replace("</i>", "_")
.replace("<em>", "_")
.replace("</em>", "_")
.replace("<code>", "`")
.replace("</code>", "`")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Naive HTML stripping leaves unknown tags in place

html_to_plain only handles a fixed set of known tags. Any other HTML element — <div>, <span>, <ul>, <li>, <a href="...">, <script>, etc. — will be passed through verbatim to the Matrix plain-text body. The Matrix spec requires the plain-text body to be human-readable.

Consider using a lightweight HTML-stripping crate (e.g. ammonia or htmd), or add a residual tag-stripping pass after the existing replacements.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq bot commented Mar 28, 2026

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing feat/matrix-channel (2bb87b6) with main (efc18a9)2

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (7966ba9) during the generation of this report, so efc18a9 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

2 participants