rewrite(matrix): use matrix-bot-sdk as base to enable e2ee encryption, strictly follow location + typing + group concepts, fix room bugs
This commit is contained in:
committed by
Peter Steinberger
parent
dd82d32d85
commit
9b71382efb
@@ -14,6 +14,7 @@ Clawdbot normalizes shared locations from chat channels into:
|
|||||||
Currently supported:
|
Currently supported:
|
||||||
- **Telegram** (location pins + venues + live locations)
|
- **Telegram** (location pins + venues + live locations)
|
||||||
- **WhatsApp** (locationMessage + liveLocationMessage)
|
- **WhatsApp** (locationMessage + liveLocationMessage)
|
||||||
|
- **Matrix** (`m.location` with `geo_uri`)
|
||||||
|
|
||||||
## Text formatting
|
## Text formatting
|
||||||
Locations are rendered as friendly lines without brackets:
|
Locations are rendered as friendly lines without brackets:
|
||||||
@@ -44,3 +45,4 @@ When a location is present, these fields are added to `ctx`:
|
|||||||
## Channel notes
|
## Channel notes
|
||||||
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
||||||
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
|
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
|
||||||
|
- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false.
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Matrix (plugin)
|
# Matrix (plugin)
|
||||||
|
|
||||||
Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
|
Matrix is an open, decentralized messaging protocol. Clawdbot connects as a Matrix **user**
|
||||||
|
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
|
||||||
|
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
|
||||||
|
but it requires E2EE to be enabled.
|
||||||
|
|
||||||
|
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
|
||||||
|
polls (send + poll-start as text), location, and E2EE (with crypto support).
|
||||||
|
|
||||||
## Plugin required
|
## Plugin required
|
||||||
|
|
||||||
Matrix ships as a plugin and is not bundled with the core install.
|
Matrix ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot plugins install @clawdbot/matrix
|
clawdbot plugins install @clawdbot/matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
Local checkout (when running from a git repo):
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot plugins install ./extensions/matrix
|
clawdbot plugins install ./extensions/matrix
|
||||||
```
|
```
|
||||||
@@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically.
|
|||||||
|
|
||||||
Details: [Plugins](/plugin)
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Setup
|
||||||
|
|
||||||
1) Install the Matrix plugin:
|
1) Install the Matrix plugin:
|
||||||
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
||||||
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
||||||
2) Configure credentials:
|
2) Create a Matrix account on a homeserver:
|
||||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
|
||||||
|
- Or host it yourself.
|
||||||
|
3) Get an access token for the bot account:
|
||||||
|
- Use the Matrix login API with `curl` at your home server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url https://matrix.example.org/_matrix/client/v3/login \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": {
|
||||||
|
"type": "m.id.user",
|
||||||
|
"user": "your-user-name"
|
||||||
|
},
|
||||||
|
"password": "your-password"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `matrix.example.org` with your homeserver URL.
|
||||||
|
- Or set `channels.matrix.userId` + `channels.matrix.password`: Clawdbot calls the same
|
||||||
|
login endpoint, stores the access token in `~/.clawdbot/credentials/matrix/credentials.json`,
|
||||||
|
and reuses it on next start.
|
||||||
|
4) Configure credentials:
|
||||||
|
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
|
||||||
- Or config: `channels.matrix.*`
|
- Or config: `channels.matrix.*`
|
||||||
- If both are set, config takes precedence.
|
- If both are set, config takes precedence.
|
||||||
3) Restart the gateway (or finish onboarding).
|
- With access token: user ID is fetched automatically via `/whoami`.
|
||||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
|
||||||
|
5) Restart the gateway (or finish onboarding).
|
||||||
|
6) Start a DM with the bot or invite it to a room from any Matrix client
|
||||||
|
(Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE,
|
||||||
|
so set `channels.matrix.encryption: true` and verify the device.
|
||||||
|
|
||||||
Runtime note: Matrix requires Node.js (Bun is not supported).
|
Minimal config (access token, user ID auto-fetched):
|
||||||
|
|
||||||
Minimal config:
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
homeserver: "https://matrix.example.org",
|
homeserver: "https://matrix.example.org",
|
||||||
userId: "@clawdbot:example.org",
|
|
||||||
accessToken: "syt_***",
|
accessToken: "syt_***",
|
||||||
dm: { policy: "pairing" }
|
dm: { policy: "pairing" }
|
||||||
}
|
}
|
||||||
@@ -53,18 +89,49 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Encryption (E2EE)
|
E2EE config (end to end encryption enabled):
|
||||||
End-to-end encrypted rooms are **not** supported.
|
|
||||||
- Use unencrypted rooms or disable encryption when creating the room.
|
|
||||||
- If a room is E2EE, the bot will receive encrypted events and won’t reply.
|
|
||||||
|
|
||||||
## What it is
|
```json5
|
||||||
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
|
{
|
||||||
- A Matrix user account owned by the Gateway.
|
channels: {
|
||||||
- Deterministic routing: replies go back to Matrix.
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "syt_***",
|
||||||
|
encryption: true,
|
||||||
|
dm: { policy: "pairing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encryption (E2EE)
|
||||||
|
|
||||||
|
End-to-end encryption is **supported** via the Rust crypto SDK.
|
||||||
|
|
||||||
|
Enable with `channels.matrix.encryption: true`:
|
||||||
|
|
||||||
|
- If the crypto module loads, encrypted rooms are decrypted automatically.
|
||||||
|
- Outbound media is encrypted when sending to encrypted rooms.
|
||||||
|
- On first connection, Clawdbot requests device verification from your other sessions.
|
||||||
|
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
|
||||||
|
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
||||||
|
Clawdbot logs a warning.
|
||||||
|
|
||||||
|
Crypto state is stored in `~/.clawdbot/matrix/crypto/` (SQLite database).
|
||||||
|
|
||||||
|
**Device verification:**
|
||||||
|
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
||||||
|
Open Element (or another client) and approve the verification request to establish trust.
|
||||||
|
Once verified, the bot can decrypt messages in encrypted rooms.
|
||||||
|
|
||||||
|
## Routing model
|
||||||
|
|
||||||
|
- Replies always go back to Matrix.
|
||||||
- DMs share the agent's main session; rooms map to group sessions.
|
- DMs share the agent's main session; rooms map to group sessions.
|
||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
|
|
||||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list matrix`
|
- `clawdbot pairing list matrix`
|
||||||
@@ -73,58 +140,80 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
|||||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
||||||
|
|
||||||
## Rooms (groups)
|
## Rooms (groups)
|
||||||
|
|
||||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
- Allowlist rooms with `channels.matrix.rooms`:
|
- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
rooms: {
|
groupPolicy: "allowlist",
|
||||||
"!roomId:example.org": { requireMention: true }
|
groups: {
|
||||||
}
|
"!roomId:example.org": { allow: true },
|
||||||
|
"#alias:example.org": { allow: true }
|
||||||
|
},
|
||||||
|
groupAllowFrom: ["@owner:example.org"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `requireMention: false` enables auto-reply in that room.
|
- `requireMention: false` enables auto-reply in that room.
|
||||||
|
- `groups."*"` can set defaults for mention gating across rooms.
|
||||||
|
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional).
|
||||||
|
- Per-room `users` allowlists can further restrict senders inside a specific room.
|
||||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
||||||
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
|
||||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||||
|
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
|
||||||
|
|
||||||
## Threads
|
## Threads
|
||||||
|
|
||||||
- Reply threading is supported.
|
- Reply threading is supported.
|
||||||
- `channels.matrix.replyToMode` controls replies when tagged:
|
- `channels.matrix.threadReplies` controls whether replies stay in threads:
|
||||||
|
- `off`, `inbound` (default), `always`
|
||||||
|
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
|
||||||
- `off` (default), `first`, `all`
|
- `off` (default), `first`, `all`
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Direct messages | ✅ Supported |
|
| Direct messages | ✅ Supported |
|
||||||
| Rooms | ✅ Supported |
|
| Rooms | ✅ Supported |
|
||||||
| Threads | ✅ Supported |
|
| Threads | ✅ Supported |
|
||||||
| Media | ✅ Supported |
|
| Media | ✅ Supported |
|
||||||
| Reactions | ✅ Supported |
|
| E2EE | ✅ Supported (crypto module required) |
|
||||||
| Polls | ✅ Supported |
|
| Reactions | ✅ Supported (send/read via tools) |
|
||||||
|
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
|
||||||
|
| Location | ✅ Supported (geo URI; altitude ignored) |
|
||||||
| Native commands | ✅ Supported |
|
| Native commands | ✅ Supported |
|
||||||
|
|
||||||
## Configuration reference (Matrix)
|
## Configuration reference (Matrix)
|
||||||
|
|
||||||
Full configuration: [Configuration](/gateway/configuration)
|
Full configuration: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
Provider options:
|
Provider options:
|
||||||
|
|
||||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
- `channels.matrix.enabled`: enable/disable channel startup.
|
||||||
- `channels.matrix.homeserver`: homeserver URL.
|
- `channels.matrix.homeserver`: homeserver URL.
|
||||||
- `channels.matrix.userId`: Matrix user ID.
|
- `channels.matrix.userId`: Matrix user ID (optional with access token).
|
||||||
- `channels.matrix.accessToken`: access token.
|
- `channels.matrix.accessToken`: access token.
|
||||||
- `channels.matrix.password`: password for login (token stored).
|
- `channels.matrix.password`: password for login (token stored).
|
||||||
- `channels.matrix.deviceName`: device display name.
|
- `channels.matrix.deviceName`: device display name.
|
||||||
|
- `channels.matrix.encryption`: enable E2EE (default: false).
|
||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
|
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages.
|
||||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
- `channels.matrix.groups`: group allowlist + per-room settings map.
|
||||||
|
- `channels.matrix.rooms`: legacy group allowlist/config.
|
||||||
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
||||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ Control how group/room messages are handled per channel:
|
|||||||
slack: {
|
slack: {
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
channels: { "#general": { allow: true } }
|
channels: { "#general": { allow: true } }
|
||||||
|
},
|
||||||
|
matrix: {
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groupAllowFrom: ["@owner:example.org"],
|
||||||
|
groups: {
|
||||||
|
"!roomId:example.org": { allow: true },
|
||||||
|
"#alias:example.org": { allow: true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +173,7 @@ Notes:
|
|||||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||||
- Slack: allowlist uses `channels.slack.channels`.
|
- Slack: allowlist uses `channels.slack.channels`.
|
||||||
|
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
|
||||||
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
|
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
|
||||||
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
|
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
|
||||||
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
|
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||||
"clawdbot": "workspace:*",
|
"clawdbot": "workspace:*",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"matrix-js-sdk": "40.0.0"
|
"matrix-bot-sdk": "0.8.0",
|
||||||
|
"music-metadata": "^11.10.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import os from "node:os";
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMatrixRuntime({
|
setMatrixRuntime({
|
||||||
state: {
|
state: {
|
||||||
resolveStateDir: () => os.tmpdir(),
|
resolveStateDir: (_env, homeDir) => homeDir(),
|
||||||
},
|
},
|
||||||
} as PluginRuntime);
|
} as PluginRuntime);
|
||||||
});
|
});
|
||||||
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
|
|||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||||
rooms: {
|
groupAllowFrom: ["@dana:example.org"],
|
||||||
|
groups: {
|
||||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||||
"#alias:example.org": { users: [] },
|
"#alias:example.org": { users: [] },
|
||||||
},
|
},
|
||||||
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
|
|||||||
{ kind: "user", id: "user:@alice:example.org" },
|
{ kind: "user", id: "user:@alice:example.org" },
|
||||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||||
{ kind: "user", id: "user:@carol:example.org" },
|
{ kind: "user", id: "user:@carol:example.org" },
|
||||||
|
{ kind: "user", id: "user:@dana:example.org" },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ const meta = {
|
|||||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||||
let normalized = raw.trim();
|
let normalized = raw.trim();
|
||||||
if (!normalized) return undefined;
|
if (!normalized) return undefined;
|
||||||
if (normalized.toLowerCase().startsWith("matrix:")) {
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (lowered.startsWith("matrix:")) {
|
||||||
normalized = normalized.slice("matrix:".length).trim();
|
normalized = normalized.slice("matrix:".length).trim();
|
||||||
}
|
}
|
||||||
return normalized ? normalized.toLowerCase() : undefined;
|
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
|
||||||
|
return stripped || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMatrixConfigUpdate(
|
function buildMatrixConfigUpdate(
|
||||||
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
}),
|
}),
|
||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy =
|
||||||
|
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") return [];
|
||||||
return [
|
return [
|
||||||
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
|
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,6 +171,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) =>
|
resolveReplyToMode: ({ cfg }) =>
|
||||||
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
||||||
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
|
const currentTarget = context.To;
|
||||||
|
return {
|
||||||
|
currentChannelId: currentTarget?.trim() || undefined,
|
||||||
|
currentThreadTs:
|
||||||
|
context.MessageThreadId != null
|
||||||
|
? String(context.MessageThreadId)
|
||||||
|
: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||||
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const room of Object.values(account.config.rooms ?? {})) {
|
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||||
|
const raw = String(entry).trim();
|
||||||
|
if (!raw || raw === "*") continue;
|
||||||
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||||
|
for (const room of Object.values(groups)) {
|
||||||
for (const entry of room.users ?? []) {
|
for (const entry of room.users ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") continue;
|
if (!raw || raw === "*") continue;
|
||||||
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||||
const q = query?.trim().toLowerCase() || "";
|
const q = query?.trim().toLowerCase() || "";
|
||||||
const ids = Object.keys(account.config.rooms ?? {})
|
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||||
|
const ids = Object.keys(groups)
|
||||||
.map((raw) => raw.trim())
|
.map((raw) => raw.trim())
|
||||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||||
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (input.useEnv) return null;
|
if (input.useEnv) return null;
|
||||||
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
||||||
if (!input.userId?.trim()) return "Matrix requires --user-id";
|
const accessToken = input.accessToken?.trim();
|
||||||
if (!input.accessToken?.trim() && !input.password?.trim()) {
|
const password = input.password?.trim();
|
||||||
|
const userId = input.userId?.trim();
|
||||||
|
if (!accessToken && !password) {
|
||||||
return "Matrix requires --access-token or --password";
|
return "Matrix requires --access-token or --password";
|
||||||
}
|
}
|
||||||
|
if (!accessToken) {
|
||||||
|
if (!userId) return "Matrix requires --user-id when using --password";
|
||||||
|
if (!password) return "Matrix requires --password when using --user-id";
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
applyAccountConfig: ({ cfg, input }) => {
|
applyAccountConfig: ({ cfg, input }) => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({
|
|||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
deviceName: z.string().optional(),
|
deviceName: z.string().optional(),
|
||||||
initialSyncLimit: z.number().optional(),
|
initialSyncLimit: z.number().optional(),
|
||||||
|
encryption: z.boolean().optional(),
|
||||||
allowlistOnly: z.boolean().optional(),
|
allowlistOnly: z.boolean().optional(),
|
||||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||||
@@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({
|
|||||||
mediaMaxMb: z.number().optional(),
|
mediaMaxMb: z.number().optional(),
|
||||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||||
|
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||||
dm: matrixDmSchema,
|
dm: matrixDmSchema,
|
||||||
|
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||||
actions: matrixActionSchema,
|
actions: matrixActionSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
|||||||
const aliases = groupChannel ? [groupChannel] : [];
|
const aliases = groupChannel ? [groupChannel] : [];
|
||||||
const cfg = params.cfg as CoreConfig;
|
const cfg = params.cfg as CoreConfig;
|
||||||
const resolved = resolveMatrixRoomConfig({
|
const resolved = resolveMatrixRoomConfig({
|
||||||
rooms: cfg.channels?.matrix?.rooms,
|
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||||
roomId,
|
roomId,
|
||||||
aliases,
|
aliases,
|
||||||
name: groupChannel || undefined,
|
name: groupChannel || undefined,
|
||||||
|
|||||||
83
extensions/matrix/src/matrix/accounts.test.ts
Normal file
83
extensions/matrix/src/matrix/accounts.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { resolveMatrixAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
vi.mock("./credentials.js", () => ({
|
||||||
|
loadMatrixCredentials: () => null,
|
||||||
|
credentialsMatchConfig: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const envKeys = [
|
||||||
|
"MATRIX_HOMESERVER",
|
||||||
|
"MATRIX_USER_ID",
|
||||||
|
"MATRIX_ACCESS_TOKEN",
|
||||||
|
"MATRIX_PASSWORD",
|
||||||
|
"MATRIX_DEVICE_NAME",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resolveMatrixAccount", () => {
|
||||||
|
let prevEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prevEnv = {};
|
||||||
|
for (const key of envKeys) {
|
||||||
|
prevEnv[key] = process.env[key];
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of envKeys) {
|
||||||
|
const value = prevEnv[key];
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats access-token-only config as configured", () => {
|
||||||
|
const cfg: CoreConfig = {
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "tok-access",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveMatrixAccount({ cfg });
|
||||||
|
expect(account.configured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires userId + password when no access token is set", () => {
|
||||||
|
const cfg: CoreConfig = {
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
userId: "@bot:example.org",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveMatrixAccount({ cfg });
|
||||||
|
expect(account.configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks password auth as configured when userId is present", () => {
|
||||||
|
const cfg: CoreConfig = {
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
userId: "@bot:example.org",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = resolveMatrixAccount({ cfg });
|
||||||
|
expect(account.configured).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,18 +31,20 @@ export function resolveMatrixAccount(params: {
|
|||||||
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
|
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
|
||||||
const enabled = base.enabled !== false;
|
const enabled = base.enabled !== false;
|
||||||
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
||||||
const hasCore = Boolean(resolved.homeserver && resolved.userId);
|
const hasHomeserver = Boolean(resolved.homeserver);
|
||||||
const hasToken = Boolean(resolved.accessToken || resolved.password);
|
const hasUserId = Boolean(resolved.userId);
|
||||||
|
const hasAccessToken = Boolean(resolved.accessToken);
|
||||||
|
const hasPassword = Boolean(resolved.password);
|
||||||
|
const hasPasswordAuth = hasUserId && hasPassword;
|
||||||
const stored = loadMatrixCredentials(process.env);
|
const stored = loadMatrixCredentials(process.env);
|
||||||
const hasStored =
|
const hasStored =
|
||||||
stored &&
|
stored && resolved.homeserver
|
||||||
resolved.homeserver &&
|
? credentialsMatchConfig(stored, {
|
||||||
resolved.userId &&
|
homeserver: resolved.homeserver,
|
||||||
credentialsMatchConfig(stored, {
|
userId: resolved.userId || "",
|
||||||
homeserver: resolved.homeserver,
|
})
|
||||||
userId: resolved.userId,
|
: false;
|
||||||
});
|
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
||||||
const configured = hasCore && (hasToken || Boolean(hasStored));
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
import {
|
|
||||||
Direction,
|
|
||||||
EventType,
|
|
||||||
MatrixError,
|
|
||||||
MsgType,
|
|
||||||
RelationType,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import type {
|
|
||||||
ReactionEventContent,
|
|
||||||
RoomMessageEventContent,
|
|
||||||
} from "matrix-js-sdk/lib/@types/events.js";
|
|
||||||
import type {
|
|
||||||
RoomPinnedEventsEventContent,
|
|
||||||
RoomTopicEventContent,
|
|
||||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
@@ -23,7 +8,6 @@ import {
|
|||||||
isBunRuntime,
|
isBunRuntime,
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
waitForMatrixSync,
|
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import {
|
import {
|
||||||
reactMatrixMessage,
|
reactMatrixMessage,
|
||||||
@@ -31,6 +15,62 @@ import {
|
|||||||
sendMessageMatrix,
|
sendMessageMatrix,
|
||||||
} from "./send.js";
|
} from "./send.js";
|
||||||
|
|
||||||
|
// Constants that were previously from matrix-js-sdk
|
||||||
|
const MsgType = {
|
||||||
|
Text: "m.text",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const RelationType = {
|
||||||
|
Replace: "m.replace",
|
||||||
|
Annotation: "m.annotation",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EventType = {
|
||||||
|
RoomMessage: "m.room.message",
|
||||||
|
RoomPinnedEvents: "m.room.pinned_events",
|
||||||
|
RoomTopic: "m.room.topic",
|
||||||
|
Reaction: "m.reaction",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Type definitions for matrix-bot-sdk event content
|
||||||
|
type RoomMessageEventContent = {
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
"m.new_content"?: RoomMessageEventContent;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?: { event_id?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReactionEventContent = {
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: string;
|
||||||
|
event_id: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomPinnedEventsEventContent = {
|
||||||
|
pinned: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomTopicEventContent = {
|
||||||
|
topic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixRawEvent = {
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
type: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
unsigned?: {
|
||||||
|
redacted_because?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MatrixActionClientOpts = {
|
export type MatrixActionClientOpts = {
|
||||||
client?: MatrixClient;
|
client?: MatrixClient;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@@ -86,19 +126,15 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
|||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
await client.startClient({
|
await client.start();
|
||||||
initialSyncLimit: 0,
|
|
||||||
lazyLoadMembers: true,
|
|
||||||
threadSupport: true,
|
|
||||||
});
|
|
||||||
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
|
|
||||||
return { client, stopOnDone: true };
|
return { client, stopOnDone: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
|
function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
||||||
const content = event.getContent<RoomMessageEventContent>();
|
const content = event.content as RoomMessageEventContent;
|
||||||
const relates = content["m.relates_to"];
|
const relates = content["m.relates_to"];
|
||||||
let relType: string | undefined;
|
let relType: string | undefined;
|
||||||
let eventId: string | undefined;
|
let eventId: string | undefined;
|
||||||
@@ -118,27 +154,28 @@ function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
eventId: event.getId() ?? undefined,
|
eventId: event.event_id,
|
||||||
sender: event.getSender() ?? undefined,
|
sender: event.sender,
|
||||||
body: content.body,
|
body: content.body,
|
||||||
msgtype: content.msgtype,
|
msgtype: content.msgtype,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: event.origin_server_ts,
|
||||||
relatesTo,
|
relatesTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const content = (await client.getStateEvent(
|
const content = (await client.getRoomStateEvent(
|
||||||
roomId,
|
roomId,
|
||||||
EventType.RoomPinnedEvents,
|
EventType.RoomPinnedEvents,
|
||||||
"",
|
"",
|
||||||
)) as RoomPinnedEventsEventContent;
|
)) as RoomPinnedEventsEventContent;
|
||||||
const pinned = content.pinned;
|
const pinned = content.pinned;
|
||||||
return pinned.filter((id) => id.trim().length > 0);
|
return pinned.filter((id) => id.trim().length > 0);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
|
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
||||||
const errcode = err instanceof MatrixError ? err.errcode : undefined;
|
const httpStatus = errObj.statusCode;
|
||||||
|
const errcode = errObj.body?.errcode;
|
||||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -151,11 +188,14 @@ async function fetchEventSummary(
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
): Promise<MatrixMessageSummary | null> {
|
): Promise<MatrixMessageSummary | null> {
|
||||||
const raw = await client.fetchRoomEvent(roomId, eventId);
|
try {
|
||||||
const mapper = client.getEventMapper();
|
const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent;
|
||||||
const event = mapper(raw);
|
if (raw.unsigned?.redacted_because) return null;
|
||||||
if (event.isRedacted()) return null;
|
return summarizeMatrixRawEvent(raw);
|
||||||
return summarizeMatrixEvent(event);
|
} catch (err) {
|
||||||
|
// Event not found, redacted, or inaccessible - return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMatrixMessage(
|
export async function sendMatrixMessage(
|
||||||
@@ -200,10 +240,10 @@ export async function editMatrixMessage(
|
|||||||
event_id: messageId,
|
event_id: messageId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const response = await client.sendMessage(resolvedRoom, payload);
|
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||||
return { eventId: response.event_id ?? null };
|
return { eventId: eventId ?? null };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,11 +255,9 @@ export async function deleteMatrixMessage(
|
|||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
await client.redactEvent(resolvedRoom, messageId, undefined, {
|
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||||
reason: opts.reason,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +280,25 @@ export async function readMatrixMessages(
|
|||||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||||
? Math.max(1, Math.floor(opts.limit))
|
? Math.max(1, Math.floor(opts.limit))
|
||||||
: 20;
|
: 20;
|
||||||
const token = opts.before?.trim() || opts.after?.trim() || null;
|
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||||
const dir = opts.after ? Direction.Forward : Direction.Backward;
|
const dir = opts.after ? "f" : "b";
|
||||||
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
|
// matrix-bot-sdk uses doRequest for room messages
|
||||||
const mapper = client.getEventMapper();
|
const res = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, {
|
||||||
const events = res.chunk.map(mapper);
|
dir,
|
||||||
const messages = events
|
limit,
|
||||||
.filter((event) => event.getType() === EventType.RoomMessage)
|
from: token,
|
||||||
.filter((event) => !event.isRedacted())
|
}) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
||||||
.map(summarizeMatrixEvent);
|
const messages = res.chunk
|
||||||
|
.filter((event) => event.type === EventType.RoomMessage)
|
||||||
|
.filter((event) => !event.unsigned?.redacted_because)
|
||||||
|
.map(summarizeMatrixRawEvent);
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
nextBatch: res.end ?? null,
|
nextBatch: res.end ?? null,
|
||||||
prevBatch: res.start ?? null,
|
prevBatch: res.start ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,19 +314,18 @@ export async function listMatrixReactions(
|
|||||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||||
? Math.max(1, Math.floor(opts.limit))
|
? Math.max(1, Math.floor(opts.limit))
|
||||||
: 100;
|
: 100;
|
||||||
const res = await client.relations(
|
// matrix-bot-sdk uses doRequest for relations
|
||||||
resolvedRoom,
|
const res = await client.doRequest(
|
||||||
messageId,
|
"GET",
|
||||||
RelationType.Annotation,
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
EventType.Reaction,
|
{ dir: "b", limit },
|
||||||
{ dir: Direction.Backward, limit },
|
) as { chunk: MatrixRawEvent[] };
|
||||||
);
|
|
||||||
const summaries = new Map<string, MatrixReactionSummary>();
|
const summaries = new Map<string, MatrixReactionSummary>();
|
||||||
for (const event of res.events) {
|
for (const event of res.chunk) {
|
||||||
const content = event.getContent<ReactionEventContent>();
|
const content = event.content as ReactionEventContent;
|
||||||
const key = content["m.relates_to"].key;
|
const key = content["m.relates_to"]?.key;
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const sender = event.getSender() ?? "";
|
const sender = event.sender ?? "";
|
||||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||||
key,
|
key,
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -299,7 +339,7 @@ export async function listMatrixReactions(
|
|||||||
}
|
}
|
||||||
return Array.from(summaries.values());
|
return Array.from(summaries.values());
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,30 +351,28 @@ export async function removeMatrixReactions(
|
|||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
const res = await client.relations(
|
const res = await client.doRequest(
|
||||||
resolvedRoom,
|
"GET",
|
||||||
messageId,
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
RelationType.Annotation,
|
{ dir: "b", limit: 200 },
|
||||||
EventType.Reaction,
|
) as { chunk: MatrixRawEvent[] };
|
||||||
{ dir: Direction.Backward, limit: 200 },
|
const userId = await client.getUserId();
|
||||||
);
|
|
||||||
const userId = client.getUserId();
|
|
||||||
if (!userId) return { removed: 0 };
|
if (!userId) return { removed: 0 };
|
||||||
const targetEmoji = opts.emoji?.trim();
|
const targetEmoji = opts.emoji?.trim();
|
||||||
const toRemove = res.events
|
const toRemove = res.chunk
|
||||||
.filter((event) => event.getSender() === userId)
|
.filter((event) => event.sender === userId)
|
||||||
.filter((event) => {
|
.filter((event) => {
|
||||||
if (!targetEmoji) return true;
|
if (!targetEmoji) return true;
|
||||||
const content = event.getContent<ReactionEventContent>();
|
const content = event.content as ReactionEventContent;
|
||||||
return content["m.relates_to"].key === targetEmoji;
|
return content["m.relates_to"]?.key === targetEmoji;
|
||||||
})
|
})
|
||||||
.map((event) => event.getId())
|
.map((event) => event.event_id)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
if (toRemove.length === 0) return { removed: 0 };
|
if (toRemove.length === 0) return { removed: 0 };
|
||||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||||
return { removed: toRemove.length };
|
return { removed: toRemove.length };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,10 +387,10 @@ export async function pinMatrixMessage(
|
|||||||
const current = await readPinnedEvents(client, resolvedRoom);
|
const current = await readPinnedEvents(client, resolvedRoom);
|
||||||
const next = current.includes(messageId) ? current : [...current, messageId];
|
const next = current.includes(messageId) ? current : [...current, messageId];
|
||||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,10 +405,10 @@ export async function unpinMatrixMessage(
|
|||||||
const current = await readPinnedEvents(client, resolvedRoom);
|
const current = await readPinnedEvents(client, resolvedRoom);
|
||||||
const next = current.filter((id) => id !== messageId);
|
const next = current.filter((id) => id !== messageId);
|
||||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +433,7 @@ export async function listMatrixPins(
|
|||||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||||
return { pinned, events };
|
return { pinned, events };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,20 +444,23 @@ export async function getMatrixMemberInfo(
|
|||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||||
const profile = await client.getProfileInfo(userId);
|
// matrix-bot-sdk uses getUserProfile
|
||||||
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
|
const profile = await client.getUserProfile(userId);
|
||||||
|
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||||
|
// We'd need to fetch room state separately if needed
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
profile: {
|
profile: {
|
||||||
displayName: profile?.displayname ?? null,
|
displayName: profile?.displayname ?? null,
|
||||||
avatarUrl: profile?.avatar_url ?? null,
|
avatarUrl: profile?.avatar_url ?? null,
|
||||||
},
|
},
|
||||||
membership: member?.membership ?? null,
|
membership: null, // Would need separate room state query
|
||||||
powerLevel: member?.powerLevel ?? null,
|
powerLevel: null, // Would need separate power levels state query
|
||||||
displayName: member?.name ?? null,
|
displayName: profile?.displayname ?? null,
|
||||||
|
roomId: roomId ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,20 +468,42 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
|||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
const room = client.getRoom(resolvedRoom);
|
// matrix-bot-sdk uses getRoomState for state events
|
||||||
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
|
let name: string | null = null;
|
||||||
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
|
let topic: string | null = null;
|
||||||
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
|
let canonicalAlias: string | null = null;
|
||||||
|
let memberCount: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||||
|
name = nameState?.name ?? null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||||
|
topic = topicState?.topic ?? null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||||
|
canonicalAlias = aliasState?.alias ?? null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
||||||
|
memberCount = members.length;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roomId: resolvedRoom,
|
roomId: resolvedRoom,
|
||||||
name: room?.name ?? null,
|
name,
|
||||||
topic: topic ?? null,
|
topic,
|
||||||
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
|
canonicalAlias,
|
||||||
altAliases: room?.getAltAliases?.() ?? [],
|
altAliases: [], // Would need separate query
|
||||||
memberCount: room?.getJoinedMemberCount?.() ?? null,
|
memberCount,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stopClient();
|
if (stopOnDone) client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
let activeClient: MatrixClient | null = null;
|
let activeClient: MatrixClient | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => {
|
|||||||
password: "cfg-pass",
|
password: "cfg-pass",
|
||||||
deviceName: "CfgDevice",
|
deviceName: "CfgDevice",
|
||||||
initialSyncLimit: 5,
|
initialSyncLimit: 5,
|
||||||
|
encryption: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => {
|
|||||||
expect(resolved.password).toBe("env-pass");
|
expect(resolved.password).toBe("env-pass");
|
||||||
expect(resolved.deviceName).toBe("EnvDevice");
|
expect(resolved.deviceName).toBe("EnvDevice");
|
||||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||||
|
expect(resolved.encryption).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
import {
|
||||||
|
ConsoleLogger,
|
||||||
|
LogService,
|
||||||
|
MatrixClient,
|
||||||
|
SimpleFsStorageProvider,
|
||||||
|
RustSdkCryptoStorageProvider,
|
||||||
|
} from "matrix-bot-sdk";
|
||||||
|
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
@@ -10,22 +17,30 @@ export type MatrixResolvedConfig = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated Matrix configuration.
|
||||||
|
* Note: deviceId is NOT included here because it's implicit in the accessToken.
|
||||||
|
* The crypto storage assumes the device ID (and thus access token) does not change
|
||||||
|
* between restarts. If the access token becomes invalid or crypto storage is lost,
|
||||||
|
* both will need to be recreated together.
|
||||||
|
*/
|
||||||
export type MatrixAuth = {
|
export type MatrixAuth = {
|
||||||
homeserver: string;
|
homeserver: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MatrixSdk = typeof import("matrix-js-sdk");
|
|
||||||
|
|
||||||
type SharedMatrixClientState = {
|
type SharedMatrixClientState = {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
key: string;
|
key: string;
|
||||||
started: boolean;
|
started: boolean;
|
||||||
|
cryptoReady: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let sharedClientState: SharedMatrixClientState | null = null;
|
let sharedClientState: SharedMatrixClientState | null = null;
|
||||||
@@ -37,14 +52,65 @@ export function isBunRuntime(): boolean {
|
|||||||
return typeof versions.bun === "string";
|
return typeof versions.bun === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMatrixSdk(): Promise<MatrixSdk> {
|
let matrixSdkLoggingConfigured = false;
|
||||||
return (await import("matrix-js-sdk")) as MatrixSdk;
|
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||||
|
|
||||||
|
function shouldSuppressMatrixHttpNotFound(
|
||||||
|
module: string,
|
||||||
|
messageOrObject: unknown[],
|
||||||
|
): boolean {
|
||||||
|
if (module !== "MatrixHttpClient") return false;
|
||||||
|
return messageOrObject.some((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return false;
|
||||||
|
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMatrixSdkLoggingConfigured(): void {
|
||||||
|
if (matrixSdkLoggingConfigured) return;
|
||||||
|
matrixSdkLoggingConfigured = true;
|
||||||
|
|
||||||
|
LogService.setLogger({
|
||||||
|
trace: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||||
|
debug: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||||
|
info: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||||
|
warn: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||||
|
error: (module, ...messageOrObject) => {
|
||||||
|
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
||||||
|
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clean(value?: string): string {
|
function clean(value?: string): string {
|
||||||
return value?.trim() ?? "";
|
return value?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||||
|
if (input == null) return [];
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const filtered = input.filter(
|
||||||
|
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||||
|
);
|
||||||
|
if (filtered.length !== input.length) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMatrixConfig(
|
export function resolveMatrixConfig(
|
||||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
@@ -61,6 +127,7 @@ export function resolveMatrixConfig(
|
|||||||
typeof matrix.initialSyncLimit === "number"
|
typeof matrix.initialSyncLimit === "number"
|
||||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const encryption = matrix.encryption ?? false;
|
||||||
return {
|
return {
|
||||||
homeserver,
|
homeserver,
|
||||||
userId,
|
userId,
|
||||||
@@ -68,9 +135,26 @@ export function resolveMatrixConfig(
|
|||||||
password,
|
password,
|
||||||
deviceName,
|
deviceName,
|
||||||
initialSyncLimit,
|
initialSyncLimit,
|
||||||
|
encryption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCryptoStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
|
||||||
|
require("node:os").homedir(),
|
||||||
|
);
|
||||||
|
const path = require("node:path");
|
||||||
|
return path.join(stateDir, "matrix", "crypto");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStoragePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
|
||||||
|
require("node:os").homedir(),
|
||||||
|
);
|
||||||
|
const path = require("node:path");
|
||||||
|
return path.join(stateDir, "matrix", "bot-storage.json");
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveMatrixAuth(params?: {
|
export async function resolveMatrixAuth(params?: {
|
||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -81,9 +165,6 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
if (!resolved.homeserver) {
|
if (!resolved.homeserver) {
|
||||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||||
}
|
}
|
||||||
if (!resolved.userId) {
|
|
||||||
throw new Error("Matrix userId is required (matrix.userId)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loadMatrixCredentials,
|
loadMatrixCredentials,
|
||||||
@@ -97,21 +178,36 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
cached &&
|
cached &&
|
||||||
credentialsMatchConfig(cached, {
|
credentialsMatchConfig(cached, {
|
||||||
homeserver: resolved.homeserver,
|
homeserver: resolved.homeserver,
|
||||||
userId: resolved.userId,
|
userId: resolved.userId || "",
|
||||||
})
|
})
|
||||||
? cached
|
? cached
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// If we have an access token, we can fetch userId via whoami if not provided
|
||||||
if (resolved.accessToken) {
|
if (resolved.accessToken) {
|
||||||
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
let userId = resolved.userId;
|
||||||
|
if (!userId) {
|
||||||
|
// Fetch userId from access token via whoami
|
||||||
|
ensureMatrixSdkLoggingConfigured();
|
||||||
|
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||||
|
const whoami = await tempClient.getUserId();
|
||||||
|
userId = whoami;
|
||||||
|
// Save the credentials with the fetched userId
|
||||||
|
saveMatrixCredentials({
|
||||||
|
homeserver: resolved.homeserver,
|
||||||
|
userId,
|
||||||
|
accessToken: resolved.accessToken,
|
||||||
|
});
|
||||||
|
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||||
touchMatrixCredentials(env);
|
touchMatrixCredentials(env);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
homeserver: resolved.homeserver,
|
homeserver: resolved.homeserver,
|
||||||
userId: resolved.userId,
|
userId,
|
||||||
accessToken: resolved.accessToken,
|
accessToken: resolved.accessToken,
|
||||||
deviceName: resolved.deviceName,
|
deviceName: resolved.deviceName,
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,25 +219,45 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
accessToken: cachedCredentials.accessToken,
|
accessToken: cachedCredentials.accessToken,
|
||||||
deviceName: resolved.deviceName,
|
deviceName: resolved.deviceName,
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resolved.userId) {
|
||||||
|
throw new Error(
|
||||||
|
"Matrix userId is required when no access token is configured (matrix.userId)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!resolved.password) {
|
if (!resolved.password) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
|
"Matrix password is required when no access token is configured (matrix.password)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdk = await loadMatrixSdk();
|
// Login with password using HTTP API
|
||||||
const loginClient = sdk.createClient({
|
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
||||||
baseUrl: resolved.homeserver,
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
const login = await loginClient.loginRequest({
|
body: JSON.stringify({
|
||||||
type: "m.login.password",
|
type: "m.login.password",
|
||||||
identifier: { type: "m.id.user", user: resolved.userId },
|
identifier: { type: "m.id.user", user: resolved.userId },
|
||||||
password: resolved.password,
|
password: resolved.password,
|
||||||
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
const errorText = await loginResponse.text();
|
||||||
|
throw new Error(`Matrix login failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = (await loginResponse.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
user_id?: string;
|
||||||
|
device_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const accessToken = login.access_token?.trim();
|
const accessToken = login.access_token?.trim();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error("Matrix login did not return an access token");
|
throw new Error("Matrix login did not return an access token");
|
||||||
@@ -153,12 +269,14 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
accessToken,
|
accessToken,
|
||||||
deviceName: resolved.deviceName,
|
deviceName: resolved.deviceName,
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveMatrixCredentials({
|
saveMatrixCredentials({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
|
deviceId: login.device_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return auth;
|
return auth;
|
||||||
@@ -168,21 +286,79 @@ export async function createMatrixClient(params: {
|
|||||||
homeserver: string;
|
homeserver: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
encryption?: boolean;
|
||||||
localTimeoutMs?: number;
|
localTimeoutMs?: number;
|
||||||
}): Promise<MatrixClient> {
|
}): Promise<MatrixClient> {
|
||||||
const sdk = await loadMatrixSdk();
|
ensureMatrixSdkLoggingConfigured();
|
||||||
const store = new sdk.MemoryStore();
|
const env = process.env;
|
||||||
return sdk.createClient({
|
|
||||||
baseUrl: params.homeserver,
|
// Create storage provider
|
||||||
userId: params.userId,
|
const storagePath = resolveStoragePath(env);
|
||||||
accessToken: params.accessToken,
|
const fs = await import("node:fs");
|
||||||
localTimeoutMs: params.localTimeoutMs,
|
const path = await import("node:path");
|
||||||
store,
|
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||||
});
|
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath);
|
||||||
|
|
||||||
|
// Create crypto storage if encryption is enabled
|
||||||
|
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||||
|
if (params.encryption) {
|
||||||
|
const cryptoPath = resolveCryptoStorePath(env);
|
||||||
|
fs.mkdirSync(cryptoPath, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||||
|
cryptoStorage = new RustSdkCryptoStorageProvider(cryptoPath, StoreType.Sqlite);
|
||||||
|
} catch (err) {
|
||||||
|
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new MatrixClient(
|
||||||
|
params.homeserver,
|
||||||
|
params.accessToken,
|
||||||
|
storage,
|
||||||
|
cryptoStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (client.crypto) {
|
||||||
|
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||||
|
client.crypto.updateSyncData = async (
|
||||||
|
toDeviceMessages,
|
||||||
|
otkCounts,
|
||||||
|
unusedFallbackKeyAlgs,
|
||||||
|
changedDeviceLists,
|
||||||
|
leftDeviceLists,
|
||||||
|
) => {
|
||||||
|
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||||
|
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||||
|
try {
|
||||||
|
return await originalUpdateSyncData(
|
||||||
|
toDeviceMessages,
|
||||||
|
otkCounts,
|
||||||
|
unusedFallbackKeyAlgs,
|
||||||
|
safeChanged,
|
||||||
|
safeLeft,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||||
|
if (message.includes("Expect value to be String")) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
"Ignoring malformed device list entries during crypto sync",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
function buildSharedClientKey(auth: MatrixAuth): string {
|
||||||
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
|
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSharedMatrixClient(params: {
|
async function createSharedMatrixClient(params: {
|
||||||
@@ -193,15 +369,22 @@ async function createSharedMatrixClient(params: {
|
|||||||
homeserver: params.auth.homeserver,
|
homeserver: params.auth.homeserver,
|
||||||
userId: params.auth.userId,
|
userId: params.auth.userId,
|
||||||
accessToken: params.auth.accessToken,
|
accessToken: params.auth.accessToken,
|
||||||
|
encryption: params.auth.encryption,
|
||||||
localTimeoutMs: params.timeoutMs,
|
localTimeoutMs: params.timeoutMs,
|
||||||
});
|
});
|
||||||
return { client, key: buildSharedClientKey(params.auth), started: false };
|
return {
|
||||||
|
client,
|
||||||
|
key: buildSharedClientKey(params.auth),
|
||||||
|
started: false,
|
||||||
|
cryptoReady: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSharedClientStarted(params: {
|
async function ensureSharedClientStarted(params: {
|
||||||
state: SharedMatrixClientState;
|
state: SharedMatrixClientState;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (params.state.started) return;
|
if (params.state.started) return;
|
||||||
if (sharedClientStartPromise) {
|
if (sharedClientStartPromise) {
|
||||||
@@ -209,18 +392,22 @@ async function ensureSharedClientStarted(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sharedClientStartPromise = (async () => {
|
sharedClientStartPromise = (async () => {
|
||||||
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
|
const client = params.state.client;
|
||||||
lazyLoadMembers: true,
|
|
||||||
threadSupport: true,
|
// Initialize crypto if enabled
|
||||||
};
|
if (params.encryption && !params.state.cryptoReady) {
|
||||||
if (typeof params.initialSyncLimit === "number") {
|
try {
|
||||||
startOpts.initialSyncLimit = params.initialSyncLimit;
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
if (client.crypto) {
|
||||||
|
await client.crypto.prepare(joinedRooms);
|
||||||
|
params.state.cryptoReady = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await params.state.client.startClient(startOpts);
|
|
||||||
await waitForMatrixSync({
|
await client.start();
|
||||||
client: params.state.client,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
});
|
|
||||||
params.state.started = true;
|
params.state.started = true;
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
@@ -249,6 +436,7 @@ export async function resolveSharedMatrixClient(
|
|||||||
state: sharedClientState,
|
state: sharedClientState,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return sharedClientState.client;
|
return sharedClientState.client;
|
||||||
@@ -262,11 +450,12 @@ export async function resolveSharedMatrixClient(
|
|||||||
state: pending,
|
state: pending,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return pending.client;
|
return pending.client;
|
||||||
}
|
}
|
||||||
pending.client.stopClient();
|
pending.client.stop();
|
||||||
sharedClientState = null;
|
sharedClientState = null;
|
||||||
sharedClientPromise = null;
|
sharedClientPromise = null;
|
||||||
}
|
}
|
||||||
@@ -283,6 +472,7 @@ export async function resolveSharedMatrixClient(
|
|||||||
state: created,
|
state: created,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return created.client;
|
return created.client;
|
||||||
@@ -291,48 +481,18 @@ export async function resolveSharedMatrixClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForMatrixSync(params: {
|
export async function waitForMatrixSync(_params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
|
// matrix-bot-sdk handles sync internally in start()
|
||||||
if (params.client.getSyncState() === SyncState.Syncing) return;
|
// This is kept for API compatibility but is essentially a no-op now
|
||||||
await new Promise<void>((resolve, reject) => {
|
}
|
||||||
let done = false;
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
export function stopSharedClient(): void {
|
||||||
const cleanup = () => {
|
if (sharedClientState) {
|
||||||
if (done) return;
|
sharedClientState.client.stop();
|
||||||
done = true;
|
sharedClientState = null;
|
||||||
params.client.removeListener(ClientEvent.Sync, onSync);
|
}
|
||||||
if (params.abortSignal) {
|
|
||||||
params.abortSignal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onSync = (state: SyncState) => {
|
|
||||||
if (done) return;
|
|
||||||
if (state === SyncState.Prepared || state === SyncState.Syncing) {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
if (state === SyncState.Error) {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("Matrix sync failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAbort = () => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("Matrix sync aborted"));
|
|
||||||
};
|
|
||||||
params.client.on(ClientEvent.Sync, onSync);
|
|
||||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("Matrix sync timed out"));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type MatrixStoredCredentials = {
|
|||||||
homeserver: string;
|
homeserver: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
deviceId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
};
|
};
|
||||||
@@ -94,5 +95,9 @@ export function credentialsMatchConfig(
|
|||||||
stored: MatrixStoredCredentials,
|
stored: MatrixStoredCredentials,
|
||||||
config: { homeserver: string; userId: string },
|
config: { homeserver: string; userId: string },
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// If userId is empty (token-based auth), only match homeserver
|
||||||
|
if (!config.userId) {
|
||||||
|
return stored.homeserver === config.homeserver;
|
||||||
|
}
|
||||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
|
||||||
|
|
||||||
export function isMatrixSdkAvailable(): boolean {
|
export function isMatrixSdkAvailable(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
|
|||||||
if (isMatrixSdkAvailable()) return;
|
if (isMatrixSdkAvailable()) return;
|
||||||
const confirm = params.confirm;
|
const confirm = params.confirm;
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
const ok = await confirm("Matrix requires matrix-js-sdk. Install now?");
|
const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new Error("Matrix requires matrix-js-sdk (install dependencies first).");
|
throw new Error("Matrix requires matrix-bot-sdk (install dependencies first).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isMatrixSdkAvailable()) {
|
if (!isMatrixSdkAvailable()) {
|
||||||
throw new Error("Matrix dependency install completed but matrix-js-sdk is still missing.");
|
throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { probeMatrix } from "./probe.js";
|
|||||||
export {
|
export {
|
||||||
reactMatrixMessage,
|
reactMatrixMessage,
|
||||||
resolveMatrixRoomId,
|
resolveMatrixRoomId,
|
||||||
|
sendReadReceiptMatrix,
|
||||||
sendMessageMatrix,
|
sendMessageMatrix,
|
||||||
sendPollMatrix,
|
sendPollMatrix,
|
||||||
sendTypingMatrix,
|
sendTypingMatrix,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
import { AutojoinRoomsMixin } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
@@ -19,25 +19,40 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||||
|
|
||||||
client.on(RoomMemberEvent.Membership, async (_event: MatrixEvent, member: RoomMember) => {
|
if (autoJoin === "off") {
|
||||||
if (member.userId !== client.getUserId()) return;
|
return;
|
||||||
if (member.membership !== "invite") return;
|
}
|
||||||
const roomId = member.roomId;
|
|
||||||
if (autoJoin === "off") return;
|
if (autoJoin === "always") {
|
||||||
if (autoJoin === "allowlist") {
|
// Use the built-in autojoin mixin for "always" mode
|
||||||
const invitedRoom = client.getRoom(roomId);
|
AutojoinRoomsMixin.setupOnClient(client);
|
||||||
const alias = invitedRoom?.getCanonicalAlias?.() ?? "";
|
logVerbose("matrix: auto-join enabled for all invites");
|
||||||
const altAliases = invitedRoom?.getAltAliases?.() ?? [];
|
return;
|
||||||
const allowed =
|
}
|
||||||
autoJoinAllowlist.includes("*") ||
|
|
||||||
autoJoinAllowlist.includes(roomId) ||
|
// For "allowlist" mode, handle invites manually
|
||||||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||||
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
if (autoJoin !== "allowlist") return;
|
||||||
if (!allowed) {
|
|
||||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
// Get room alias if available
|
||||||
return;
|
let alias: string | undefined;
|
||||||
}
|
try {
|
||||||
|
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
|
||||||
|
alias = aliasState?.alias;
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
autoJoinAllowlist.includes("*") ||
|
||||||
|
autoJoinAllowlist.includes(roomId) ||
|
||||||
|
(alias ? autoJoinAllowlist.includes(alias) : false);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.joinRoom(roomId);
|
await client.joinRoom(roomId);
|
||||||
logVerbose(`matrix: joined room ${roomId}`);
|
logVerbose(`matrix: joined room ${roomId}`);
|
||||||
|
|||||||
@@ -1,80 +1,105 @@
|
|||||||
import type {
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
AccountDataEvents,
|
|
||||||
MatrixClient,
|
|
||||||
MatrixEvent,
|
|
||||||
Room,
|
|
||||||
RoomMember,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { ClientEvent, EventType } from "matrix-js-sdk";
|
|
||||||
|
|
||||||
function hasDirectFlag(member?: RoomMember | null): boolean {
|
type DirectMessageCheck = {
|
||||||
if (!member?.events.member) return false;
|
roomId: string;
|
||||||
const content = member.events.member.getContent() as { is_direct?: boolean } | undefined;
|
senderId?: string;
|
||||||
if (content?.is_direct === true) return true;
|
selfUserId?: string;
|
||||||
const prev = member.events.member.getPrevContent() as { is_direct?: boolean } | undefined;
|
};
|
||||||
return prev?.is_direct === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLikelyDirectRoom(params: {
|
type DirectRoomTrackerOptions = {
|
||||||
room: Room;
|
log?: (message: string) => void;
|
||||||
senderId: string;
|
};
|
||||||
selfId?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
if (!params.selfId) return false;
|
|
||||||
const memberCount = params.room.getJoinedMemberCount?.();
|
|
||||||
if (typeof memberCount !== "number" || memberCount !== 2) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDirectRoomByFlag(params: {
|
const DM_CACHE_TTL_MS = 30_000;
|
||||||
room: Room;
|
|
||||||
senderId: string;
|
|
||||||
selfId?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
if (!params.selfId) return false;
|
|
||||||
const selfMember = params.room.getMember(params.selfId);
|
|
||||||
const senderMember = params.room.getMember(params.senderId);
|
|
||||||
if (hasDirectFlag(selfMember) || hasDirectFlag(senderMember)) return true;
|
|
||||||
const inviter = selfMember?.getDMInviter() ?? senderMember?.getDMInviter();
|
|
||||||
return Boolean(inviter);
|
|
||||||
}
|
|
||||||
|
|
||||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
export function createDirectRoomTracker(
|
||||||
|
client: MatrixClient,
|
||||||
|
opts: DirectRoomTrackerOptions = {},
|
||||||
|
) {
|
||||||
|
const log = opts.log ?? (() => {});
|
||||||
|
let lastDmUpdateMs = 0;
|
||||||
|
let cachedSelfUserId: string | null = null;
|
||||||
|
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||||
|
|
||||||
export function createDirectRoomTracker(client: MatrixClient) {
|
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||||
const directMap = new Map<string, Set<string>>();
|
if (cachedSelfUserId) return cachedSelfUserId;
|
||||||
|
try {
|
||||||
|
cachedSelfUserId = await client.getUserId();
|
||||||
|
} catch {
|
||||||
|
cachedSelfUserId = null;
|
||||||
|
}
|
||||||
|
return cachedSelfUserId;
|
||||||
|
};
|
||||||
|
|
||||||
const updateDirectMap = (content: MatrixDirectAccountData) => {
|
const refreshDmCache = async (): Promise<void> => {
|
||||||
directMap.clear();
|
const now = Date.now();
|
||||||
for (const [userId, rooms] of Object.entries(content)) {
|
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
|
||||||
if (!Array.isArray(rooms)) continue;
|
lastDmUpdateMs = now;
|
||||||
const ids = rooms.map((roomId) => String(roomId).trim()).filter(Boolean);
|
try {
|
||||||
if (ids.length === 0) continue;
|
await client.dms.update();
|
||||||
directMap.set(userId, new Set(ids));
|
} catch (err) {
|
||||||
|
log(`matrix: dm cache refresh failed (${String(err)})`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialDirect = client.getAccountData(EventType.Direct);
|
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
|
||||||
if (initialDirect) {
|
const cached = memberCountCache.get(roomId);
|
||||||
updateDirectMap(initialDirect.getContent<MatrixDirectAccountData>() ?? {});
|
const now = Date.now();
|
||||||
}
|
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
|
||||||
|
return cached.count;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const members = await client.getJoinedRoomMembers(roomId);
|
||||||
|
const count = members.length;
|
||||||
|
memberCountCache.set(roomId, { count, ts: now });
|
||||||
|
return count;
|
||||||
|
} catch (err) {
|
||||||
|
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
client.on(ClientEvent.AccountData, (event: MatrixEvent) => {
|
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
||||||
if (event.getType() !== EventType.Direct) return;
|
const target = userId?.trim();
|
||||||
updateDirectMap(event.getContent<MatrixDirectAccountData>() ?? {});
|
if (!target) return false;
|
||||||
});
|
try {
|
||||||
|
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
||||||
|
return state?.is_direct === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDirectMessage: (room: Room, senderId: string) => {
|
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
|
||||||
const roomId = room.roomId;
|
const { roomId, senderId } = params;
|
||||||
const directRooms = directMap.get(senderId);
|
await refreshDmCache();
|
||||||
const selfId = client.getUserId();
|
|
||||||
const isDirectByFlag = isDirectRoomByFlag({ room, senderId, selfId });
|
if (client.dms.isDm(roomId)) {
|
||||||
return (
|
log(`matrix: dm detected via m.direct room=${roomId}`);
|
||||||
Boolean(directRooms?.has(roomId)) ||
|
return true;
|
||||||
isDirectByFlag ||
|
}
|
||||||
isLikelyDirectRoom({ room, senderId, selfId })
|
|
||||||
|
const memberCount = await resolveMemberCount(roomId);
|
||||||
|
if (memberCount === 2) {
|
||||||
|
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
|
||||||
|
const directViaState =
|
||||||
|
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
|
||||||
|
if (directViaState) {
|
||||||
|
log(`matrix: dm detected via member state room=${roomId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
`matrix: dm check room=${roomId} result=group members=${
|
||||||
|
memberCount ?? "unknown"
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { MatrixEvent, Room } from "matrix-js-sdk";
|
import type {
|
||||||
import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
LocationMessageEventContent,
|
||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
MatrixClient,
|
||||||
|
MessageEventContent,
|
||||||
|
} from "matrix-bot-sdk";
|
||||||
|
import { format } from "node:util";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
|
formatLocationText,
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
|
toLocationContext,
|
||||||
|
type NormalizedLocation,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -15,6 +21,7 @@ import {
|
|||||||
isBunRuntime,
|
isBunRuntime,
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
|
stopSharedClient,
|
||||||
} from "../client.js";
|
} from "../client.js";
|
||||||
import {
|
import {
|
||||||
formatPollAsText,
|
formatPollAsText,
|
||||||
@@ -22,7 +29,12 @@ import {
|
|||||||
type PollStartContent,
|
type PollStartContent,
|
||||||
parsePollStartContent,
|
parsePollStartContent,
|
||||||
} from "../poll-types.js";
|
} from "../poll-types.js";
|
||||||
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
|
import {
|
||||||
|
reactMatrixMessage,
|
||||||
|
sendMessageMatrix,
|
||||||
|
sendReadReceiptMatrix,
|
||||||
|
sendTypingMatrix,
|
||||||
|
} from "../send.js";
|
||||||
import {
|
import {
|
||||||
resolveMatrixAllowListMatch,
|
resolveMatrixAllowListMatch,
|
||||||
resolveMatrixAllowListMatches,
|
resolveMatrixAllowListMatches,
|
||||||
@@ -38,6 +50,118 @@ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.
|
|||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
|
// Constants that were previously from matrix-js-sdk
|
||||||
|
const EventType = {
|
||||||
|
RoomMessage: "m.room.message",
|
||||||
|
RoomMessageEncrypted: "m.room.encrypted",
|
||||||
|
RoomMember: "m.room.member",
|
||||||
|
Location: "m.location",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const RelationType = {
|
||||||
|
Replace: "m.replace",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Type for raw Matrix events from matrix-bot-sdk
|
||||||
|
type MatrixRawEvent = {
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
type: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
unsigned?: {
|
||||||
|
age?: number;
|
||||||
|
redacted_because?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomMessageEventContent = MessageEventContent & {
|
||||||
|
url?: string;
|
||||||
|
info?: {
|
||||||
|
mimetype?: string;
|
||||||
|
};
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?: { event_id?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixLocationPayload = {
|
||||||
|
text: string;
|
||||||
|
context: ReturnType<typeof toLocationContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeoUriParams = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracy?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseGeoUri(value: string): GeoUriParams | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (!trimmed.toLowerCase().startsWith("geo:")) return null;
|
||||||
|
const payload = trimmed.slice(4);
|
||||||
|
const [coordsPart, ...paramParts] = payload.split(";");
|
||||||
|
const coords = coordsPart.split(",");
|
||||||
|
if (coords.length < 2) return null;
|
||||||
|
const latitude = Number.parseFloat(coords[0] ?? "");
|
||||||
|
const longitude = Number.parseFloat(coords[1] ?? "");
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
|
|
||||||
|
const params = new Map<string, string>();
|
||||||
|
for (const part of paramParts) {
|
||||||
|
const segment = part.trim();
|
||||||
|
if (!segment) continue;
|
||||||
|
const eqIndex = segment.indexOf("=");
|
||||||
|
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
||||||
|
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
||||||
|
const key = rawKey.trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
const valuePart = rawValue.trim();
|
||||||
|
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accuracyRaw = params.get("u");
|
||||||
|
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMatrixLocation(params: {
|
||||||
|
eventType: string;
|
||||||
|
content: LocationMessageEventContent;
|
||||||
|
}): MatrixLocationPayload | null {
|
||||||
|
const { eventType, content } = params;
|
||||||
|
const isLocation =
|
||||||
|
eventType === EventType.Location ||
|
||||||
|
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
||||||
|
if (!isLocation) return null;
|
||||||
|
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
||||||
|
if (!geoUri) return null;
|
||||||
|
const parsed = parseGeoUri(geoUri);
|
||||||
|
if (!parsed) return null;
|
||||||
|
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
||||||
|
const location: NormalizedLocation = {
|
||||||
|
latitude: parsed.latitude,
|
||||||
|
longitude: parsed.longitude,
|
||||||
|
accuracy: parsed.accuracy,
|
||||||
|
caption: caption || undefined,
|
||||||
|
source: "pin",
|
||||||
|
isLive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: formatLocationText(location),
|
||||||
|
context: toLocationContext(location),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type MonitorMatrixOpts = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@@ -56,13 +180,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
let cfg = core.config.loadConfig() as CoreConfig;
|
let cfg = core.config.loadConfig() as CoreConfig;
|
||||||
if (cfg.channels?.matrix?.enabled === false) return;
|
if (cfg.channels?.matrix?.enabled === false) return;
|
||||||
|
|
||||||
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
|
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
log: console.log,
|
log: (...args) => {
|
||||||
error: console.error,
|
logger.info(formatRuntimeMessage(...args));
|
||||||
|
},
|
||||||
|
error: (...args) => {
|
||||||
|
logger.error(formatRuntimeMessage(...args));
|
||||||
|
},
|
||||||
exit: (code: number): never => {
|
exit: (code: number): never => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
|
logger.debug(message);
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeUserEntry = (raw: string) =>
|
const normalizeUserEntry = (raw: string) =>
|
||||||
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
|
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
|
||||||
@@ -70,8 +204,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
|
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
|
||||||
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||||
|
|
||||||
|
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||||
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
||||||
let roomsConfig = cfg.channels?.matrix?.rooms;
|
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
|
||||||
|
|
||||||
if (allowFrom.length > 0) {
|
if (allowFrom.length > 0) {
|
||||||
const entries = allowFrom
|
const entries = allowFrom
|
||||||
@@ -163,7 +298,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
...cfg.channels?.matrix?.dm,
|
...cfg.channels?.matrix?.dm,
|
||||||
allowFrom,
|
allowFrom,
|
||||||
},
|
},
|
||||||
rooms: roomsConfig,
|
...(roomsConfig ? { groups: roomsConfig } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -185,13 +320,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
setActiveMatrixClient(client);
|
setActiveMatrixClient(client);
|
||||||
|
|
||||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
|
||||||
const logVerboseMessage = (message: string) => {
|
|
||||||
if (core.logging.shouldLogVerbose()) {
|
|
||||||
logger.debug(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
|
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
|
||||||
@@ -206,30 +334,75 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||||
const startupMs = Date.now();
|
const startupMs = Date.now();
|
||||||
const startupGraceMs = 0;
|
const startupGraceMs = 0;
|
||||||
const directTracker = createDirectRoomTracker(client);
|
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
|
||||||
registerMatrixAutoJoin({ client, cfg, runtime });
|
registerMatrixAutoJoin({ client, cfg, runtime });
|
||||||
|
const warnedEncryptedRooms = new Set<string>();
|
||||||
|
const warnedCryptoMissingRooms = new Set<string>();
|
||||||
|
|
||||||
const handleTimeline = async (
|
const roomInfoCache = new Map<
|
||||||
event: MatrixEvent,
|
string,
|
||||||
room: Room | undefined,
|
{ name?: string; canonicalAlias?: string; altAliases: string[] }
|
||||||
toStartOfTimeline?: boolean,
|
>();
|
||||||
|
|
||||||
|
// Helper to get room info
|
||||||
|
const getRoomInfo = async (roomId: string) => {
|
||||||
|
const cached = roomInfoCache.get(roomId);
|
||||||
|
if (cached) return cached;
|
||||||
|
let name: string | undefined;
|
||||||
|
let canonicalAlias: string | undefined;
|
||||||
|
let altAliases: string[] = [];
|
||||||
|
try {
|
||||||
|
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
|
||||||
|
name = nameState?.name;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
|
||||||
|
canonicalAlias = aliasState?.alias;
|
||||||
|
altAliases = aliasState?.alt_aliases ?? [];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const info = { name, canonicalAlias, altAliases };
|
||||||
|
roomInfoCache.set(roomId, info);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get member display name
|
||||||
|
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const memberState = await client.getRoomStateEvent(roomId, "m.room.member", userId).catch(() => null);
|
||||||
|
return memberState?.displayname ?? userId;
|
||||||
|
} catch {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomMessage = async (
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRawEvent,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
if (!room) return;
|
const eventType = event.type;
|
||||||
if (toStartOfTimeline) return;
|
if (eventType === EventType.RoomMessageEncrypted) {
|
||||||
if (event.getType() === EventType.RoomMessageEncrypted || event.isDecryptionFailure()) {
|
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = event.getType();
|
|
||||||
const isPollEvent = isPollStartType(eventType);
|
const isPollEvent = isPollStartType(eventType);
|
||||||
if (eventType !== EventType.RoomMessage && !isPollEvent) return;
|
const locationContent = event.content as LocationMessageEventContent;
|
||||||
if (event.isRedacted()) return;
|
const isLocationEvent =
|
||||||
const senderId = event.getSender();
|
eventType === EventType.Location ||
|
||||||
|
(eventType === EventType.RoomMessage &&
|
||||||
|
locationContent.msgtype === EventType.Location);
|
||||||
|
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
if (event.unsigned?.redacted_because) return;
|
||||||
|
const senderId = event.sender;
|
||||||
if (!senderId) return;
|
if (!senderId) return;
|
||||||
if (senderId === client.getUserId()) return;
|
const selfUserId = await client.getUserId();
|
||||||
const eventTs = event.getTs();
|
if (senderId === selfUserId) return;
|
||||||
const eventAge = event.getAge();
|
const eventTs = event.origin_server_ts;
|
||||||
|
const eventAge = event.unsigned?.age;
|
||||||
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,15 +414,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = event.getContent<RoomMessageEventContent>();
|
const roomInfo = await getRoomInfo(roomId);
|
||||||
|
const roomName = roomInfo.name;
|
||||||
|
const roomAliases = [
|
||||||
|
roomInfo.canonicalAlias ?? "",
|
||||||
|
...roomInfo.altAliases,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
let content = event.content as RoomMessageEventContent;
|
||||||
if (isPollEvent) {
|
if (isPollEvent) {
|
||||||
const pollStartContent = event.getContent<PollStartContent>();
|
const pollStartContent = event.content as PollStartContent;
|
||||||
const pollSummary = parsePollStartContent(pollStartContent);
|
const pollSummary = parsePollStartContent(pollStartContent);
|
||||||
if (pollSummary) {
|
if (pollSummary) {
|
||||||
pollSummary.eventId = event.getId() ?? "";
|
pollSummary.eventId = event.event_id ?? "";
|
||||||
pollSummary.roomId = room.roomId;
|
pollSummary.roomId = roomId;
|
||||||
pollSummary.sender = senderId;
|
pollSummary.sender = senderId;
|
||||||
pollSummary.senderName = room.getMember(senderId)?.name ?? senderId;
|
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
|
||||||
|
pollSummary.senderName = senderDisplayName;
|
||||||
const pollText = formatPollAsText(pollSummary);
|
const pollText = formatPollAsText(pollSummary);
|
||||||
content = {
|
content = {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
@@ -260,50 +441,64 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locationPayload = resolveMatrixLocation({
|
||||||
|
eventType,
|
||||||
|
content: content as LocationMessageEventContent,
|
||||||
|
});
|
||||||
|
|
||||||
const relates = content["m.relates_to"];
|
const relates = content["m.relates_to"];
|
||||||
if (relates && "rel_type" in relates) {
|
if (relates && "rel_type" in relates) {
|
||||||
if (relates.rel_type === RelationType.Replace) return;
|
if (relates.rel_type === RelationType.Replace) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = room.roomId;
|
const isDirectMessage = await directTracker.isDirectMessage({
|
||||||
const isDirectMessage = directTracker.isDirectMessage(room, senderId);
|
roomId,
|
||||||
|
senderId,
|
||||||
|
selfUserId,
|
||||||
|
});
|
||||||
const isRoom = !isDirectMessage;
|
const isRoom = !isDirectMessage;
|
||||||
|
|
||||||
if (!isDirectMessage && groupPolicy === "disabled") return;
|
if (isRoom && groupPolicy === "disabled") return;
|
||||||
|
|
||||||
const roomAliases = [
|
const roomConfigInfo = isRoom
|
||||||
room.getCanonicalAlias?.() ?? "",
|
? resolveMatrixRoomConfig({
|
||||||
...(room.getAltAliases?.() ?? []),
|
rooms: roomsConfig,
|
||||||
].filter(Boolean);
|
roomId,
|
||||||
const roomName = room.name ?? undefined;
|
aliases: roomAliases,
|
||||||
const roomConfigInfo = resolveMatrixRoomConfig({
|
name: roomName,
|
||||||
rooms: cfg.channels?.matrix?.rooms,
|
})
|
||||||
roomId,
|
: undefined;
|
||||||
aliases: roomAliases,
|
const roomConfig = roomConfigInfo?.config;
|
||||||
name: roomName,
|
const roomMatchMeta = roomConfigInfo
|
||||||
});
|
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
||||||
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
roomConfigInfo.matchSource ?? "none"
|
||||||
roomConfigInfo.matchSource ?? "none"
|
}`
|
||||||
}`;
|
: "matchKey=none matchSource=none";
|
||||||
|
|
||||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
|
||||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (isRoom && groupPolicy === "allowlist") {
|
||||||
if (!roomConfigInfo.allowlistConfigured) {
|
if (!roomConfigInfo?.allowlistConfigured) {
|
||||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roomConfigInfo.config) {
|
if (!roomConfig) {
|
||||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName = room.getMember(senderId)?.name ?? senderId;
|
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||||
|
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||||
|
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
||||||
|
...groupAllowFrom,
|
||||||
|
...storeAllowFrom,
|
||||||
|
]);
|
||||||
|
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
if (!dmEnabled || dmPolicy === "disabled") return;
|
if (!dmEnabled || dmPolicy === "disabled") return;
|
||||||
@@ -353,9 +548,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRoom && roomConfigInfo.config?.users?.length) {
|
const roomUsers = roomConfig?.users ?? [];
|
||||||
|
if (isRoom && roomUsers.length > 0) {
|
||||||
const userMatch = resolveMatrixAllowListMatch({
|
const userMatch = resolveMatrixAllowListMatch({
|
||||||
allowList: normalizeAllowListLower(roomConfigInfo.config.users),
|
allowList: normalizeAllowListLower(roomUsers),
|
||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
@@ -368,11 +564,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
|
||||||
|
const groupAllowMatch = resolveMatrixAllowListMatch({
|
||||||
|
allowList: effectiveGroupAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
});
|
||||||
|
if (!groupAllowMatch.allowed) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||||
|
groupAllowMatch,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isRoom) {
|
if (isRoom) {
|
||||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBody = content.body.trim();
|
const rawBody = locationPayload?.text
|
||||||
|
?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||||
let media: {
|
let media: {
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -406,7 +618,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
|
|
||||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||||
content,
|
content,
|
||||||
userId: client.getUserId(),
|
userId: selfUserId,
|
||||||
text: bodyText,
|
text: bodyText,
|
||||||
mentionRegexes,
|
mentionRegexes,
|
||||||
});
|
});
|
||||||
@@ -420,10 +632,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
});
|
});
|
||||||
|
const senderAllowedForGroup = groupAllowConfigured
|
||||||
|
? resolveMatrixAllowListMatches({
|
||||||
|
allowList: effectiveGroupAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
const senderAllowedForRoomUsers =
|
||||||
|
isRoom && roomUsers.length > 0
|
||||||
|
? resolveMatrixAllowListMatches({
|
||||||
|
allowList: normalizeAllowListLower(roomUsers),
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
|
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
||||||
|
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
@@ -436,12 +665,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldRequireMention = isRoom
|
const shouldRequireMention = isRoom
|
||||||
? roomConfigInfo.config?.autoReply === true
|
? roomConfig?.autoReply === true
|
||||||
? false
|
? false
|
||||||
: roomConfigInfo.config?.autoReply === false
|
: roomConfig?.autoReply === false
|
||||||
? true
|
? true
|
||||||
: typeof roomConfigInfo.config?.requireMention === "boolean"
|
: typeof roomConfig?.requireMention === "boolean"
|
||||||
? roomConfigInfo.config.requireMention
|
? roomConfig?.requireMention
|
||||||
: true
|
: true
|
||||||
: false;
|
: false;
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
@@ -457,13 +686,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = event.getId() ?? "";
|
const messageId = event.event_id ?? "";
|
||||||
|
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
|
||||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||||
const threadTarget = resolveMatrixThreadTarget({
|
const threadTarget = resolveMatrixThreadTarget({
|
||||||
threadReplies,
|
threadReplies,
|
||||||
messageId,
|
messageId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
isThreadRoot: event.isThreadRoot,
|
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
@@ -484,16 +714,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
storePath,
|
storePath,
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
});
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: eventTs ?? undefined,
|
||||||
previousTimestamp,
|
previousTimestamp,
|
||||||
envelope: envelopeOptions,
|
envelope: envelopeOptions,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: body,
|
Body: body,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
@@ -508,18 +738,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
SenderId: senderId,
|
SenderId: senderId,
|
||||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||||
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
|
||||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||||
Provider: "matrix" as const,
|
Provider: "matrix" as const,
|
||||||
Surface: "matrix" as const,
|
Surface: "matrix" as const,
|
||||||
WasMentioned: isRoom ? wasMentioned : undefined,
|
WasMentioned: isRoom ? wasMentioned : undefined,
|
||||||
MessageSid: messageId,
|
MessageSid: messageId,
|
||||||
ReplyToId: threadTarget ? undefined : (event.replyEventId ?? undefined),
|
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
||||||
MessageThreadId: threadTarget,
|
MessageThreadId: threadTarget,
|
||||||
Timestamp: event.getTs() ?? undefined,
|
Timestamp: eventTs ?? undefined,
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
|
...(locationPayload?.context ?? {}),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "text" as const,
|
CommandSource: "text" as const,
|
||||||
OriginatingChannel: "matrix" as const,
|
OriginatingChannel: "matrix" as const,
|
||||||
@@ -577,6 +808,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
@@ -606,7 +845,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions: {
|
replyOptions: {
|
||||||
...replyOptions,
|
...replyOptions,
|
||||||
skillFilter: roomConfigInfo.config?.skills,
|
skillFilter: roomConfig?.skills,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
@@ -628,15 +867,100 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
client.on(RoomEvent.Timeline, handleTimeline);
|
// matrix-bot-sdk uses on("room.message", handler)
|
||||||
|
client.on("room.message", handleRoomMessage);
|
||||||
|
|
||||||
await resolveSharedMatrixClient({ cfg, auth: authWithLimit, startClient: true });
|
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
runtime.log?.(`matrix: logged in as ${auth.userId}`);
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle failed E2EE decryption
|
||||||
|
client.on("room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||||
|
logger.warn({ roomId, eventId: event.event_id, error: error.message }, "Failed to decrypt message");
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const sender = event?.sender ?? "unknown";
|
||||||
|
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
if (eventType === EventType.RoomMessageEncrypted) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
|
||||||
|
warnedEncryptedRooms.add(roomId);
|
||||||
|
const warning =
|
||||||
|
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
|
||||||
|
logger.warn({ roomId }, warning);
|
||||||
|
}
|
||||||
|
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||||
|
warnedCryptoMissingRooms.add(roomId);
|
||||||
|
const warning =
|
||||||
|
"matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart";
|
||||||
|
logger.warn({ roomId }, warning);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventType === EventType.RoomMember) {
|
||||||
|
const membership = (event?.content as { membership?: string } | undefined)?.membership;
|
||||||
|
const stateKey = (event as { state_key?: string }).state_key ?? "";
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logVerboseMessage("matrix: starting client");
|
||||||
|
await resolveSharedMatrixClient({
|
||||||
|
cfg,
|
||||||
|
auth: authWithLimit,
|
||||||
|
});
|
||||||
|
logVerboseMessage("matrix: client started");
|
||||||
|
|
||||||
|
// matrix-bot-sdk client is already started via resolveSharedMatrixClient
|
||||||
|
logger.info(`matrix: logged in as ${auth.userId}`);
|
||||||
|
|
||||||
|
// If E2EE is enabled, trigger device verification
|
||||||
|
if (auth.encryption && client.crypto) {
|
||||||
|
try {
|
||||||
|
// Request verification from other sessions
|
||||||
|
const verificationRequest = await client.crypto.requestOwnUserVerification();
|
||||||
|
if (verificationRequest) {
|
||||||
|
logger.info("matrix: device verification requested - please verify in another client");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
try {
|
try {
|
||||||
client.stopClient();
|
logVerboseMessage("matrix: stopping client");
|
||||||
|
stopSharedClient();
|
||||||
} finally {
|
} finally {
|
||||||
setActiveMatrixClient(null);
|
setActiveMatrixClient(null);
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,35 +1,68 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
|
// Type for encrypted file info
|
||||||
|
type EncryptedFile = {
|
||||||
|
url: string;
|
||||||
|
key: {
|
||||||
|
kty: string;
|
||||||
|
key_ops: string[];
|
||||||
|
alg: string;
|
||||||
|
k: string;
|
||||||
|
ext: boolean;
|
||||||
|
};
|
||||||
|
iv: string;
|
||||||
|
hashes: Record<string, string>;
|
||||||
|
v: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchMatrixMediaBuffer(params: {
|
async function fetchMatrixMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
||||||
const url = params.client.mxcUrlToHttp(
|
// matrix-bot-sdk provides mxcToHttp helper
|
||||||
params.mxcUrl,
|
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const token = params.client.getAccessToken();
|
|
||||||
const res = await fetch(url, {
|
// Use the client's download method which handles auth
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
try {
|
||||||
});
|
const buffer = await params.client.downloadContent(params.mxcUrl);
|
||||||
if (!res.ok) {
|
if (buffer.byteLength > params.maxBytes) {
|
||||||
throw new Error(`Matrix media download failed: HTTP ${res.status}`);
|
throw new Error("Matrix media exceeds configured size limit");
|
||||||
|
}
|
||||||
|
return { buffer: Buffer.from(buffer) };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Matrix media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
}
|
||||||
if (buffer.byteLength > params.maxBytes) {
|
|
||||||
|
/**
|
||||||
|
* Download and decrypt encrypted media from a Matrix room.
|
||||||
|
*/
|
||||||
|
async function fetchEncryptedMediaBuffer(params: {
|
||||||
|
client: MatrixClient;
|
||||||
|
file: EncryptedFile;
|
||||||
|
maxBytes: number;
|
||||||
|
}): Promise<{ buffer: Buffer } | null> {
|
||||||
|
if (!params.client.crypto) {
|
||||||
|
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the encrypted content
|
||||||
|
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
||||||
|
if (encryptedBuffer.byteLength > params.maxBytes) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
throw new Error("Matrix media exceeds configured size limit");
|
||||||
}
|
}
|
||||||
const headerType = res.headers.get("content-type") ?? undefined;
|
|
||||||
return { buffer, headerType };
|
// Decrypt using matrix-bot-sdk crypto
|
||||||
|
const decrypted = await params.client.crypto.decryptMedia(
|
||||||
|
Buffer.from(encryptedBuffer),
|
||||||
|
params.file,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { buffer: decrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadMatrixMedia(params: {
|
export async function downloadMatrixMedia(params: {
|
||||||
@@ -37,16 +70,30 @@ export async function downloadMatrixMedia(params: {
|
|||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
|
file?: EncryptedFile;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const fetched = await fetchMatrixMediaBuffer({
|
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||||
client: params.client,
|
|
||||||
mxcUrl: params.mxcUrl,
|
if (params.file) {
|
||||||
maxBytes: params.maxBytes,
|
// Encrypted media
|
||||||
});
|
fetched = await fetchEncryptedMediaBuffer({
|
||||||
|
client: params.client,
|
||||||
|
file: params.file,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unencrypted media
|
||||||
|
fetched = await fetchMatrixMediaBuffer({
|
||||||
|
client: params.client,
|
||||||
|
mxcUrl: params.mxcUrl,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!fetched) return null;
|
if (!fetched) return null;
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
|
||||||
|
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
|
// Type for room message content with mentions
|
||||||
|
type MessageContentWithMentions = {
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
"m.mentions"?: {
|
||||||
|
user_ids?: string[];
|
||||||
|
room?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveMentions(params: {
|
export function resolveMentions(params: {
|
||||||
content: RoomMessageEventContent;
|
content: MessageContentWithMentions;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
mentionRegexes: RegExp[];
|
mentionRegexes: RegExp[];
|
||||||
}) {
|
}) {
|
||||||
const mentions = params.content["m.mentions"] as
|
const mentions = params.content["m.mentions"];
|
||||||
| { user_ids?: string[]; room?: boolean }
|
|
||||||
| undefined;
|
|
||||||
const mentionedUsers = Array.isArray(mentions?.user_ids)
|
const mentionedUsers = Array.isArray(mentions?.user_ids)
|
||||||
? new Set(mentions.user_ids)
|
? new Set(mentions.user_ids)
|
||||||
: new Set<string>();
|
: new Set<string>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MatrixClient } from "matrix-js-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
|
import type { MatrixRoomConfig } from "../../types.js";
|
||||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "clawdbot/plugin-sdk";
|
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
export type MatrixRoomConfigResolved = {
|
export type MatrixRoomConfigResolved = {
|
||||||
@@ -10,7 +10,7 @@ export type MatrixRoomConfigResolved = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function resolveMatrixRoomConfig(params: {
|
export function resolveMatrixRoomConfig(params: {
|
||||||
rooms?: MatrixConfig["rooms"];
|
rooms?: Record<string, MatrixRoomConfig>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import type { MatrixEvent } from "matrix-js-sdk";
|
// Type for raw Matrix event from matrix-bot-sdk
|
||||||
import { RelationType } from "matrix-js-sdk";
|
type MatrixRawEvent = {
|
||||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
type: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomMessageEventContent = {
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?: { event_id?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const RelationType = {
|
||||||
|
Thread: "m.thread",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function resolveMatrixThreadTarget(params: {
|
export function resolveMatrixThreadTarget(params: {
|
||||||
threadReplies: "off" | "inbound" | "always";
|
threadReplies: "off" | "inbound" | "always";
|
||||||
@@ -22,13 +41,9 @@ export function resolveMatrixThreadTarget(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatrixThreadRootId(params: {
|
export function resolveMatrixThreadRootId(params: {
|
||||||
event: MatrixEvent;
|
event: MatrixRawEvent;
|
||||||
content: RoomMessageEventContent;
|
content: RoomMessageEventContent;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const fromThread = params.event.getThread?.()?.id;
|
|
||||||
if (fromThread) return fromThread;
|
|
||||||
const direct = params.event.threadRootId ?? undefined;
|
|
||||||
if (direct) return direct;
|
|
||||||
const relates = params.content["m.relates_to"];
|
const relates = params.content["m.relates_to"];
|
||||||
if (!relates || typeof relates !== "object") return undefined;
|
if (!relates || typeof relates !== "object") return undefined;
|
||||||
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
* - m.poll.end - Closes a poll
|
* - m.poll.end - Closes a poll
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
|
|
||||||
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
|
|
||||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
export const M_POLL_START = "m.poll.start" as const;
|
export const M_POLL_START = "m.poll.start" as const;
|
||||||
@@ -34,7 +32,9 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
|
|||||||
|
|
||||||
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
||||||
|
|
||||||
export type TextContent = ExtensibleAnyMessageEventContent & {
|
export type TextContent = {
|
||||||
|
"m.text"?: string;
|
||||||
|
"org.matrix.msc1767.text"?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +53,13 @@ export type LegacyPollStartContent = {
|
|||||||
"m.poll"?: PollStartSubtype;
|
"m.poll"?: PollStartSubtype;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
|
export type PollStartContent = {
|
||||||
|
[M_POLL_START]?: PollStartSubtype;
|
||||||
|
[ORG_POLL_START]?: PollStartSubtype;
|
||||||
|
"m.poll"?: PollStartSubtype;
|
||||||
|
"m.text"?: string;
|
||||||
|
"org.matrix.msc1767.text"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PollSummary = {
|
export type PollSummary = {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ export async function probeMatrix(params: {
|
|||||||
accessToken: params.accessToken,
|
accessToken: params.accessToken,
|
||||||
localTimeoutMs: params.timeoutMs,
|
localTimeoutMs: params.timeoutMs,
|
||||||
});
|
});
|
||||||
const res = await client.whoami();
|
// matrix-bot-sdk uses getUserId() which calls whoami internally
|
||||||
|
const userId = await client.getUserId();
|
||||||
result.ok = true;
|
result.ok = true;
|
||||||
result.userId = res.user_id ?? null;
|
result.userId = userId ?? null;
|
||||||
|
|
||||||
result.elapsedMs = Date.now() - started;
|
result.elapsedMs = Date.now() - started;
|
||||||
return result;
|
return result;
|
||||||
@@ -59,8 +60,8 @@ export async function probeMatrix(params: {
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
status:
|
status:
|
||||||
typeof err === "object" && err && "httpStatus" in err
|
typeof err === "object" && err && "statusCode" in err
|
||||||
? Number((err as { httpStatus?: number }).httpStatus)
|
? Number((err as { statusCode?: number }).statusCode)
|
||||||
: result.status,
|
: result.status,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
elapsedMs: Date.now() - started,
|
elapsedMs: Date.now() - started,
|
||||||
|
|||||||
@@ -3,22 +3,20 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
import { setMatrixRuntime } from "../runtime.js";
|
import { setMatrixRuntime } from "../runtime.js";
|
||||||
|
|
||||||
vi.mock("matrix-js-sdk", () => ({
|
vi.mock("matrix-bot-sdk", () => ({
|
||||||
EventType: {
|
ConsoleLogger: class {
|
||||||
Direct: "m.direct",
|
trace = vi.fn();
|
||||||
RoomMessage: "m.room.message",
|
debug = vi.fn();
|
||||||
Reaction: "m.reaction",
|
info = vi.fn();
|
||||||
|
warn = vi.fn();
|
||||||
|
error = vi.fn();
|
||||||
},
|
},
|
||||||
MsgType: {
|
LogService: {
|
||||||
Text: "m.text",
|
setLogger: vi.fn(),
|
||||||
File: "m.file",
|
|
||||||
Image: "m.image",
|
|
||||||
Audio: "m.audio",
|
|
||||||
Video: "m.video",
|
|
||||||
},
|
|
||||||
RelationType: {
|
|
||||||
Annotation: "m.annotation",
|
|
||||||
},
|
},
|
||||||
|
MatrixClient: vi.fn(),
|
||||||
|
SimpleFsStorageProvider: vi.fn(),
|
||||||
|
RustSdkCryptoStorageProvider: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||||
@@ -52,14 +50,13 @@ const runtimeStub = {
|
|||||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||||
|
|
||||||
const makeClient = () => {
|
const makeClient = () => {
|
||||||
const sendMessage = vi.fn().mockResolvedValue({ event_id: "evt1" });
|
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
||||||
const uploadContent = vi.fn().mockResolvedValue({
|
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
|
||||||
content_uri: "mxc://example/file",
|
|
||||||
});
|
|
||||||
const client = {
|
const client = {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
uploadContent,
|
uploadContent,
|
||||||
} as unknown as import("matrix-js-sdk").MatrixClient;
|
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||||
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
return { client, sendMessage, uploadContent };
|
return { client, sendMessage, uploadContent };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,4 +93,41 @@ describe("sendMessageMatrix media", () => {
|
|||||||
expect(content.formatted_body).toContain("caption");
|
expect(content.formatted_body).toContain("caption");
|
||||||
expect(content.url).toBe("mxc://example/file");
|
expect(content.url).toBe("mxc://example/file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uploads encrypted media with file payloads", async () => {
|
||||||
|
const { client, sendMessage, uploadContent } = makeClient();
|
||||||
|
(client as { crypto?: object }).crypto = {
|
||||||
|
isRoomEncrypted: vi.fn().mockResolvedValue(true),
|
||||||
|
encryptMedia: vi.fn().mockResolvedValue({
|
||||||
|
buffer: Buffer.from("encrypted"),
|
||||||
|
file: {
|
||||||
|
key: {
|
||||||
|
kty: "oct",
|
||||||
|
key_ops: ["encrypt", "decrypt"],
|
||||||
|
alg: "A256CTR",
|
||||||
|
k: "secret",
|
||||||
|
ext: true,
|
||||||
|
},
|
||||||
|
iv: "iv",
|
||||||
|
hashes: { sha256: "hash" },
|
||||||
|
v: "v2",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendMessageMatrix("room:!room:example", "caption", {
|
||||||
|
client,
|
||||||
|
mediaUrl: "file:///tmp/photo.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
|
||||||
|
expect(uploadArg?.toString()).toBe("encrypted");
|
||||||
|
|
||||||
|
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||||
|
url?: string;
|
||||||
|
file?: { url?: string };
|
||||||
|
};
|
||||||
|
expect(content.url).toBeUndefined();
|
||||||
|
expect(content.file?.url).toBe("mxc://example/file");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
|
|
||||||
import type {
|
import type {
|
||||||
RoomMessageEventContent,
|
DimensionalFileInfo,
|
||||||
ReactionEventContent,
|
EncryptedFile,
|
||||||
} from "matrix-js-sdk/lib/@types/events.js";
|
FileWithThumbnailInfo,
|
||||||
|
MessageEventContent,
|
||||||
|
TextualMessageEventContent,
|
||||||
|
TimedFileInfo,
|
||||||
|
VideoFileInfo,
|
||||||
|
MatrixClient,
|
||||||
|
} from "matrix-bot-sdk";
|
||||||
|
import { parseBuffer, type IFileInfo } from "music-metadata";
|
||||||
|
|
||||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
import { getMatrixRuntime } from "../runtime.js";
|
||||||
@@ -13,7 +18,6 @@ import {
|
|||||||
isBunRuntime,
|
isBunRuntime,
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
waitForMatrixSync,
|
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import { markdownToMatrixHtml } from "./format.js";
|
import { markdownToMatrixHtml } from "./format.js";
|
||||||
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||||
@@ -22,18 +26,63 @@ import type { CoreConfig } from "../types.js";
|
|||||||
const MATRIX_TEXT_LIMIT = 4000;
|
const MATRIX_TEXT_LIMIT = 4000;
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
// Message types
|
||||||
|
const MsgType = {
|
||||||
|
Text: "m.text",
|
||||||
|
Image: "m.image",
|
||||||
|
Audio: "m.audio",
|
||||||
|
Video: "m.video",
|
||||||
|
File: "m.file",
|
||||||
|
Notice: "m.notice",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Relation types
|
||||||
|
const RelationType = {
|
||||||
|
Annotation: "m.annotation",
|
||||||
|
Replace: "m.replace",
|
||||||
|
Thread: "m.thread",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Event types
|
||||||
|
const EventType = {
|
||||||
|
Direct: "m.direct",
|
||||||
|
Reaction: "m.reaction",
|
||||||
|
RoomMessage: "m.room.message",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type MatrixDirectAccountData = Record<string, string[]>;
|
||||||
|
|
||||||
type MatrixReplyRelation = {
|
type MatrixReplyRelation = {
|
||||||
"m.in_reply_to": { event_id: string };
|
"m.in_reply_to": { event_id: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type MatrixMessageContent = Record<string, unknown> & {
|
type MatrixReplyMeta = {
|
||||||
msgtype: MsgType;
|
"m.relates_to"?: MatrixReplyRelation;
|
||||||
body: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type MatrixUploadContent = Parameters<MatrixClient["uploadContent"]>[0];
|
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
|
||||||
|
|
||||||
|
type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta;
|
||||||
|
|
||||||
|
type MatrixMediaContent = MessageEventContent &
|
||||||
|
MatrixReplyMeta & {
|
||||||
|
info?: MatrixMediaInfo;
|
||||||
|
url?: string;
|
||||||
|
file?: EncryptedFile;
|
||||||
|
filename?: string;
|
||||||
|
"org.matrix.msc3245.voice"?: Record<string, never>;
|
||||||
|
"org.matrix.msc1767.audio"?: { duration: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
|
||||||
|
|
||||||
|
type ReactionEventContent = {
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: typeof RelationType.Annotation;
|
||||||
|
event_id: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MatrixSendResult = {
|
export type MatrixSendResult = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -83,13 +132,14 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
|||||||
if (!trimmed.startsWith("@")) {
|
if (!trimmed.startsWith("@")) {
|
||||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||||
}
|
}
|
||||||
const directEvent = client.getAccountData(EventType.Direct);
|
// matrix-bot-sdk: use getAccountData to retrieve m.direct
|
||||||
const directContent = directEvent?.getContent<MatrixDirectAccountData>();
|
try {
|
||||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||||
if (list.length > 0) return list[0];
|
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||||
const server = await client.getAccountDataFromServer(EventType.Direct);
|
if (list.length > 0) return list[0];
|
||||||
const serverList = Array.isArray(server?.[trimmed]) ? server[trimmed] : [];
|
} catch {
|
||||||
if (serverList.length > 0) return serverList[0];
|
// Ignore errors, try fetching from server
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
||||||
);
|
);
|
||||||
@@ -117,75 +167,116 @@ export async function resolveMatrixRoomId(
|
|||||||
return await resolveDirectRoomId(client, target);
|
return await resolveDirectRoomId(client, target);
|
||||||
}
|
}
|
||||||
if (target.startsWith("#")) {
|
if (target.startsWith("#")) {
|
||||||
const resolved = await client.getRoomIdForAlias(target);
|
const resolved = await client.resolveRoom(target);
|
||||||
if (!resolved?.room_id) {
|
if (!resolved) {
|
||||||
throw new Error(`Matrix alias ${target} could not be resolved`);
|
throw new Error(`Matrix alias ${target} could not be resolved`);
|
||||||
}
|
}
|
||||||
return resolved.room_id;
|
return resolved;
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixImageInfo = {
|
type MatrixMediaMsgType =
|
||||||
w?: number;
|
| typeof MsgType.Image
|
||||||
h?: number;
|
| typeof MsgType.Audio
|
||||||
thumbnail_url?: string;
|
| typeof MsgType.Video
|
||||||
thumbnail_info?: {
|
| typeof MsgType.File;
|
||||||
w: number;
|
|
||||||
h: number;
|
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||||
mimetype: string;
|
|
||||||
size: number;
|
function buildMatrixMediaInfo(params: {
|
||||||
};
|
size: number;
|
||||||
|
mimetype?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
imageInfo?: DimensionalFileInfo;
|
||||||
|
}): MatrixMediaInfo | undefined {
|
||||||
|
const base: FileWithThumbnailInfo = {};
|
||||||
|
if (Number.isFinite(params.size)) {
|
||||||
|
base.size = params.size;
|
||||||
|
}
|
||||||
|
if (params.mimetype) {
|
||||||
|
base.mimetype = params.mimetype;
|
||||||
|
}
|
||||||
|
if (params.imageInfo) {
|
||||||
|
const dimensional: DimensionalFileInfo = {
|
||||||
|
...base,
|
||||||
|
...params.imageInfo,
|
||||||
|
};
|
||||||
|
if (typeof params.durationMs === "number") {
|
||||||
|
const videoInfo: VideoFileInfo = {
|
||||||
|
...dimensional,
|
||||||
|
duration: params.durationMs,
|
||||||
|
};
|
||||||
|
return videoInfo;
|
||||||
|
}
|
||||||
|
return dimensional;
|
||||||
|
}
|
||||||
|
if (typeof params.durationMs === "number") {
|
||||||
|
const timedInfo: TimedFileInfo = {
|
||||||
|
...base,
|
||||||
|
duration: params.durationMs,
|
||||||
|
};
|
||||||
|
return timedInfo;
|
||||||
|
}
|
||||||
|
if (Object.keys(base).length === 0) return undefined;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatrixFormattedContent = MessageEventContent & {
|
||||||
|
format?: string;
|
||||||
|
formatted_body?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMediaContent(params: {
|
function buildMediaContent(params: {
|
||||||
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
|
msgtype: MatrixMediaMsgType;
|
||||||
body: string;
|
body: string;
|
||||||
url: string;
|
url?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
size: number;
|
size: number;
|
||||||
relation?: MatrixReplyRelation;
|
relation?: MatrixReplyRelation;
|
||||||
isVoice?: boolean;
|
isVoice?: boolean;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
imageInfo?: MatrixImageInfo;
|
imageInfo?: DimensionalFileInfo;
|
||||||
}): RoomMessageEventContent {
|
file?: EncryptedFile; // For encrypted media
|
||||||
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
|
}): MatrixMediaContent {
|
||||||
if (params.durationMs !== undefined) {
|
const info = buildMatrixMediaInfo({
|
||||||
info.duration = params.durationMs;
|
size: params.size,
|
||||||
}
|
mimetype: params.mimetype,
|
||||||
if (params.imageInfo) {
|
durationMs: params.durationMs,
|
||||||
if (params.imageInfo.w) info.w = params.imageInfo.w;
|
imageInfo: params.imageInfo,
|
||||||
if (params.imageInfo.h) info.h = params.imageInfo.h;
|
});
|
||||||
if (params.imageInfo.thumbnail_url) {
|
const base: MatrixMediaContent = {
|
||||||
info.thumbnail_url = params.imageInfo.thumbnail_url;
|
|
||||||
if (params.imageInfo.thumbnail_info) {
|
|
||||||
info.thumbnail_info = params.imageInfo.thumbnail_info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const base: MatrixMessageContent = {
|
|
||||||
msgtype: params.msgtype,
|
msgtype: params.msgtype,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
filename: params.filename,
|
filename: params.filename,
|
||||||
info,
|
info: info ?? undefined,
|
||||||
url: params.url,
|
|
||||||
};
|
};
|
||||||
|
// Encrypted media should only include the "file" payload, not top-level "url".
|
||||||
|
if (!params.file && params.url) {
|
||||||
|
base.url = params.url;
|
||||||
|
}
|
||||||
|
// For encrypted files, add the file object
|
||||||
|
if (params.file) {
|
||||||
|
base.file = params.file;
|
||||||
|
}
|
||||||
if (params.isVoice) {
|
if (params.isVoice) {
|
||||||
base["org.matrix.msc3245.voice"] = {};
|
base["org.matrix.msc3245.voice"] = {};
|
||||||
base["org.matrix.msc1767.audio"] = {
|
if (typeof params.durationMs === "number") {
|
||||||
duration: params.durationMs,
|
base["org.matrix.msc1767.audio"] = {
|
||||||
};
|
duration: params.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (params.relation) {
|
if (params.relation) {
|
||||||
base["m.relates_to"] = params.relation;
|
base["m.relates_to"] = params.relation;
|
||||||
}
|
}
|
||||||
applyMatrixFormatting(base, params.body);
|
applyMatrixFormatting(base, params.body);
|
||||||
return base as RoomMessageEventContent;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMessageEventContent {
|
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
|
||||||
const content: MatrixMessageContent = relation
|
const content: MatrixTextContent = relation
|
||||||
? {
|
? {
|
||||||
msgtype: MsgType.Text,
|
msgtype: MsgType.Text,
|
||||||
body,
|
body,
|
||||||
@@ -196,10 +287,10 @@ function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMes
|
|||||||
body,
|
body,
|
||||||
};
|
};
|
||||||
applyMatrixFormatting(content, body);
|
applyMatrixFormatting(content, body);
|
||||||
return content as RoomMessageEventContent;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMatrixFormatting(content: MatrixMessageContent, body: string): void {
|
function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
||||||
const formatted = markdownToMatrixHtml(body ?? "");
|
const formatted = markdownToMatrixHtml(body ?? "");
|
||||||
if (!formatted) return;
|
if (!formatted) return;
|
||||||
content.format = "org.matrix.custom.html";
|
content.format = "org.matrix.custom.html";
|
||||||
@@ -215,7 +306,7 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
|
|||||||
function resolveMatrixMsgType(
|
function resolveMatrixMsgType(
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
fileName?: string,
|
fileName?: string,
|
||||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
): MatrixMediaMsgType {
|
||||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
@@ -247,10 +338,10 @@ const THUMBNAIL_QUALITY = 80;
|
|||||||
async function prepareImageInfo(params: {
|
async function prepareImageInfo(params: {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
}): Promise<MatrixImageInfo | undefined> {
|
}): Promise<DimensionalFileInfo | undefined> {
|
||||||
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||||
if (!meta) return undefined;
|
if (!meta) return undefined;
|
||||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
||||||
const maxDim = Math.max(meta.width, meta.height);
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
try {
|
try {
|
||||||
@@ -261,11 +352,12 @@ async function prepareImageInfo(params: {
|
|||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||||
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
const thumbUri = await params.client.uploadContent(
|
||||||
type: "image/jpeg",
|
thumbBuffer,
|
||||||
name: "thumbnail.jpg",
|
"image/jpeg",
|
||||||
});
|
"thumbnail.jpg",
|
||||||
imageInfo.thumbnail_url = thumbUri.content_uri;
|
);
|
||||||
|
imageInfo.thumbnail_url = thumbUri;
|
||||||
if (thumbMeta) {
|
if (thumbMeta) {
|
||||||
imageInfo.thumbnail_info = {
|
imageInfo.thumbnail_info = {
|
||||||
w: thumbMeta.width,
|
w: thumbMeta.width,
|
||||||
@@ -281,21 +373,76 @@ async function prepareImageInfo(params: {
|
|||||||
return imageInfo;
|
return imageInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveMediaDurationMs(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
kind: MediaKind;
|
||||||
|
}): Promise<number | undefined> {
|
||||||
|
if (params.kind !== "audio" && params.kind !== "video") return undefined;
|
||||||
|
try {
|
||||||
|
const fileInfo: IFileInfo | string | undefined =
|
||||||
|
params.contentType || params.fileName
|
||||||
|
? {
|
||||||
|
mimeType: params.contentType,
|
||||||
|
size: params.buffer.byteLength,
|
||||||
|
path: params.fileName,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const metadata = await parseBuffer(params.buffer, fileInfo, {
|
||||||
|
duration: true,
|
||||||
|
skipCovers: true,
|
||||||
|
});
|
||||||
|
const durationSeconds = metadata.format.duration;
|
||||||
|
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
|
||||||
|
return Math.max(0, Math.round(durationSeconds * 1000));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Duration is optional; ignore parse failures.
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadFile(
|
async function uploadFile(
|
||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
file: MatrixUploadContent | Buffer,
|
file: Buffer,
|
||||||
params: {
|
params: {
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
includeFilename?: boolean;
|
|
||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const upload = await client.uploadContent(file as MatrixUploadContent, {
|
return await client.uploadContent(file, params.contentType, params.filename);
|
||||||
type: params.contentType,
|
}
|
||||||
name: params.filename,
|
|
||||||
includeFilename: params.includeFilename,
|
/**
|
||||||
});
|
* Upload media with optional encryption for E2EE rooms.
|
||||||
return upload.content_uri;
|
*/
|
||||||
|
async function uploadMediaMaybeEncrypted(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
params: {
|
||||||
|
contentType?: string;
|
||||||
|
filename?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||||
|
// Check if room is encrypted and crypto is available
|
||||||
|
const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId);
|
||||||
|
|
||||||
|
if (isEncrypted && client.crypto) {
|
||||||
|
// Encrypt the media before uploading
|
||||||
|
const encrypted = await client.crypto.encryptMedia(buffer);
|
||||||
|
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
||||||
|
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
||||||
|
return {
|
||||||
|
url: mxc,
|
||||||
|
file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload unencrypted
|
||||||
|
const mxc = await uploadFile(client, buffer, params);
|
||||||
|
return { url: mxc };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveMatrixClient(opts: {
|
async function resolveMatrixClient(opts: {
|
||||||
@@ -318,14 +465,11 @@ async function resolveMatrixClient(opts: {
|
|||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
await client.startClient({
|
// matrix-bot-sdk uses start() instead of startClient()
|
||||||
initialSyncLimit: 0,
|
await client.start();
|
||||||
lazyLoadMembers: true,
|
|
||||||
threadSupport: true,
|
|
||||||
});
|
|
||||||
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
|
|
||||||
return { client, stopOnDone: true };
|
return { client, stopOnDone: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,17 +494,26 @@ export async function sendMessageMatrix(
|
|||||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||||
const sendContent = (content: RoomMessageEventContent) =>
|
const sendContent = async (content: MatrixOutboundContent) => {
|
||||||
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
|
// matrix-bot-sdk uses sendMessage differently
|
||||||
|
const eventId = await client.sendMessage(roomId, content);
|
||||||
|
return eventId;
|
||||||
|
};
|
||||||
|
|
||||||
let lastMessageId = "";
|
let lastMessageId = "";
|
||||||
if (opts.mediaUrl) {
|
if (opts.mediaUrl) {
|
||||||
const maxBytes = resolveMediaMaxBytes();
|
const maxBytes = resolveMediaMaxBytes();
|
||||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||||
const contentUri = await uploadFile(client, media.buffer, {
|
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
filename: media.fileName,
|
filename: media.fileName,
|
||||||
});
|
});
|
||||||
|
const durationMs = await resolveMediaDurationMs({
|
||||||
|
buffer: media.buffer,
|
||||||
|
contentType: media.contentType,
|
||||||
|
fileName: media.fileName,
|
||||||
|
kind: media.kind,
|
||||||
|
});
|
||||||
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
|
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
|
||||||
const { useVoice } = resolveMatrixVoiceDecision({
|
const { useVoice } = resolveMatrixVoiceDecision({
|
||||||
wantsVoice: opts.audioAsVoice === true,
|
wantsVoice: opts.audioAsVoice === true,
|
||||||
@@ -375,31 +528,33 @@ export async function sendMessageMatrix(
|
|||||||
const content = buildMediaContent({
|
const content = buildMediaContent({
|
||||||
msgtype,
|
msgtype,
|
||||||
body,
|
body,
|
||||||
url: contentUri,
|
url: uploaded.url,
|
||||||
|
file: uploaded.file,
|
||||||
filename: media.fileName,
|
filename: media.fileName,
|
||||||
mimetype: media.contentType,
|
mimetype: media.contentType,
|
||||||
size: media.buffer.byteLength,
|
size: media.buffer.byteLength,
|
||||||
|
durationMs,
|
||||||
relation,
|
relation,
|
||||||
isVoice: useVoice,
|
isVoice: useVoice,
|
||||||
imageInfo,
|
imageInfo,
|
||||||
});
|
});
|
||||||
const response = await sendContent(content);
|
const eventId = await sendContent(content);
|
||||||
lastMessageId = response.event_id ?? lastMessageId;
|
lastMessageId = eventId ?? lastMessageId;
|
||||||
const textChunks = useVoice ? chunks : rest;
|
const textChunks = useVoice ? chunks : rest;
|
||||||
for (const chunk of textChunks) {
|
for (const chunk of textChunks) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const followup = buildTextContent(text);
|
const followup = buildTextContent(text);
|
||||||
const followupRes = await sendContent(followup);
|
const followupEventId = await sendContent(followup);
|
||||||
lastMessageId = followupRes.event_id ?? lastMessageId;
|
lastMessageId = followupEventId ?? lastMessageId;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const chunk of chunks.length ? chunks : [""]) {
|
for (const chunk of chunks.length ? chunks : [""]) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const content = buildTextContent(text, relation);
|
const content = buildTextContent(text, relation);
|
||||||
const response = await sendContent(content);
|
const eventId = await sendContent(content);
|
||||||
lastMessageId = response.event_id ?? lastMessageId;
|
lastMessageId = eventId ?? lastMessageId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +564,7 @@ export async function sendMessageMatrix(
|
|||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) {
|
||||||
client.stopClient();
|
client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,27 +588,16 @@ export async function sendPollMatrix(
|
|||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const pollContent = buildPollStartContent(poll);
|
const pollContent = buildPollStartContent(poll);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
// matrix-bot-sdk sendEvent returns eventId string directly
|
||||||
const response = threadId
|
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
|
||||||
? await client.sendEvent(
|
|
||||||
roomId,
|
|
||||||
threadId,
|
|
||||||
M_POLL_START,
|
|
||||||
pollContent,
|
|
||||||
)
|
|
||||||
: await client.sendEvent(
|
|
||||||
roomId,
|
|
||||||
M_POLL_START,
|
|
||||||
pollContent,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventId: response.event_id ?? "unknown",
|
eventId: eventId ?? "unknown",
|
||||||
roomId,
|
roomId,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) {
|
||||||
client.stopClient();
|
client.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,10 +614,29 @@ export async function sendTypingMatrix(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
|
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
|
||||||
await resolved.sendTyping(roomId, typing, resolvedTimeoutMs);
|
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) {
|
||||||
resolved.stopClient();
|
resolved.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendReadReceiptMatrix(
|
||||||
|
roomId: string,
|
||||||
|
eventId: string,
|
||||||
|
client?: MatrixClient,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!eventId?.trim()) return;
|
||||||
|
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
||||||
|
await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) {
|
||||||
|
resolved.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +665,7 @@ export async function reactMatrixMessage(
|
|||||||
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
|
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) {
|
if (stopOnDone) {
|
||||||
resolved.stopClient();
|
resolved.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
|
|||||||
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
|
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"Matrix requires a homeserver URL + user ID.",
|
"Matrix requires a homeserver URL.",
|
||||||
"Use an access token or a password (password logs in and stores a token).",
|
"Use an access token (recommended) or a password (logs in and stores a token).",
|
||||||
|
"With access token: user ID is fetched automatically.",
|
||||||
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
|
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
|
||||||
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
|
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@@ -146,8 +147,8 @@ function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist"
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
|
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
|
||||||
const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
|
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
channels: {
|
channels: {
|
||||||
@@ -155,7 +156,7 @@ function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
|
|||||||
matrix: {
|
matrix: {
|
||||||
...cfg.channels?.matrix,
|
...cfg.channels?.matrix,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
rooms,
|
groups,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -180,9 +181,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
configured,
|
configured,
|
||||||
statusLines: [`Matrix: ${configured ? "configured" : "needs homeserver + user id"}`],
|
statusLines: [
|
||||||
|
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
|
||||||
|
],
|
||||||
selectionHint: !sdkReady
|
selectionHint: !sdkReady
|
||||||
? "install matrix-js-sdk"
|
? "install matrix-bot-sdk"
|
||||||
: configured
|
: configured
|
||||||
? "configured"
|
? "configured"
|
||||||
: "needs auth",
|
: "needs auth",
|
||||||
@@ -208,7 +211,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
const envUserId = process.env.MATRIX_USER_ID?.trim();
|
const envUserId = process.env.MATRIX_USER_ID?.trim();
|
||||||
const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim();
|
const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim();
|
||||||
const envPassword = process.env.MATRIX_PASSWORD?.trim();
|
const envPassword = process.env.MATRIX_PASSWORD?.trim();
|
||||||
const envReady = Boolean(envHomeserver && envUserId && (envAccessToken || envPassword));
|
const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword)));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
envReady &&
|
envReady &&
|
||||||
@@ -252,22 +255,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const userId = String(
|
|
||||||
await prompter.text({
|
|
||||||
message: "Matrix user ID",
|
|
||||||
initialValue: existing.userId ?? envUserId,
|
|
||||||
validate: (value) => {
|
|
||||||
const raw = String(value ?? "").trim();
|
|
||||||
if (!raw) return "Required";
|
|
||||||
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
|
||||||
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
let accessToken = existing.accessToken ?? "";
|
let accessToken = existing.accessToken ?? "";
|
||||||
let password = existing.password ?? "";
|
let password = existing.password ?? "";
|
||||||
|
let userId = existing.userId ?? "";
|
||||||
|
|
||||||
if (accessToken || password) {
|
if (accessToken || password) {
|
||||||
const keep = await prompter.confirm({
|
const keep = await prompter.confirm({
|
||||||
@@ -277,15 +267,17 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
if (!keep) {
|
if (!keep) {
|
||||||
accessToken = "";
|
accessToken = "";
|
||||||
password = "";
|
password = "";
|
||||||
|
userId = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken && !password) {
|
if (!accessToken && !password) {
|
||||||
|
// Ask auth method FIRST before asking for user ID
|
||||||
const authMode = (await prompter.select({
|
const authMode = (await prompter.select({
|
||||||
message: "Matrix auth method",
|
message: "Matrix auth method",
|
||||||
options: [
|
options: [
|
||||||
{ value: "token", label: "Access token" },
|
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
||||||
{ value: "password", label: "Password (stores token)" },
|
{ value: "password", label: "Password (requires user ID)" },
|
||||||
],
|
],
|
||||||
})) as "token" | "password";
|
})) as "token" | "password";
|
||||||
|
|
||||||
@@ -296,7 +288,24 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
|
// With access token, we can fetch the userId automatically - don't prompt for it
|
||||||
|
// The client.ts will use whoami() to get it
|
||||||
|
userId = "";
|
||||||
} else {
|
} else {
|
||||||
|
// Password auth requires user ID upfront
|
||||||
|
userId = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Matrix user ID",
|
||||||
|
initialValue: existing.userId ?? envUserId,
|
||||||
|
validate: (value) => {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) return "Required";
|
||||||
|
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
||||||
|
if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)";
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
password = String(
|
password = String(
|
||||||
await prompter.text({
|
await prompter.text({
|
||||||
message: "Matrix password",
|
message: "Matrix password",
|
||||||
@@ -313,6 +322,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
|
// Ask about E2EE encryption
|
||||||
|
const enableEncryption = await prompter.confirm({
|
||||||
|
message: "Enable end-to-end encryption (E2EE)?",
|
||||||
|
initialValue: existing.encryption ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
channels: {
|
channels: {
|
||||||
@@ -321,10 +336,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
...next.channels?.matrix,
|
...next.channels?.matrix,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
homeserver,
|
homeserver,
|
||||||
userId,
|
userId: userId || undefined,
|
||||||
accessToken: accessToken || undefined,
|
accessToken: accessToken || undefined,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
deviceName: deviceName || undefined,
|
deviceName: deviceName || undefined,
|
||||||
|
encryption: enableEncryption || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -333,13 +349,14 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
next = await promptMatrixAllowFrom({ cfg: next, prompter });
|
next = await promptMatrixAllowFrom({ cfg: next, prompter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms;
|
||||||
const accessConfig = await promptChannelAccessConfig({
|
const accessConfig = await promptChannelAccessConfig({
|
||||||
prompter,
|
prompter,
|
||||||
label: "Matrix rooms",
|
label: "Matrix rooms",
|
||||||
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
|
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
|
||||||
currentEntries: Object.keys(next.channels?.matrix?.rooms ?? {}),
|
currentEntries: Object.keys(existingGroups ?? {}),
|
||||||
placeholder: "!roomId:server, #alias:server, Project Room",
|
placeholder: "!roomId:server, #alias:server, Project Room",
|
||||||
updatePrompt: Boolean(next.channels?.matrix?.rooms),
|
updatePrompt: Boolean(existingGroups),
|
||||||
});
|
});
|
||||||
if (accessConfig) {
|
if (accessConfig) {
|
||||||
if (accessConfig.policy !== "allowlist") {
|
if (accessConfig.policy !== "allowlist") {
|
||||||
@@ -398,7 +415,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
next = setMatrixGroupPolicy(next, "allowlist");
|
next = setMatrixGroupPolicy(next, "allowlist");
|
||||||
next = setMatrixRoomAllowlist(next, roomKeys);
|
next = setMatrixGroupRooms(next, roomKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ export type MatrixConfig = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
/** Optional device name when logging in via password. */
|
/** Optional device name when logging in via password. */
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
/** Initial sync limit for startup (default: matrix-js-sdk default). */
|
/** Initial sync limit for startup (default: matrix-bot-sdk default). */
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
/** Enable end-to-end encryption (E2EE). Default: false. */
|
||||||
|
encryption?: boolean;
|
||||||
/** If true, enforce allowlists for groups + DMs regardless of policy. */
|
/** If true, enforce allowlists for groups + DMs regardless of policy. */
|
||||||
allowlistOnly?: boolean;
|
allowlistOnly?: boolean;
|
||||||
/** Group message policy (default: allowlist). */
|
/** Group message policy (default: allowlist). */
|
||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
|
/** Allowlist for group senders (user IDs or localparts). */
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
/** Control reply threading when reply tags are present (off|first|all). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
/** How to handle thread replies (off|inbound|always). */
|
/** How to handle thread replies (off|inbound|always). */
|
||||||
@@ -72,6 +76,8 @@ export type MatrixConfig = {
|
|||||||
/** Direct message policy + allowlist overrides. */
|
/** Direct message policy + allowlist overrides. */
|
||||||
dm?: MatrixDmConfig;
|
dm?: MatrixDmConfig;
|
||||||
/** Room config allowlist keyed by room ID, alias, or name. */
|
/** Room config allowlist keyed by room ID, alias, or name. */
|
||||||
|
groups?: Record<string, MatrixRoomConfig>;
|
||||||
|
/** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
|
||||||
rooms?: Record<string, MatrixRoomConfig>;
|
rooms?: Record<string, MatrixRoomConfig>;
|
||||||
/** Per-action tool gating (default: true for all). */
|
/** Per-action tool gating (default: true for all). */
|
||||||
actions?: MatrixActionConfig;
|
actions?: MatrixActionConfig;
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export {
|
|||||||
resolveMentionGatingWithBypass,
|
resolveMentionGatingWithBypass,
|
||||||
} from "../channels/mention-gating.js";
|
} from "../channels/mention-gating.js";
|
||||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
||||||
|
export type { NormalizedLocation } from "../channels/location.js";
|
||||||
|
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
export {
|
export {
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
resolveIMessageGroupRequireMention,
|
resolveIMessageGroupRequireMention,
|
||||||
|
|||||||
Reference in New Issue
Block a user