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:
Sebastian Schubotz
2026-01-20 09:37:27 +01:00
committed by Peter Steinberger
parent dd82d32d85
commit 9b71382efb
32 changed files with 1727 additions and 616 deletions

View File

@@ -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.

View File

@@ -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 wont 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).

View File

@@ -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.

View File

@@ -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"
} }
} }

View File

@@ -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" },
]), ]),
); );

View File

@@ -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 }) => {

View File

@@ -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,
}); });

View File

@@ -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,

View 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);
});
});

View File

@@ -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,

View File

@@ -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();
} }
} }

View File

@@ -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;

View File

@@ -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);
}); });
}); });

View File

@@ -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);
});
} }

View File

@@ -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;
} }

View File

@@ -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.");
} }
} }

View File

@@ -3,6 +3,7 @@ export { probeMatrix } from "./probe.js";
export { export {
reactMatrixMessage, reactMatrixMessage,
resolveMatrixRoomId, resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix, sendMessageMatrix,
sendPollMatrix, sendPollMatrix,
sendTypingMatrix, sendTypingMatrix,

View File

@@ -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}`);

View File

@@ -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;
}, },
}; };
} }

View File

@@ -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();

View File

@@ -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(

View File

@@ -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>();

View File

@@ -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";

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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");
});
}); });

View 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();
} }
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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;

View File

@@ -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,