feat(matrix): enable e2ee flow and add channel operations docs
This commit is contained in:
parent
e6029e8cec
commit
18b6ea1e79
12 changed files with 2827 additions and 289 deletions
1725
Cargo.lock
generated
1725
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,9 @@ tokio-util = { version = "0.7", default-features = false }
|
||||||
# HTTP client - minimal features
|
# HTTP client - minimal features
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking", "multipart", "stream", "socks"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking", "multipart", "stream", "socks"] }
|
||||||
|
|
||||||
|
# Matrix client + E2EE decryption
|
||||||
|
matrix-sdk = { version = "0.16", default-features = false, features = ["e2e-encryption", "rustls-tls"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||||
serde_json = { version = "1.0", default-features = false, features = ["std"] }
|
serde_json = { version = "1.0", default-features = false, features = ["std"] }
|
||||||
|
|
@ -81,7 +84,7 @@ ring = "0.17"
|
||||||
prost = { version = "0.14", default-features = false }
|
prost = { version = "0.14", default-features = false }
|
||||||
|
|
||||||
# Memory / persistence
|
# Memory / persistence
|
||||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
|
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,8 @@ Inbound sender policy is now consistent:
|
||||||
|
|
||||||
This keeps accidental exposure low by default.
|
This keeps accidental exposure low by default.
|
||||||
|
|
||||||
|
Full channel configuration reference: [docs/channels-reference.md](docs/channels-reference.md).
|
||||||
|
|
||||||
Recommended low-friction setup (secure + fast):
|
Recommended low-friction setup (secure + fast):
|
||||||
|
|
||||||
- **Telegram:** allowlist your own `@username` (without `@`) and/or your numeric Telegram user ID.
|
- **Telegram:** allowlist your own `@username` (without `@`) and/or your numeric Telegram user ID.
|
||||||
|
|
@ -834,9 +836,11 @@ Start from the docs hub for a task-based map:
|
||||||
|
|
||||||
Core collaboration references:
|
Core collaboration references:
|
||||||
|
|
||||||
- Documentation index: [docs/README.md](docs/README.md)
|
- Documentation hub: [docs/README.md](docs/README.md)
|
||||||
- Documentation template: [docs/doc-template.md](docs/doc-template.md)
|
- Documentation template: [docs/doc-template.md](docs/doc-template.md)
|
||||||
- Documentation change checklist: [docs/README.md#4-documentation-change-checklist](docs/README.md#4-documentation-change-checklist)
|
- Documentation change checklist: [docs/README.md#4-documentation-change-checklist](docs/README.md#4-documentation-change-checklist)
|
||||||
|
- Channel configuration reference: [docs/channels-reference.md](docs/channels-reference.md)
|
||||||
|
- Matrix encrypted-room operations: [docs/matrix-e2ee-guide.md](docs/matrix-e2ee-guide.md)
|
||||||
- Contribution guide: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- Contribution guide: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- PR workflow policy: [docs/pr-workflow.md](docs/pr-workflow.md)
|
- PR workflow policy: [docs/pr-workflow.md](docs/pr-workflow.md)
|
||||||
- Reviewer playbook (triage + deep review): [docs/reviewer-playbook.md](docs/reviewer-playbook.md)
|
- Reviewer playbook (triage + deep review): [docs/reviewer-playbook.md](docs/reviewer-playbook.md)
|
||||||
|
|
@ -867,7 +871,7 @@ We're building in the open because the best ideas come from everywhere. If you'r
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE) and [NOTICE](NOTICE) for contributor attribution
|
MIT — see [LICENSE](LICENSE) for license terms and attribution baseline
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
|
||||||
| Check config defaults and keys quickly | [config-reference.md](config-reference.md) |
|
| Check config defaults and keys quickly | [config-reference.md](config-reference.md) |
|
||||||
| Operate runtime (day-2 runbook) | [operations-runbook.md](operations-runbook.md) |
|
| Operate runtime (day-2 runbook) | [operations-runbook.md](operations-runbook.md) |
|
||||||
| Troubleshoot install/runtime/channel issues | [troubleshooting.md](troubleshooting.md) |
|
| Troubleshoot install/runtime/channel issues | [troubleshooting.md](troubleshooting.md) |
|
||||||
|
| Run Matrix encrypted-room setup and diagnostics | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
|
||||||
| Browse docs by category | [SUMMARY.md](SUMMARY.md) |
|
| Browse docs by category | [SUMMARY.md](SUMMARY.md) |
|
||||||
| See project PR/issue docs snapshot | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
|
| See project PR/issue docs snapshot | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
|
||||||
- [commands-reference.md](commands-reference.md) — command lookup by workflow
|
- [commands-reference.md](commands-reference.md) — command lookup by workflow
|
||||||
- [providers-reference.md](providers-reference.md) — provider IDs, aliases, credential env vars
|
- [providers-reference.md](providers-reference.md) — provider IDs, aliases, credential env vars
|
||||||
- [channels-reference.md](channels-reference.md) — channel capabilities and setup paths
|
- [channels-reference.md](channels-reference.md) — channel capabilities and setup paths
|
||||||
|
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — Matrix encrypted-room (E2EE) setup and no-response diagnostics
|
||||||
- [config-reference.md](config-reference.md) — high-signal config keys and secure defaults
|
- [config-reference.md](config-reference.md) — high-signal config keys and secure defaults
|
||||||
- [operations-runbook.md](operations-runbook.md) — day-2 runtime operations and rollback flow
|
- [operations-runbook.md](operations-runbook.md) — day-2 runtime operations and rollback flow
|
||||||
- [troubleshooting.md](troubleshooting.md) — common failure signatures and recovery steps
|
- [troubleshooting.md](troubleshooting.md) — common failure signatures and recovery steps
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,41 @@
|
||||||
# ZeroClaw Channels Reference
|
# Channels Reference
|
||||||
|
|
||||||
This reference maps channel capabilities, config blocks, allowlist behavior, and setup paths.
|
This document is the canonical reference for channel configuration in ZeroClaw.
|
||||||
|
|
||||||
Last verified: **February 18, 2026**.
|
For encrypted Matrix rooms, also read the dedicated runbook:
|
||||||
|
- [Matrix E2EE Guide](./matrix-e2ee-guide.md)
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Paths
|
||||||
|
|
||||||
```bash
|
- Need a full config reference by channel: jump to [Per-Channel Config Examples](#4-per-channel-config-examples).
|
||||||
zeroclaw channel list
|
- Need a no-response diagnosis flow: jump to [Troubleshooting Checklist](#6-troubleshooting-checklist).
|
||||||
zeroclaw channel start
|
- Need Matrix encrypted-room help: use [Matrix E2EE Guide](./matrix-e2ee-guide.md).
|
||||||
zeroclaw channel doctor
|
- Need deployment/network assumptions (polling vs webhook): use [Network Deployment](./network-deployment.md).
|
||||||
zeroclaw channel bind-telegram <IDENTITY>
|
|
||||||
|
## FAQ: Matrix setup passes but no reply
|
||||||
|
|
||||||
|
This is the most common symptom (same class as issue #499). Check these in order:
|
||||||
|
|
||||||
|
1. **Allowlist mismatch**: `allowed_users` does not include the sender (or is empty).
|
||||||
|
2. **Wrong room target**: bot is not joined to the configured `room_id` / alias target room.
|
||||||
|
3. **Token/account mismatch**: token is valid but belongs to another Matrix account.
|
||||||
|
4. **E2EE device identity gap**: `whoami` does not return `device_id` and config does not provide one.
|
||||||
|
5. **Key sharing/trust gap**: room keys were not shared to the bot device, so encrypted events cannot be decrypted.
|
||||||
|
6. **Stale runtime state**: config changed but `zeroclaw daemon` was not restarted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configuration Namespace
|
||||||
|
|
||||||
|
All channel settings live under `channels_config` in `~/.zeroclaw/config.toml`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config]
|
||||||
|
cli = true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each channel is enabled by creating its sub-table (for example, `[channels_config.telegram]`).
|
||||||
|
|
||||||
## In-Chat Runtime Model Switching (Telegram / Discord)
|
## In-Chat Runtime Model Switching (Telegram / Discord)
|
||||||
|
|
||||||
When running `zeroclaw channel start` (or daemon mode), Telegram and Discord now support sender-scoped runtime switching:
|
When running `zeroclaw channel start` (or daemon mode), Telegram and Discord now support sender-scoped runtime switching:
|
||||||
|
|
@ -30,91 +53,287 @@ Notes:
|
||||||
|
|
||||||
## Channel Matrix
|
## Channel Matrix
|
||||||
|
|
||||||
| Channel | Config section | Access control field | Setup path |
|
---
|
||||||
|---|---|---|---|
|
|
||||||
| `CLI` | n/a (always enabled) | n/a | Built-in |
|
|
||||||
| `Telegram` | `[channels_config.telegram]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `Discord` | `[channels_config.discord]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `Slack` | `[channels_config.slack]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `Mattermost` | `[channels_config.mattermost]` | `allowed_users` | Manual config |
|
|
||||||
| `Webhook` | `[channels_config.webhook]` | n/a (`secret` optional) | `zeroclaw onboard` or manual |
|
|
||||||
| `iMessage` | `[channels_config.imessage]` | `allowed_contacts` | `zeroclaw onboard` (macOS) |
|
|
||||||
| `Matrix` | `[channels_config.matrix]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `Signal` | `[channels_config.signal]` | `allowed_from` | Manual config |
|
|
||||||
| `WhatsApp` | `[channels_config.whatsapp]` | `allowed_numbers` | `zeroclaw onboard` |
|
|
||||||
| `Email` | `[channels_config.email]` | `allowed_senders` | Manual config |
|
|
||||||
| `IRC` | `[channels_config.irc]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `Lark` | `[channels_config.lark]` | `allowed_users` | Manual config |
|
|
||||||
| `DingTalk` | `[channels_config.dingtalk]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
| `QQ` | `[channels_config.qq]` | `allowed_users` | `zeroclaw onboard` |
|
|
||||||
|
|
||||||
## Deny-by-Default Rules
|
## 2. Delivery Modes at a Glance
|
||||||
|
|
||||||
For channel allowlists, the runtime behavior is intentionally strict:
|
| Channel | Receive mode | Public inbound port required? |
|
||||||
|
|---|---|---|
|
||||||
|
| CLI | local stdin/stdout | No |
|
||||||
|
| Telegram | polling | No |
|
||||||
|
| Discord | gateway/websocket | No |
|
||||||
|
| Slack | events API | No (token-based channel flow) |
|
||||||
|
| Mattermost | polling | No |
|
||||||
|
| Matrix | sync API (supports E2EE) | No |
|
||||||
|
| Signal | signal-cli HTTP bridge | No (local bridge endpoint) |
|
||||||
|
| WhatsApp | webhook | Yes (public HTTPS callback) |
|
||||||
|
| Webhook | gateway endpoint (`/webhook`) | Usually yes |
|
||||||
|
| Email | IMAP polling + SMTP send | No |
|
||||||
|
| IRC | IRC socket | No |
|
||||||
|
| Lark/Feishu | websocket (default) or webhook | Webhook mode only |
|
||||||
|
| DingTalk | stream mode | No |
|
||||||
|
| QQ | bot gateway | No |
|
||||||
|
| iMessage | local integration | No |
|
||||||
|
|
||||||
- Empty allowlist (`[]`) means **deny all**.
|
---
|
||||||
- Wildcard (`["*"]`) means **allow all**.
|
|
||||||
- Explicit IDs are exact matches unless channel-specific docs state otherwise.
|
|
||||||
|
|
||||||
### Telegram pairing bootstrap
|
## 3. Allowlist Semantics
|
||||||
|
|
||||||
Telegram has a secure bootstrap flow:
|
For channels with inbound sender allowlists:
|
||||||
|
|
||||||
- Keep `allowed_users = []` to start in pairing mode.
|
- Empty allowlist: deny all inbound messages.
|
||||||
- Run `zeroclaw channel bind-telegram <IDENTITY>` to add one identity safely.
|
- `"*"`: allow all inbound senders (use for temporary verification only).
|
||||||
- After binding, restart long-running channel processes if needed (`daemon` / `channel start`).
|
- Explicit list: allow only listed senders.
|
||||||
|
|
||||||
## Minimal Config Examples
|
Field names differ by channel:
|
||||||
|
|
||||||
### Telegram
|
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/DingTalk/QQ)
|
||||||
|
- `allowed_from` (Signal)
|
||||||
|
- `allowed_numbers` (WhatsApp)
|
||||||
|
- `allowed_senders` (Email)
|
||||||
|
- `allowed_contacts` (iMessage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-Channel Config Examples
|
||||||
|
|
||||||
|
### 4.1 Telegram
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[channels_config.telegram]
|
[channels_config.telegram]
|
||||||
bot_token = "123456:ABCDEF"
|
bot_token = "123456:telegram-token"
|
||||||
allowed_users = []
|
allowed_users = ["*"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### WhatsApp
|
### 4.2 Discord
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[channels_config.whatsapp]
|
[channels_config.discord]
|
||||||
access_token = "EAABx..."
|
bot_token = "discord-bot-token"
|
||||||
phone_number_id = "123456789012345"
|
guild_id = "123456789012345678" # optional
|
||||||
verify_token = "your-verify-token"
|
allowed_users = ["*"]
|
||||||
allowed_numbers = ["+1234567890"]
|
listen_to_bots = false
|
||||||
|
mention_only = false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signal
|
### 4.3 Slack
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.slack]
|
||||||
|
bot_token = "xoxb-..."
|
||||||
|
app_token = "xapp-..." # optional
|
||||||
|
channel_id = "C1234567890" # optional
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Mattermost
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.mattermost]
|
||||||
|
url = "https://mm.example.com"
|
||||||
|
bot_token = "mattermost-token"
|
||||||
|
channel_id = "channel-id" # required for listening
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Matrix
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.matrix]
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
access_token = "syt_..."
|
||||||
|
user_id = "@zeroclaw:matrix.example.com" # optional, recommended for E2EE
|
||||||
|
device_id = "DEVICEID123" # optional, recommended for E2EE
|
||||||
|
room_id = "!room:matrix.example.com" # or room alias (#ops:matrix.example.com)
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Matrix E2EE Guide](./matrix-e2ee-guide.md) for encrypted-room troubleshooting.
|
||||||
|
|
||||||
|
### 4.6 Signal
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[channels_config.signal]
|
[channels_config.signal]
|
||||||
http_url = "http://127.0.0.1:8686"
|
http_url = "http://127.0.0.1:8686"
|
||||||
account = "+1234567890"
|
account = "+1234567890"
|
||||||
allowed_from = ["+1987654321"]
|
group_id = "dm" # optional: "dm" / group id / omitted
|
||||||
ignore_attachments = true
|
allowed_from = ["*"]
|
||||||
|
ignore_attachments = false
|
||||||
ignore_stories = true
|
ignore_stories = true
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lark
|
### 4.7 WhatsApp
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.whatsapp]
|
||||||
|
access_token = "EAAB..."
|
||||||
|
phone_number_id = "123456789012345"
|
||||||
|
verify_token = "your-verify-token"
|
||||||
|
app_secret = "your-app-secret" # optional but recommended
|
||||||
|
allowed_numbers = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 Webhook Channel Config (Gateway)
|
||||||
|
|
||||||
|
`channels_config.webhook` enables webhook-specific gateway behavior.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.webhook]
|
||||||
|
port = 8080
|
||||||
|
secret = "optional-shared-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with gateway/daemon and verify `/health`.
|
||||||
|
|
||||||
|
### 4.9 Email
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.email]
|
||||||
|
imap_host = "imap.example.com"
|
||||||
|
imap_port = 993
|
||||||
|
imap_folder = "INBOX"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 465
|
||||||
|
smtp_tls = true
|
||||||
|
username = "bot@example.com"
|
||||||
|
password = "email-password"
|
||||||
|
from_address = "bot@example.com"
|
||||||
|
poll_interval_secs = 60
|
||||||
|
allowed_senders = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.10 IRC
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.irc]
|
||||||
|
server = "irc.libera.chat"
|
||||||
|
port = 6697
|
||||||
|
nickname = "zeroclaw-bot"
|
||||||
|
username = "zeroclaw" # optional
|
||||||
|
channels = ["#zeroclaw"]
|
||||||
|
allowed_users = ["*"]
|
||||||
|
server_password = "" # optional
|
||||||
|
nickserv_password = "" # optional
|
||||||
|
sasl_password = "" # optional
|
||||||
|
verify_tls = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.11 Lark / Feishu
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[channels_config.lark]
|
[channels_config.lark]
|
||||||
app_id = "cli_xxx"
|
app_id = "cli_xxx"
|
||||||
app_secret = "xxx"
|
app_secret = "xxx"
|
||||||
allowed_users = ["ou_abc"]
|
encrypt_key = "" # optional
|
||||||
receive_mode = "websocket" # or "webhook"
|
verification_token = "" # optional
|
||||||
# port = 3100 # required only when receive_mode = "webhook"
|
allowed_users = ["*"]
|
||||||
|
use_feishu = false
|
||||||
|
receive_mode = "websocket" # or "webhook"
|
||||||
|
port = 8081 # required for webhook mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operational Notes
|
### 4.12 DingTalk
|
||||||
|
|
||||||
- `zeroclaw channel add/remove` is intentionally not a full config mutator yet; use `zeroclaw onboard` or edit `~/.zeroclaw/config.toml`.
|
```toml
|
||||||
- `zeroclaw channel doctor` validates configured channel health and prints timeout/unhealthy status.
|
[channels_config.dingtalk]
|
||||||
- If `webhook` is configured, doctor guidance points to gateway health check (`GET /health`).
|
client_id = "ding-app-key"
|
||||||
|
client_secret = "ding-app-secret"
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
## Related Docs
|
### 4.13 QQ
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.qq]
|
||||||
|
app_id = "qq-app-id"
|
||||||
|
app_secret = "qq-app-secret"
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.14 iMessage
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.imessage]
|
||||||
|
allowed_contacts = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Validation Workflow
|
||||||
|
|
||||||
|
1. Configure one channel with permissive allowlist (`"*"`) for initial verification.
|
||||||
|
2. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zeroclaw onboard --channels-only
|
||||||
|
zeroclaw daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Send a message from an expected sender.
|
||||||
|
4. Confirm a reply arrives.
|
||||||
|
5. Tighten allowlist from `"*"` to explicit IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting Checklist
|
||||||
|
|
||||||
|
If a channel appears connected but does not respond:
|
||||||
|
|
||||||
|
1. Confirm the sender identity is allowed by the correct allowlist field.
|
||||||
|
2. Confirm bot account membership/permissions in target room/channel.
|
||||||
|
3. Confirm tokens/secrets are valid (and not expired/revoked).
|
||||||
|
4. Confirm transport mode assumptions:
|
||||||
|
- polling/websocket channels do not need public inbound HTTP
|
||||||
|
- webhook channels do need reachable HTTPS callback
|
||||||
|
5. Restart `zeroclaw daemon` after config changes.
|
||||||
|
|
||||||
|
For Matrix encrypted rooms specifically, use:
|
||||||
|
- [Matrix E2EE Guide](./matrix-e2ee-guide.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Operations Appendix: Log Keywords Matrix
|
||||||
|
|
||||||
|
Use this appendix for fast triage. Match log keywords first, then follow the troubleshooting steps above.
|
||||||
|
|
||||||
|
### 7.1 Recommended capture command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info zeroclaw daemon 2>&1 | tee /tmp/zeroclaw.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Then filter channel/gateway events:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "Matrix|Telegram|Discord|Slack|Mattermost|Signal|WhatsApp|Email|IRC|Lark|DingTalk|QQ|iMessage|Webhook|Channel" /tmp/zeroclaw.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Keyword table
|
||||||
|
|
||||||
|
| Component | Startup / healthy signal | Authorization / policy signal | Transport / failure signal |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Telegram | `Telegram channel listening for messages...` | `Telegram: ignoring message from unauthorized user:` | `Telegram poll error:` / `Telegram parse error:` / `Telegram polling conflict (409):` |
|
||||||
|
| Discord | `Discord: connected and identified` | `Discord: ignoring message from unauthorized user:` | `Discord: received Reconnect (op 7)` / `Discord: received Invalid Session (op 9)` |
|
||||||
|
| Slack | `Slack channel listening on #` | `Slack: ignoring message from unauthorized user:` | `Slack poll error:` / `Slack parse error:` |
|
||||||
|
| Mattermost | `Mattermost channel listening on` | `Mattermost: ignoring message from unauthorized user:` | `Mattermost poll error:` / `Mattermost parse error:` |
|
||||||
|
| Matrix | `Matrix channel listening on room` / `Matrix room ... is encrypted; E2EE decryption is enabled via matrix-sdk.` | `Matrix whoami failed; falling back to configured session hints for E2EE session restore:` / `Matrix whoami failed while resolving listener user_id; using configured user_id hint:` | `Matrix sync error: ... retrying...` |
|
||||||
|
| Signal | `Signal channel listening via SSE on` | (allowlist checks are enforced by `allowed_from`) | `Signal SSE returned ...` / `Signal SSE connect error:` |
|
||||||
|
| WhatsApp (channel) | `WhatsApp channel active (webhook mode).` | `WhatsApp: ignoring message from unauthorized number:` | `WhatsApp send failed:` |
|
||||||
|
| Webhook / WhatsApp (gateway) | `WhatsApp webhook verified successfully` | `Webhook: rejected — not paired / invalid bearer token` / `Webhook: rejected request — invalid or missing X-Webhook-Secret` / `WhatsApp webhook verification failed — token mismatch` | `Webhook JSON parse error:` |
|
||||||
|
| Email | `Email polling every ...` / `Email sent to ...` | `Blocked email from ...` | `Email poll failed:` / `Email poll task panicked:` |
|
||||||
|
| IRC | `IRC channel connecting to ...` / `IRC registered as ...` | (allowlist checks are enforced by `allowed_users`) | `IRC SASL authentication failed (...)` / `IRC server does not support SASL...` / `IRC nickname ... is in use, trying ...` |
|
||||||
|
| Lark / Feishu | `Lark: WS connected` / `Lark event callback server listening on` | `Lark WS: ignoring ... (not in allowed_users)` / `Lark: ignoring message from unauthorized user:` | `Lark: ping failed, reconnecting` / `Lark: heartbeat timeout, reconnecting` / `Lark: WS read error:` |
|
||||||
|
| DingTalk | `DingTalk: connected and listening for messages...` | `DingTalk: ignoring message from unauthorized user:` | `DingTalk WebSocket error:` / `DingTalk: message channel closed` |
|
||||||
|
| QQ | `QQ: connected and identified` | `QQ: ignoring C2C message from unauthorized user:` / `QQ: ignoring group message from unauthorized user:` | `QQ: received Reconnect (op 7)` / `QQ: received Invalid Session (op 9)` / `QQ: message channel closed` |
|
||||||
|
| iMessage | `iMessage channel listening (AppleScript bridge)...` | (contact allowlist enforced by `allowed_contacts`) | `iMessage poll error:` |
|
||||||
|
|
||||||
|
### 7.3 Runtime supervisor keywords
|
||||||
|
|
||||||
|
If a specific channel task crashes or exits, the channel supervisor in `channels/mod.rs` emits:
|
||||||
|
|
||||||
|
- `Channel <name> exited unexpectedly; restarting`
|
||||||
|
- `Channel <name> error: ...; restarting`
|
||||||
|
- `Channel message worker crashed:`
|
||||||
|
|
||||||
|
These messages indicate automatic restart behavior is active, and you should inspect preceding logs for root cause.
|
||||||
|
|
||||||
- [README.md (Channel allowlists)](../README.md#channel-allowlists-deny-by-default)
|
|
||||||
- [network-deployment.md](network-deployment.md)
|
|
||||||
- [mattermost-setup.md](mattermost-setup.md)
|
|
||||||
- [commands-reference.md](commands-reference.md)
|
|
||||||
|
|
|
||||||
133
docs/matrix-e2ee-guide.md
Normal file
133
docs/matrix-e2ee-guide.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Matrix E2EE Guide
|
||||||
|
|
||||||
|
This guide explains how to run ZeroClaw reliably in Matrix rooms, including end-to-end encrypted (E2EE) rooms.
|
||||||
|
|
||||||
|
It focuses on the common failure mode reported by users:
|
||||||
|
|
||||||
|
> “Matrix is configured correctly, checks pass, but the bot does not respond.”
|
||||||
|
|
||||||
|
## 0. Fast FAQ (#499-class symptom)
|
||||||
|
|
||||||
|
If Matrix appears connected but there is no reply, validate these first:
|
||||||
|
|
||||||
|
1. Sender is allowed by `allowed_users` (for testing: `["*"]`).
|
||||||
|
2. Bot account has joined the exact target room.
|
||||||
|
3. Token belongs to the same bot account (`whoami` check).
|
||||||
|
4. Encrypted room has usable device identity (`device_id`) and key sharing.
|
||||||
|
5. Daemon is restarted after config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Requirements
|
||||||
|
|
||||||
|
Before testing message flow, make sure all of the following are true:
|
||||||
|
|
||||||
|
1. The bot account is joined to the target room.
|
||||||
|
2. The access token belongs to the same bot account.
|
||||||
|
3. `room_id` is correct:
|
||||||
|
- preferred: canonical room ID (`!room:server`)
|
||||||
|
- supported: room alias (`#alias:server`) and ZeroClaw will resolve it
|
||||||
|
4. `allowed_users` allows the sender (`["*"]` for open testing).
|
||||||
|
5. For E2EE rooms, the bot device has received encryption keys for the room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration
|
||||||
|
|
||||||
|
Use `~/.zeroclaw/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[channels_config.matrix]
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
access_token = "syt_your_token"
|
||||||
|
|
||||||
|
# Optional but recommended for E2EE stability:
|
||||||
|
user_id = "@zeroclaw:matrix.example.com"
|
||||||
|
device_id = "DEVICEID123"
|
||||||
|
|
||||||
|
# Room ID or alias
|
||||||
|
room_id = "!xtHhdHIIVEZbDPvTvZ:matrix.example.com"
|
||||||
|
# room_id = "#ops:matrix.example.com"
|
||||||
|
|
||||||
|
# Use ["*"] during initial verification, then tighten.
|
||||||
|
allowed_users = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### About `user_id` and `device_id`
|
||||||
|
|
||||||
|
- ZeroClaw attempts to read identity from Matrix `/_matrix/client/v3/account/whoami`.
|
||||||
|
- If `whoami` does not return `device_id`, set `device_id` manually.
|
||||||
|
- These hints are especially important for E2EE session restore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Quick Validation Flow
|
||||||
|
|
||||||
|
1. Run channel setup and daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zeroclaw onboard --channels-only
|
||||||
|
zeroclaw daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Send a plain text message in the configured Matrix room.
|
||||||
|
|
||||||
|
3. Confirm ZeroClaw logs contain Matrix listener startup and no repeated sync/auth errors.
|
||||||
|
|
||||||
|
4. In an encrypted room, verify the bot can read and reply to encrypted messages from allowed users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Troubleshooting “No Response”
|
||||||
|
|
||||||
|
Use this checklist in order.
|
||||||
|
|
||||||
|
### A. Room and membership
|
||||||
|
|
||||||
|
- Ensure the bot account has joined the room.
|
||||||
|
- If using alias (`#...`), verify it resolves to the expected canonical room.
|
||||||
|
|
||||||
|
### B. Sender allowlist
|
||||||
|
|
||||||
|
- If `allowed_users = []`, all inbound messages are denied.
|
||||||
|
- For diagnosis, temporarily set `allowed_users = ["*"]`.
|
||||||
|
|
||||||
|
### C. Token and identity
|
||||||
|
|
||||||
|
- Validate token with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Authorization: Bearer $MATRIX_TOKEN" \
|
||||||
|
"https://matrix.example.com/_matrix/client/v3/account/whoami"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check that returned `user_id` matches the bot account.
|
||||||
|
- If `device_id` is missing, set `channels_config.matrix.device_id` manually.
|
||||||
|
|
||||||
|
### D. E2EE-specific checks
|
||||||
|
|
||||||
|
- The bot device must receive room keys from trusted devices.
|
||||||
|
- If keys are not shared to this device, encrypted events cannot be decrypted.
|
||||||
|
- Verify device trust and key sharing in your Matrix client/admin workflow.
|
||||||
|
|
||||||
|
### E. Fresh start test
|
||||||
|
|
||||||
|
After updating config, restart daemon and send a new message (not just old timeline history).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Operational Notes
|
||||||
|
|
||||||
|
- Keep Matrix tokens out of logs and screenshots.
|
||||||
|
- Start with permissive `allowed_users`, then tighten to explicit user IDs.
|
||||||
|
- Prefer canonical room IDs in production to avoid alias drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Related Docs
|
||||||
|
|
||||||
|
- [Channels Reference](./channels-reference.md)
|
||||||
|
- [Operations log keyword appendix](./channels-reference.md#7-operations-appendix-log-keywords-matrix)
|
||||||
|
- [Network Deployment](./network-deployment.md)
|
||||||
|
- [Agnostic Security](./agnostic-security.md)
|
||||||
|
- [Reviewer Playbook](./reviewer-playbook.md)
|
||||||
|
|
@ -9,6 +9,7 @@ This document covers deploying ZeroClaw on a Raspberry Pi or other host on your
|
||||||
| Mode | Inbound port needed? | Use case |
|
| Mode | Inbound port needed? | Use case |
|
||||||
|------|----------------------|----------|
|
|------|----------------------|----------|
|
||||||
| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere |
|
| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere |
|
||||||
|
| **Matrix sync (including E2EE)** | No | ZeroClaw syncs via Matrix client API; no inbound webhook required |
|
||||||
| **Discord/Slack** | No | Same — outbound only |
|
| **Discord/Slack** | No | Same — outbound only |
|
||||||
| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL |
|
| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL |
|
||||||
| **Gateway pairing** | Yes | If you pair clients via the gateway |
|
| **Gateway pairing** | Yes | If you pair clients via the gateway |
|
||||||
|
|
@ -199,5 +200,7 @@ Configure Cloudflare Tunnel to forward to `127.0.0.1:3000`, then set your webhoo
|
||||||
|
|
||||||
## 7. References
|
## 7. References
|
||||||
|
|
||||||
|
- [channels-reference.md](./channels-reference.md) — Channel configuration overview
|
||||||
|
- [matrix-e2ee-guide.md](./matrix-e2ee-guide.md) — Matrix setup and encrypted-room troubleshooting
|
||||||
- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design
|
- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design
|
||||||
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards
|
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,34 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use matrix_sdk::{
|
||||||
|
authentication::matrix::MatrixSession,
|
||||||
|
config::SyncSettings,
|
||||||
|
ruma::{
|
||||||
|
events::room::message::{
|
||||||
|
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
|
||||||
|
},
|
||||||
|
OwnedRoomId, OwnedUserId,
|
||||||
|
},
|
||||||
|
Client as MatrixSdkClient, LoopCtrl, Room, RoomState, SessionMeta, SessionTokens,
|
||||||
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, Mutex, OnceCell, RwLock};
|
||||||
|
|
||||||
/// Matrix channel using the Client-Server API (no SDK needed).
|
/// Matrix channel for Matrix Client-Server API.
|
||||||
/// Connects to any Matrix homeserver (Element, Synapse, etc.).
|
/// Uses matrix-sdk for reliable sync and encrypted-room decryption.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MatrixChannel {
|
pub struct MatrixChannel {
|
||||||
homeserver: String,
|
homeserver: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
room_id: String,
|
room_id: String,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
|
session_user_id_hint: Option<String>,
|
||||||
|
session_device_id_hint: Option<String>,
|
||||||
|
resolved_room_id_cache: Arc<RwLock<Option<String>>>,
|
||||||
|
sdk_client: Arc<OnceCell<MatrixSdkClient>>,
|
||||||
|
http_client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -45,6 +62,8 @@ struct TimelineEvent {
|
||||||
event_type: String,
|
event_type: String,
|
||||||
sender: String,
|
sender: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
event_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
content: EventContent,
|
content: EventContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,47 +78,150 @@ struct EventContent {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WhoAmIResponse {
|
struct WhoAmIResponse {
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
device_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RoomAliasResponse {
|
||||||
|
room_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MatrixChannel {
|
impl MatrixChannel {
|
||||||
|
fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|entry| entry.trim().to_string())
|
||||||
|
.filter(|entry| !entry.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
homeserver: String,
|
homeserver: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
room_id: String,
|
room_id: String,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let homeserver = if homeserver.ends_with('/') {
|
Self::new_with_session_hint(homeserver, access_token, room_id, allowed_users, None, None)
|
||||||
homeserver[..homeserver.len() - 1].to_string()
|
}
|
||||||
} else {
|
|
||||||
homeserver
|
pub fn new_with_session_hint(
|
||||||
};
|
homeserver: String,
|
||||||
|
access_token: String,
|
||||||
|
room_id: String,
|
||||||
|
allowed_users: Vec<String>,
|
||||||
|
user_id_hint: Option<String>,
|
||||||
|
device_id_hint: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let homeserver = homeserver.trim_end_matches('/').to_string();
|
||||||
|
let access_token = access_token.trim().to_string();
|
||||||
|
let room_id = room_id.trim().to_string();
|
||||||
|
let allowed_users = allowed_users
|
||||||
|
.into_iter()
|
||||||
|
.map(|user| user.trim().to_string())
|
||||||
|
.filter(|user| !user.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
homeserver,
|
homeserver,
|
||||||
access_token,
|
access_token,
|
||||||
room_id,
|
room_id,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
|
session_user_id_hint: Self::normalize_optional_field(user_id_hint),
|
||||||
|
session_device_id_hint: Self::normalize_optional_field(device_id_hint),
|
||||||
|
resolved_room_id_cache: Arc::new(RwLock::new(None)),
|
||||||
|
sdk_client: Arc::new(OnceCell::new()),
|
||||||
|
http_client: Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_client(&self) -> Client {
|
fn encode_path_segment(value: &str) -> String {
|
||||||
crate::config::build_runtime_proxy_client("channel.matrix")
|
fn should_encode(byte: u8) -> bool {
|
||||||
|
!matches!(
|
||||||
|
byte,
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if should_encode(byte) {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(&mut encoded, "%{byte:02X}");
|
||||||
|
} else {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_user_allowed(&self, sender: &str) -> bool {
|
fn is_user_allowed(&self, sender: &str) -> bool {
|
||||||
if self.allowed_users.iter().any(|u| u == "*") {
|
Self::is_sender_allowed(&self.allowed_users, sender)
|
||||||
return true;
|
|
||||||
}
|
|
||||||
self.allowed_users
|
|
||||||
.iter()
|
|
||||||
.any(|u| u.eq_ignore_ascii_case(sender))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_my_user_id(&self) -> anyhow::Result<String> {
|
fn is_sender_allowed(allowed_users: &[String], sender: &str) -> bool {
|
||||||
|
if allowed_users.iter().any(|u| u == "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_users.iter().any(|u| u.eq_ignore_ascii_case(sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_message_type(msgtype: &str) -> bool {
|
||||||
|
matches!(msgtype, "m.text" | "m.notice")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_non_empty_body(body: &str) -> bool {
|
||||||
|
!body.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_event_id(
|
||||||
|
event_id: &str,
|
||||||
|
recent_order: &mut std::collections::VecDeque<String>,
|
||||||
|
recent_lookup: &mut std::collections::HashSet<String>,
|
||||||
|
) -> bool {
|
||||||
|
const MAX_RECENT_EVENT_IDS: usize = 2048;
|
||||||
|
|
||||||
|
if recent_lookup.contains(event_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_id_owned = event_id.to_string();
|
||||||
|
recent_lookup.insert(event_id_owned.clone());
|
||||||
|
recent_order.push_back(event_id_owned);
|
||||||
|
|
||||||
|
if recent_order.len() > MAX_RECENT_EVENT_IDS {
|
||||||
|
if let Some(evicted) = recent_order.pop_front() {
|
||||||
|
recent_lookup.remove(&evicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn target_room_id(&self) -> anyhow::Result<String> {
|
||||||
|
if self.room_id.starts_with('!') {
|
||||||
|
return Ok(self.room_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cached) = self.resolved_room_id_cache.read().await.clone() {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = self.resolve_room_id().await?;
|
||||||
|
*self.resolved_room_id_cache.write().await = Some(resolved.clone());
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_my_identity(&self) -> anyhow::Result<WhoAmIResponse> {
|
||||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client()
|
.http_client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
.header("Authorization", self.auth_header_value())
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -108,8 +230,213 @@ impl MatrixChannel {
|
||||||
anyhow::bail!("Matrix whoami failed: {err}");
|
anyhow::bail!("Matrix whoami failed: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let who: WhoAmIResponse = resp.json().await?;
|
Ok(resp.json().await?)
|
||||||
Ok(who.user_id)
|
}
|
||||||
|
|
||||||
|
async fn get_my_user_id(&self) -> anyhow::Result<String> {
|
||||||
|
Ok(self.get_my_identity().await?.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn matrix_client(&self) -> anyhow::Result<MatrixSdkClient> {
|
||||||
|
let client = self
|
||||||
|
.sdk_client
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
let identity = self.get_my_identity().await;
|
||||||
|
let whoami = match identity {
|
||||||
|
Ok(whoami) => Some(whoami),
|
||||||
|
Err(error) => {
|
||||||
|
if self.session_user_id_hint.is_some() && self.session_device_id_hint.is_some()
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_user_id = if let Some(whoami) = whoami.as_ref() {
|
||||||
|
if let Some(hinted) = self.session_user_id_hint.as_ref() {
|
||||||
|
if hinted != &whoami.user_id {
|
||||||
|
tracing::warn!(
|
||||||
|
"Matrix configured user_id '{}' does not match whoami '{}'; using whoami.",
|
||||||
|
hinted,
|
||||||
|
whoami.user_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whoami.user_id.clone()
|
||||||
|
} else {
|
||||||
|
self.session_user_id_hint.clone().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Matrix session restore requires user_id when whoami is unavailable"
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_device_id = match (whoami.as_ref(), self.session_device_id_hint.as_ref()) {
|
||||||
|
(Some(whoami), Some(hinted)) => {
|
||||||
|
if let Some(whoami_device_id) = whoami.device_id.as_ref() {
|
||||||
|
if whoami_device_id != hinted {
|
||||||
|
tracing::warn!(
|
||||||
|
"Matrix configured device_id '{}' does not match whoami '{}'; using whoami.",
|
||||||
|
hinted,
|
||||||
|
whoami_device_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
whoami_device_id.clone()
|
||||||
|
} else {
|
||||||
|
hinted.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(whoami), None) => whoami.device_id.clone().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Matrix whoami response did not include device_id. Set channels.matrix.device_id to enable E2EE session restore."
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
(None, Some(hinted)) => hinted.clone(),
|
||||||
|
(None, None) => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Matrix E2EE session restore requires device_id when whoami is unavailable"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = MatrixSdkClient::builder()
|
||||||
|
.homeserver_url(&self.homeserver)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id: OwnedUserId = resolved_user_id.parse()?;
|
||||||
|
let session = MatrixSession {
|
||||||
|
meta: SessionMeta {
|
||||||
|
user_id,
|
||||||
|
device_id: resolved_device_id.into(),
|
||||||
|
},
|
||||||
|
tokens: SessionTokens {
|
||||||
|
access_token: self.access_token.clone(),
|
||||||
|
refresh_token: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
client.restore_session(session).await?;
|
||||||
|
|
||||||
|
Ok::<MatrixSdkClient, anyhow::Error>(client)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(client.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_room_id(&self) -> anyhow::Result<String> {
|
||||||
|
let configured = self.room_id.trim();
|
||||||
|
|
||||||
|
if configured.starts_with('!') {
|
||||||
|
return Ok(configured.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if configured.starts_with('#') {
|
||||||
|
let encoded_alias = Self::encode_path_segment(configured);
|
||||||
|
let url = format!(
|
||||||
|
"{}/_matrix/client/v3/directory/room/{}",
|
||||||
|
self.homeserver, encoded_alias
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", self.auth_header_value())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Matrix room alias resolution failed for '{configured}': {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: RoomAliasResponse = resp.json().await?;
|
||||||
|
return Ok(resolved.room_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"Matrix room reference must start with '!' (room ID) or '#' (room alias), got: {configured}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_room_accessible(&self, room_id: &str) -> anyhow::Result<()> {
|
||||||
|
let encoded_room = Self::encode_path_segment(room_id);
|
||||||
|
let url = format!(
|
||||||
|
"{}/_matrix/client/v3/rooms/{}/joined_members",
|
||||||
|
self.homeserver, encoded_room
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", self.auth_header_value())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Matrix room access check failed for '{room_id}': {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn room_is_encrypted(&self, room_id: &str) -> anyhow::Result<bool> {
|
||||||
|
let encoded_room = Self::encode_path_segment(room_id);
|
||||||
|
let url = format!(
|
||||||
|
"{}/_matrix/client/v3/rooms/{}/state/m.room.encryption",
|
||||||
|
self.homeserver, encoded_room
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", self.auth_header_value())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Matrix room encryption check failed for '{room_id}': {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_room_supported(&self, room_id: &str) -> anyhow::Result<()> {
|
||||||
|
self.ensure_room_accessible(room_id).await?;
|
||||||
|
|
||||||
|
if self.room_is_encrypted(room_id).await? {
|
||||||
|
tracing::info!(
|
||||||
|
"Matrix room {} is encrypted; E2EE decryption is enabled via matrix-sdk.",
|
||||||
|
room_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_filter_for_room(room_id: &str, timeline_limit: usize) -> String {
|
||||||
|
let timeline_limit = timeline_limit.max(1);
|
||||||
|
serde_json::json!({
|
||||||
|
"room": {
|
||||||
|
"rooms": [room_id],
|
||||||
|
"timeline": {
|
||||||
|
"limit": timeline_limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,148 +447,157 @@ impl Channel for MatrixChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let txn_id = format!("zc_{}", chrono::Utc::now().timestamp_millis());
|
let client = self.matrix_client().await?;
|
||||||
let url = format!(
|
let target_room_id = self.target_room_id().await?;
|
||||||
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
let target_room: OwnedRoomId = target_room_id.parse()?;
|
||||||
self.homeserver, self.room_id, txn_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let mut room = client.get_room(&target_room);
|
||||||
"msgtype": "m.text",
|
if room.is_none() {
|
||||||
"body": message.content
|
let _ = client.sync_once(SyncSettings::new()).await;
|
||||||
});
|
room = client.get_room(&target_room);
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.http_client()
|
|
||||||
.put(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let err = resp.text().await?;
|
|
||||||
anyhow::bail!("Matrix send failed: {err}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(room) = room else {
|
||||||
|
anyhow::bail!("Matrix room '{}' not found in joined rooms", target_room_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if room.state() != RoomState::Joined {
|
||||||
|
anyhow::bail!("Matrix room '{}' is not in joined state", target_room_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
room.send(RoomMessageEventContent::text_plain(&message.content))
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||||
tracing::info!("Matrix channel listening on room {}...", self.room_id);
|
let target_room_id = self.target_room_id().await?;
|
||||||
|
self.ensure_room_supported(&target_room_id).await?;
|
||||||
|
|
||||||
let my_user_id = self.get_my_user_id().await?;
|
let target_room: OwnedRoomId = target_room_id.parse()?;
|
||||||
|
let my_user_id: OwnedUserId = match self.get_my_user_id().await {
|
||||||
|
Ok(user_id) => user_id.parse()?,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(hinted) = self.session_user_id_hint.as_ref() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Matrix whoami failed while resolving listener user_id; using configured user_id hint: {error}"
|
||||||
|
);
|
||||||
|
hinted.parse()?
|
||||||
|
} else {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = self.matrix_client().await?;
|
||||||
|
|
||||||
// Initial sync to get the since token
|
let _ = client.sync_once(SyncSettings::new()).await;
|
||||||
let url = format!(
|
|
||||||
"{}/_matrix/client/v3/sync?timeout=30000&filter={{\"room\":{{\"timeline\":{{\"limit\":1}}}}}}",
|
tracing::info!(
|
||||||
self.homeserver
|
"Matrix channel listening on room {} (configured as {})...",
|
||||||
|
target_room_id,
|
||||||
|
self.room_id
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = self
|
let recent_event_cache = Arc::new(Mutex::new((
|
||||||
.http_client()
|
std::collections::VecDeque::new(),
|
||||||
.get(&url)
|
std::collections::HashSet::new(),
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
)));
|
||||||
.send()
|
|
||||||
|
let tx_handler = tx.clone();
|
||||||
|
let target_room_for_handler = target_room.clone();
|
||||||
|
let my_user_id_for_handler = my_user_id.clone();
|
||||||
|
let allowed_users_for_handler = self.allowed_users.clone();
|
||||||
|
let dedupe_for_handler = Arc::clone(&recent_event_cache);
|
||||||
|
|
||||||
|
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
||||||
|
let tx = tx_handler.clone();
|
||||||
|
let target_room = target_room_for_handler.clone();
|
||||||
|
let my_user_id = my_user_id_for_handler.clone();
|
||||||
|
let allowed_users = allowed_users_for_handler.clone();
|
||||||
|
let dedupe = Arc::clone(&dedupe_for_handler);
|
||||||
|
|
||||||
|
async move {
|
||||||
|
if room.room_id().as_str() != target_room.as_str() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.sender == my_user_id {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = event.sender.to_string();
|
||||||
|
if !MatrixChannel::is_sender_allowed(&allowed_users, &sender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = match &event.content.msgtype {
|
||||||
|
MessageType::Text(content) => content.body.clone(),
|
||||||
|
MessageType::Notice(content) => content.body.clone(),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !MatrixChannel::has_non_empty_body(&body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_id = event.event_id.to_string();
|
||||||
|
{
|
||||||
|
let mut guard = dedupe.lock().await;
|
||||||
|
let (recent_order, recent_lookup) = &mut *guard;
|
||||||
|
if MatrixChannel::cache_event_id(&event_id, recent_order, recent_lookup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = ChannelMessage {
|
||||||
|
id: event_id,
|
||||||
|
sender: sender.clone(),
|
||||||
|
reply_target: sender,
|
||||||
|
content: body,
|
||||||
|
channel: "matrix".to_string(),
|
||||||
|
timestamp: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(msg).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sync_settings = SyncSettings::new().timeout(std::time::Duration::from_secs(30));
|
||||||
|
client
|
||||||
|
.sync_with_result_callback(sync_settings, |sync_result| {
|
||||||
|
let tx = tx.clone();
|
||||||
|
async move {
|
||||||
|
if tx.is_closed() {
|
||||||
|
return Ok::<LoopCtrl, matrix_sdk::Error>(LoopCtrl::Break);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = sync_result {
|
||||||
|
tracing::warn!("Matrix sync error: {error}, retrying...");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<LoopCtrl, matrix_sdk::Error>(LoopCtrl::Continue)
|
||||||
|
}
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
Ok(())
|
||||||
let err = resp.text().await?;
|
|
||||||
anyhow::bail!("Matrix initial sync failed: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let sync: SyncResponse = resp.json().await?;
|
|
||||||
let mut since = sync.next_batch;
|
|
||||||
|
|
||||||
// Long-poll loop
|
|
||||||
loop {
|
|
||||||
let url = format!(
|
|
||||||
"{}/_matrix/client/v3/sync?since={}&timeout=30000",
|
|
||||||
self.homeserver, since
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.http_client()
|
|
||||||
.get(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let resp = match resp {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Matrix sync error: {e}, retrying...");
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sync: SyncResponse = resp.json().await?;
|
|
||||||
since = sync.next_batch;
|
|
||||||
|
|
||||||
// Process events from our room
|
|
||||||
if let Some(room) = sync.rooms.join.get(&self.room_id) {
|
|
||||||
for event in &room.timeline.events {
|
|
||||||
// Skip our own messages
|
|
||||||
if event.sender == my_user_id {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process text messages
|
|
||||||
if event.event_type != "m.room.message" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.content.msgtype.as_deref() != Some("m.text") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(ref body) = event.content.body else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !self.is_user_allowed(&event.sender) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = ChannelMessage {
|
|
||||||
id: format!("mx_{}", chrono::Utc::now().timestamp_millis()),
|
|
||||||
sender: event.sender.clone(),
|
|
||||||
reply_target: event.sender.clone(),
|
|
||||||
content: body.clone(),
|
|
||||||
channel: "matrix".to_string(),
|
|
||||||
timestamp: std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if tx.send(msg).await.is_err() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> bool {
|
async fn health_check(&self) -> bool {
|
||||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
let Ok(room_id) = self.target_room_id().await else {
|
||||||
let Ok(resp) = self
|
|
||||||
.http_client()
|
|
||||||
.get(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
resp.status().is_success()
|
if self.ensure_room_supported(&room_id).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.matrix_client().await.is_ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,14 +646,139 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_trailing_slashes_strips_one() {
|
fn multiple_trailing_slashes_strip_all() {
|
||||||
let ch = MatrixChannel::new(
|
let ch = MatrixChannel::new(
|
||||||
"https://matrix.org//".to_string(),
|
"https://matrix.org//".to_string(),
|
||||||
"tok".to_string(),
|
"tok".to_string(),
|
||||||
"!r:m".to_string(),
|
"!r:m".to_string(),
|
||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
assert_eq!(ch.homeserver, "https://matrix.org/");
|
assert_eq!(ch.homeserver, "https://matrix.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_access_token() {
|
||||||
|
let ch = MatrixChannel::new(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
" syt_test_token ".to_string(),
|
||||||
|
"!r:m".to_string(),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
assert_eq!(ch.access_token, "syt_test_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_hints_are_normalized() {
|
||||||
|
let ch = MatrixChannel::new_with_session_hint(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
"!r:m".to_string(),
|
||||||
|
vec![],
|
||||||
|
Some(" @bot:matrix.org ".to_string()),
|
||||||
|
Some(" DEVICE123 ".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(ch.session_user_id_hint.as_deref(), Some("@bot:matrix.org"));
|
||||||
|
assert_eq!(ch.session_device_id_hint.as_deref(), Some("DEVICE123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_session_hints_are_ignored() {
|
||||||
|
let ch = MatrixChannel::new_with_session_hint(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
"!r:m".to_string(),
|
||||||
|
vec![],
|
||||||
|
Some(" ".to_string()),
|
||||||
|
Some("".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(ch.session_user_id_hint.is_none());
|
||||||
|
assert!(ch.session_device_id_hint.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_path_segment_encodes_room_refs() {
|
||||||
|
assert_eq!(
|
||||||
|
MatrixChannel::encode_path_segment("#ops:matrix.example.com"),
|
||||||
|
"%23ops%3Amatrix.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MatrixChannel::encode_path_segment("!room:matrix.example.com"),
|
||||||
|
"%21room%3Amatrix.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_message_type_detection() {
|
||||||
|
assert!(MatrixChannel::is_supported_message_type("m.text"));
|
||||||
|
assert!(MatrixChannel::is_supported_message_type("m.notice"));
|
||||||
|
assert!(!MatrixChannel::is_supported_message_type("m.image"));
|
||||||
|
assert!(!MatrixChannel::is_supported_message_type("m.file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_presence_detection() {
|
||||||
|
assert!(MatrixChannel::has_non_empty_body("hello"));
|
||||||
|
assert!(MatrixChannel::has_non_empty_body(" hello "));
|
||||||
|
assert!(!MatrixChannel::has_non_empty_body(""));
|
||||||
|
assert!(!MatrixChannel::has_non_empty_body(" \n\t "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_filter_for_room_targets_requested_room() {
|
||||||
|
let filter = MatrixChannel::sync_filter_for_room("!room:matrix.org", 0);
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&filter).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["room"]["rooms"][0], "!room:matrix.org");
|
||||||
|
assert_eq!(value["room"]["timeline"]["limit"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_id_cache_deduplicates_and_evicts_old_entries() {
|
||||||
|
let mut recent_order = std::collections::VecDeque::new();
|
||||||
|
let mut recent_lookup = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
assert!(!MatrixChannel::cache_event_id(
|
||||||
|
"$first:event",
|
||||||
|
&mut recent_order,
|
||||||
|
&mut recent_lookup
|
||||||
|
));
|
||||||
|
assert!(MatrixChannel::cache_event_id(
|
||||||
|
"$first:event",
|
||||||
|
&mut recent_order,
|
||||||
|
&mut recent_lookup
|
||||||
|
));
|
||||||
|
|
||||||
|
for i in 0..2050 {
|
||||||
|
let event_id = format!("$event-{i}:matrix");
|
||||||
|
MatrixChannel::cache_event_id(&event_id, &mut recent_order, &mut recent_lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!MatrixChannel::cache_event_id(
|
||||||
|
"$first:event",
|
||||||
|
&mut recent_order,
|
||||||
|
&mut recent_lookup
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_room_id_and_allowed_users() {
|
||||||
|
let ch = MatrixChannel::new(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
" !room:matrix.org ".to_string(),
|
||||||
|
vec![
|
||||||
|
" @user:matrix.org ".to_string(),
|
||||||
|
" ".to_string(),
|
||||||
|
"@other:matrix.org".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(ch.room_id, "!room:matrix.org");
|
||||||
|
assert_eq!(ch.allowed_users.len(), 2);
|
||||||
|
assert!(ch.allowed_users.contains(&"@user:matrix.org".to_string()));
|
||||||
|
assert!(ch.allowed_users.contains(&"@other:matrix.org".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -393,6 +854,7 @@ mod tests {
|
||||||
"events": [
|
"events": [
|
||||||
{
|
{
|
||||||
"type": "m.room.message",
|
"type": "m.room.message",
|
||||||
|
"event_id": "$event:matrix.org",
|
||||||
"sender": "@user:matrix.org",
|
"sender": "@user:matrix.org",
|
||||||
"content": {
|
"content": {
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
|
|
@ -410,6 +872,10 @@ mod tests {
|
||||||
let room = resp.rooms.join.get("!room:matrix.org").unwrap();
|
let room = resp.rooms.join.get("!room:matrix.org").unwrap();
|
||||||
assert_eq!(room.timeline.events.len(), 1);
|
assert_eq!(room.timeline.events.len(), 1);
|
||||||
assert_eq!(room.timeline.events[0].sender, "@user:matrix.org");
|
assert_eq!(room.timeline.events[0].sender, "@user:matrix.org");
|
||||||
|
assert_eq!(
|
||||||
|
room.timeline.events[0].event_id.as_deref(),
|
||||||
|
Some("$event:matrix.org")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
room.timeline.events[0].content.body.as_deref(),
|
room.timeline.events[0].content.body.as_deref(),
|
||||||
Some("Hello!")
|
Some("Hello!")
|
||||||
|
|
@ -461,6 +927,62 @@ mod tests {
|
||||||
assert!(event.content.msgtype.is_none());
|
assert!(event.content.msgtype.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_content_supports_notice_msgtype() {
|
||||||
|
let json = r#"{
|
||||||
|
"type":"m.room.message",
|
||||||
|
"sender":"@u:m",
|
||||||
|
"event_id":"$notice:m",
|
||||||
|
"content":{"msgtype":"m.notice","body":"Heads up"}
|
||||||
|
}"#;
|
||||||
|
let event: TimelineEvent = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(event.content.msgtype.as_deref(), Some("m.notice"));
|
||||||
|
assert_eq!(event.content.body.as_deref(), Some("Heads up"));
|
||||||
|
assert_eq!(event.event_id.as_deref(), Some("$notice:m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_room_reference_fails_fast() {
|
||||||
|
let ch = MatrixChannel::new(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
"room_without_prefix".to_string(),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = ch.resolve_room_id().await.unwrap_err();
|
||||||
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("must start with '!' (room ID) or '#' (room alias)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_room_id_keeps_canonical_room_id_without_lookup() {
|
||||||
|
let ch = MatrixChannel::new(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
"!canonical:matrix.org".to_string(),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let room_id = ch.target_room_id().await.unwrap();
|
||||||
|
assert_eq!(room_id, "!canonical:matrix.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_room_id_uses_cached_alias_resolution() {
|
||||||
|
let ch = MatrixChannel::new(
|
||||||
|
"https://matrix.org".to_string(),
|
||||||
|
"tok".to_string(),
|
||||||
|
"#ops:matrix.org".to_string(),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
*ch.resolved_room_id_cache.write().await = Some("!cached:matrix.org".to_string());
|
||||||
|
let room_id = ch.target_room_id().await.unwrap();
|
||||||
|
assert_eq!(room_id, "!cached:matrix.org");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_response_missing_rooms_defaults() {
|
fn sync_response_missing_rooms_defaults() {
|
||||||
let json = r#"{"next_batch":"s0"}"#;
|
let json = r#"{"next_batch":"s0"}"#;
|
||||||
|
|
|
||||||
|
|
@ -1351,11 +1351,13 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
||||||
if let Some(ref mx) = config.channels_config.matrix {
|
if let Some(ref mx) = config.channels_config.matrix {
|
||||||
channels.push((
|
channels.push((
|
||||||
"Matrix",
|
"Matrix",
|
||||||
Arc::new(MatrixChannel::new(
|
Arc::new(MatrixChannel::new_with_session_hint(
|
||||||
mx.homeserver.clone(),
|
mx.homeserver.clone(),
|
||||||
mx.access_token.clone(),
|
mx.access_token.clone(),
|
||||||
mx.room_id.clone(),
|
mx.room_id.clone(),
|
||||||
mx.allowed_users.clone(),
|
mx.allowed_users.clone(),
|
||||||
|
mx.user_id.clone(),
|
||||||
|
mx.device_id.clone(),
|
||||||
)),
|
)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1676,11 +1678,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref mx) = config.channels_config.matrix {
|
if let Some(ref mx) = config.channels_config.matrix {
|
||||||
channels.push(Arc::new(MatrixChannel::new(
|
channels.push(Arc::new(MatrixChannel::new_with_session_hint(
|
||||||
mx.homeserver.clone(),
|
mx.homeserver.clone(),
|
||||||
mx.access_token.clone(),
|
mx.access_token.clone(),
|
||||||
mx.room_id.clone(),
|
mx.room_id.clone(),
|
||||||
mx.allowed_users.clone(),
|
mx.allowed_users.clone(),
|
||||||
|
mx.user_id.clone(),
|
||||||
|
mx.device_id.clone(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2091,6 +2091,10 @@ pub struct IMessageConfig {
|
||||||
pub struct MatrixConfig {
|
pub struct MatrixConfig {
|
||||||
pub homeserver: String,
|
pub homeserver: String,
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_id: Option<String>,
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
pub allowed_users: Vec<String>,
|
pub allowed_users: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -3621,6 +3625,8 @@ tool_dispatcher = "xml"
|
||||||
let mc = MatrixConfig {
|
let mc = MatrixConfig {
|
||||||
homeserver: "https://matrix.org".into(),
|
homeserver: "https://matrix.org".into(),
|
||||||
access_token: "syt_token_abc".into(),
|
access_token: "syt_token_abc".into(),
|
||||||
|
user_id: Some("@bot:matrix.org".into()),
|
||||||
|
device_id: Some("DEVICE123".into()),
|
||||||
room_id: "!room123:matrix.org".into(),
|
room_id: "!room123:matrix.org".into(),
|
||||||
allowed_users: vec!["@user:matrix.org".into()],
|
allowed_users: vec!["@user:matrix.org".into()],
|
||||||
};
|
};
|
||||||
|
|
@ -3628,6 +3634,8 @@ tool_dispatcher = "xml"
|
||||||
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
|
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed.homeserver, "https://matrix.org");
|
assert_eq!(parsed.homeserver, "https://matrix.org");
|
||||||
assert_eq!(parsed.access_token, "syt_token_abc");
|
assert_eq!(parsed.access_token, "syt_token_abc");
|
||||||
|
assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
|
||||||
|
assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
|
||||||
assert_eq!(parsed.room_id, "!room123:matrix.org");
|
assert_eq!(parsed.room_id, "!room123:matrix.org");
|
||||||
assert_eq!(parsed.allowed_users.len(), 1);
|
assert_eq!(parsed.allowed_users.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
@ -3637,6 +3645,8 @@ tool_dispatcher = "xml"
|
||||||
let mc = MatrixConfig {
|
let mc = MatrixConfig {
|
||||||
homeserver: "https://synapse.local:8448".into(),
|
homeserver: "https://synapse.local:8448".into(),
|
||||||
access_token: "tok".into(),
|
access_token: "tok".into(),
|
||||||
|
user_id: None,
|
||||||
|
device_id: None,
|
||||||
room_id: "!abc:synapse.local".into(),
|
room_id: "!abc:synapse.local".into(),
|
||||||
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
|
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
|
||||||
};
|
};
|
||||||
|
|
@ -3646,6 +3656,21 @@ tool_dispatcher = "xml"
|
||||||
assert_eq!(parsed.allowed_users.len(), 2);
|
assert_eq!(parsed.allowed_users.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matrix_config_backward_compatible_without_session_hints() {
|
||||||
|
let toml = r#"
|
||||||
|
homeserver = "https://matrix.org"
|
||||||
|
access_token = "tok"
|
||||||
|
room_id = "!ops:matrix.org"
|
||||||
|
allowed_users = ["@ops:matrix.org"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let parsed: MatrixConfig = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(parsed.homeserver, "https://matrix.org");
|
||||||
|
assert!(parsed.user_id.is_none());
|
||||||
|
assert!(parsed.device_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn signal_config_serde() {
|
fn signal_config_serde() {
|
||||||
let sc = SignalConfig {
|
let sc = SignalConfig {
|
||||||
|
|
@ -3709,6 +3734,8 @@ tool_dispatcher = "xml"
|
||||||
matrix: Some(MatrixConfig {
|
matrix: Some(MatrixConfig {
|
||||||
homeserver: "https://m.org".into(),
|
homeserver: "https://m.org".into(),
|
||||||
access_token: "tok".into(),
|
access_token: "tok".into(),
|
||||||
|
user_id: None,
|
||||||
|
device_id: None,
|
||||||
room_id: "!r:m".into(),
|
room_id: "!r:m".into(),
|
||||||
allowed_users: vec!["@u:m".into()],
|
allowed_users: vec!["@u:m".into()],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -836,6 +836,8 @@ mod tests {
|
||||||
config.channels_config.matrix = Some(MatrixConfig {
|
config.channels_config.matrix = Some(MatrixConfig {
|
||||||
homeserver: "https://m.org".into(),
|
homeserver: "https://m.org".into(),
|
||||||
access_token: "tok".into(),
|
access_token: "tok".into(),
|
||||||
|
user_id: None,
|
||||||
|
device_id: None,
|
||||||
room_id: "!r:m".into(),
|
room_id: "!r:m".into(),
|
||||||
allowed_users: vec![],
|
allowed_users: vec![],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3015,14 +3015,44 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
.header("Authorization", format!("Bearer {access_token_clone}"))
|
.header("Authorization", format!("Bearer {access_token_clone}"))
|
||||||
.send()?;
|
.send()?;
|
||||||
let ok = resp.status().is_success();
|
let ok = resp.status().is_success();
|
||||||
Ok::<_, reqwest::Error>(ok)
|
|
||||||
|
if !ok {
|
||||||
|
return Ok::<_, reqwest::Error>((false, None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Value = match resp.json() {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(_) => Value::Null,
|
||||||
|
};
|
||||||
|
let user_id = payload
|
||||||
|
.get("user_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
let device_id = payload
|
||||||
|
.get("device_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
|
||||||
|
Ok::<_, reqwest::Error>((true, user_id, device_id))
|
||||||
})
|
})
|
||||||
.join();
|
.join();
|
||||||
match thread_result {
|
|
||||||
Ok(Ok(true)) => println!(
|
let (detected_user_id, detected_device_id) = match thread_result {
|
||||||
"\r {} Connection verified ",
|
Ok(Ok((true, user_id, device_id))) => {
|
||||||
style("✅").green().bold()
|
println!(
|
||||||
),
|
"\r {} Connection verified ",
|
||||||
|
style("✅").green().bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if device_id.is_none() {
|
||||||
|
println!(
|
||||||
|
" {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.",
|
||||||
|
style("⚠️").yellow().bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(user_id, device_id)
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(
|
println!(
|
||||||
"\r {} Connection failed — check homeserver URL and token",
|
"\r {} Connection failed — check homeserver URL and token",
|
||||||
|
|
@ -3030,7 +3060,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
let room_id: String = Input::new()
|
let room_id: String = Input::new()
|
||||||
.with_prompt(" Room ID (e.g. !abc123:matrix.org)")
|
.with_prompt(" Room ID (e.g. !abc123:matrix.org)")
|
||||||
|
|
@ -3050,6 +3080,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
config.matrix = Some(MatrixConfig {
|
config.matrix = Some(MatrixConfig {
|
||||||
homeserver: homeserver.trim_end_matches('/').to_string(),
|
homeserver: homeserver.trim_end_matches('/').to_string(),
|
||||||
access_token,
|
access_token,
|
||||||
|
user_id: detected_user_id,
|
||||||
|
device_id: detected_device_id,
|
||||||
room_id,
|
room_id,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue