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:
- **Telegram** (location pins + venues + live locations)
- **WhatsApp** (locationMessage + liveLocationMessage)
- **Matrix** (`m.location` with `geo_uri`)
## Text formatting
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
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
- **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)
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
Matrix ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/matrix
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/matrix
```
@@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
## Setup
1) Install the Matrix plugin:
- From npm: `clawdbot plugins install @clawdbot/matrix`
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
2) Create a Matrix account on a homeserver:
- 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.*`
- If both are set, config takes precedence.
3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact.
- With access token: user ID is fetched automatically via `/whoami`.
- 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
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@clawdbot:example.org",
accessToken: "syt_***",
dm: { policy: "pairing" }
}
@@ -53,18 +89,49 @@ Minimal config:
}
```
## Encryption (E2EE)
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.
E2EE config (end to end encryption enabled):
## What it is
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.
- Deterministic routing: replies go back to Matrix.
```json5
{
channels: {
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.
## Access control (DMs)
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `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.
## Rooms (groups)
- 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
{
channels: {
matrix: {
rooms: {
"!roomId:example.org": { requireMention: true }
}
groupPolicy: "allowlist",
groups: {
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true }
},
groupAllowFrom: ["@owner:example.org"]
}
}
}
```
- `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.
- 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).
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
## Threads
- 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`
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | ✅ Supported |
| Rooms | ✅ Supported |
| Threads | ✅ Supported |
| Media | ✅ Supported |
| Reactions | ✅ Supported |
| Polls | ✅ Supported |
| E2EE | ✅ Supported (crypto module required) |
| 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 |
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.matrix.enabled`: enable/disable channel startup.
- `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.password`: password for login (token stored).
- `channels.matrix.deviceName`: device display name.
- `channels.matrix.encryption`: enable E2EE (default: false).
- `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `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.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.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.mediaMaxMb`: inbound/outbound media cap (MB).
- `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: {
groupPolicy: "allowlist",
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`).
- Discord: allowlist uses `channels.discord.guilds.<id>.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.*`).
- 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.

View File

@@ -24,8 +24,10 @@
}
},
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"clawdbot": "workspace:*",
"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 type { PluginRuntime } from "clawdbot/plugin-sdk";
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: () => os.tmpdir(),
resolveStateDir: (_env, homeDir) => homeDir(),
},
} as PluginRuntime);
});
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
rooms: {
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ 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 {
let normalized = raw.trim();
if (!normalized) return undefined;
if (normalized.toLowerCase().startsWith("matrix:")) {
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
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(
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}),
collectWarnings: ({ account, cfg }) => {
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 [];
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: {
resolveReplyToMode: ({ cfg }) =>
(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: {
normalizeTarget: normalizeMatrixMessagingTarget,
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
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 ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
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())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
validateInput: ({ input }) => {
if (input.useEnv) return null;
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
if (!input.userId?.trim()) return "Matrix requires --user-id";
if (!input.accessToken?.trim() && !input.password?.trim()) {
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !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;
},
applyAccountConfig: ({ cfg, input }) => {

View File

@@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({
password: z.string().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
@@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
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 enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env);
const hasCore = Boolean(resolved.homeserver && resolved.userId);
const hasToken = Boolean(resolved.accessToken || resolved.password);
const hasHomeserver = Boolean(resolved.homeserver);
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 hasStored =
stored &&
resolved.homeserver &&
resolved.userId &&
credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
});
const configured = hasCore && (hasToken || Boolean(hasStored));
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,

View File

@@ -1,19 +1,4 @@
import type { MatrixClient, MatrixEvent } from "matrix-js-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 type { MatrixClient } from "matrix-bot-sdk";
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
@@ -23,7 +8,6 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
waitForMatrixSync,
} from "./client.js";
import {
reactMatrixMessage,
@@ -31,6 +15,62 @@ import {
sendMessageMatrix,
} 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 = {
client?: MatrixClient;
timeoutMs?: number;
@@ -86,19 +126,15 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
await client.startClient({
initialSyncLimit: 0,
lazyLoadMembers: true,
threadSupport: true,
});
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
await client.start();
return { client, stopOnDone: true };
}
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
const content = event.getContent<RoomMessageEventContent>();
function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
@@ -118,27 +154,28 @@ function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
}
: undefined;
return {
eventId: event.getId() ?? undefined,
sender: event.getSender() ?? undefined,
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.getTs() ?? undefined,
timestamp: event.origin_server_ts,
relatesTo,
};
}
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getStateEvent(
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err) {
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
const errcode = err instanceof MatrixError ? err.errcode : undefined;
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
@@ -151,11 +188,14 @@ async function fetchEventSummary(
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
const raw = await client.fetchRoomEvent(roomId, eventId);
const mapper = client.getEventMapper();
const event = mapper(raw);
if (event.isRedacted()) return null;
return summarizeMatrixEvent(event);
try {
const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null;
return summarizeMatrixRawEvent(raw);
} catch (err) {
// Event not found, redacted, or inaccessible - return null
return null;
}
}
export async function sendMatrixMessage(
@@ -200,10 +240,10 @@ export async function editMatrixMessage(
event_id: messageId,
},
};
const response = await client.sendMessage(resolvedRoom, payload);
return { eventId: response.event_id ?? null };
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -215,11 +255,9 @@ export async function deleteMatrixMessage(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, undefined, {
reason: opts.reason,
});
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} 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)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || null;
const dir = opts.after ? Direction.Forward : Direction.Backward;
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
const mapper = client.getEventMapper();
const events = res.chunk.map(mapper);
const messages = events
.filter((event) => event.getType() === EventType.RoomMessage)
.filter((event) => !event.isRedacted())
.map(summarizeMatrixEvent);
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, {
dir,
limit,
from: token,
}) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} 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)
? Math.max(1, Math.floor(opts.limit))
: 100;
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit },
);
// matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.events) {
const content = event.getContent<ReactionEventContent>();
const key = content["m.relates_to"].key;
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
const sender = event.getSender() ?? "";
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
@@ -299,7 +339,7 @@ export async function listMatrixReactions(
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -311,30 +351,28 @@ export async function removeMatrixReactions(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit: 200 },
);
const userId = client.getUserId();
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.events
.filter((event) => event.getSender() === userId)
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.getContent<ReactionEventContent>();
return content["m.relates_to"].key === targetEmoji;
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.getId())
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -349,10 +387,10 @@ export async function pinMatrixMessage(
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -367,10 +405,10 @@ export async function unpinMatrixMessage(
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -395,7 +433,7 @@ export async function listMatrixPins(
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -406,20 +444,23 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getProfileInfo(userId);
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
// matrix-bot-sdk uses getUserProfile
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 {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: member?.membership ?? null,
powerLevel: member?.powerLevel ?? null,
displayName: member?.name ?? null,
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
} 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);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const room = client.getRoom(resolvedRoom);
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
// matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
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 {
roomId: resolvedRoom,
name: room?.name ?? null,
topic: topic ?? null,
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
altAliases: room?.getAltAliases?.() ?? [],
memberCount: room?.getJoinedMemberCount?.() ?? null,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
} 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;

View File

@@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => {
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
@@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => {
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceName).toBe("EnvDevice");
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 { getMatrixRuntime } from "../runtime.js";
@@ -10,22 +17,30 @@ export type MatrixResolvedConfig = {
password?: string;
deviceName?: string;
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 = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
type MatrixSdk = typeof import("matrix-js-sdk");
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
@@ -37,14 +52,65 @@ export function isBunRuntime(): boolean {
return typeof versions.bun === "string";
}
async function loadMatrixSdk(): Promise<MatrixSdk> {
return (await import("matrix-js-sdk")) as MatrixSdk;
let matrixSdkLoggingConfigured = false;
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 {
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(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -61,6 +127,7 @@ export function resolveMatrixConfig(
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
@@ -68,9 +135,26 @@ export function resolveMatrixConfig(
password,
deviceName,
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?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
@@ -81,9 +165,6 @@ export async function resolveMatrixAuth(params?: {
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
if (!resolved.userId) {
throw new Error("Matrix userId is required (matrix.userId)");
}
const {
loadMatrixCredentials,
@@ -97,21 +178,36 @@ export async function resolveMatrixAuth(params?: {
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
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);
}
return {
homeserver: resolved.homeserver,
userId: resolved.userId,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
@@ -123,25 +219,45 @@ export async function resolveMatrixAuth(params?: {
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
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) {
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();
const loginClient = sdk.createClient({
baseUrl: resolved.homeserver,
});
const login = await loginClient.loginRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
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();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
@@ -153,12 +269,14 @@ export async function resolveMatrixAuth(params?: {
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
return auth;
@@ -168,21 +286,79 @@ export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
}): Promise<MatrixClient> {
const sdk = await loadMatrixSdk();
const store = new sdk.MemoryStore();
return sdk.createClient({
baseUrl: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
localTimeoutMs: params.localTimeoutMs,
store,
});
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePath = resolveStoragePath(env);
const fs = await import("node:fs");
const path = await import("node:path");
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 {
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
}
async function createSharedMatrixClient(params: {
@@ -193,15 +369,22 @@ async function createSharedMatrixClient(params: {
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
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: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
@@ -209,18 +392,22 @@ async function ensureSharedClientStarted(params: {
return;
}
sharedClientStartPromise = (async () => {
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
lazyLoadMembers: true,
threadSupport: true,
};
if (typeof params.initialSyncLimit === "number") {
startOpts.initialSyncLimit = params.initialSyncLimit;
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
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({
client: params.state.client,
timeoutMs: params.timeoutMs,
});
await client.start();
params.state.started = true;
})();
try {
@@ -249,6 +436,7 @@ export async function resolveSharedMatrixClient(
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
@@ -262,11 +450,12 @@ export async function resolveSharedMatrixClient(
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
pending.client.stopClient();
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
@@ -283,6 +472,7 @@ export async function resolveSharedMatrixClient(
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
@@ -291,48 +481,18 @@ export async function resolveSharedMatrixClient(
}
}
export async function waitForMatrixSync(params: {
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
if (params.client.getSyncState() === SyncState.Syncing) return;
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
const cleanup = () => {
if (done) return;
done = true;
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);
});
// matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}

View File

@@ -8,6 +8,7 @@ export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
@@ -94,5 +95,9 @@ export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): 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;
}

View File

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return;
const confirm = params.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) {
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()) {
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 {
reactMatrixMessage,
resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix,
sendPollMatrix,
sendTypingMatrix,

View File

@@ -1,5 +1,5 @@
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
import { RoomMemberEvent } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import { AutojoinRoomsMixin } from "matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
@@ -19,25 +19,40 @@ export function registerMatrixAutoJoin(params: {
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
client.on(RoomMemberEvent.Membership, async (_event: MatrixEvent, member: RoomMember) => {
if (member.userId !== client.getUserId()) return;
if (member.membership !== "invite") return;
const roomId = member.roomId;
if (autoJoin === "off") return;
if (autoJoin === "allowlist") {
const invitedRoom = client.getRoom(roomId);
const alias = invitedRoom?.getCanonicalAlias?.() ?? "";
const altAliases = invitedRoom?.getAltAliases?.() ?? [];
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;
}
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
// Get room alias if available
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 {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);

View File

@@ -1,80 +1,105 @@
import type {
AccountDataEvents,
MatrixClient,
MatrixEvent,
Room,
RoomMember,
} from "matrix-js-sdk";
import { ClientEvent, EventType } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
function hasDirectFlag(member?: RoomMember | null): boolean {
if (!member?.events.member) return false;
const content = member.events.member.getContent() as { is_direct?: boolean } | undefined;
if (content?.is_direct === true) return true;
const prev = member.events.member.getPrevContent() as { is_direct?: boolean } | undefined;
return prev?.is_direct === true;
}
type DirectMessageCheck = {
roomId: string;
senderId?: string;
selfUserId?: string;
};
export function isLikelyDirectRoom(params: {
room: Room;
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;
}
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
};
export function isDirectRoomByFlag(params: {
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);
}
const DM_CACHE_TTL_MS = 30_000;
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 directMap = new Map<string, Set<string>>();
const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) return cachedSelfUserId;
try {
cachedSelfUserId = await client.getUserId();
} catch {
cachedSelfUserId = null;
}
return cachedSelfUserId;
};
const updateDirectMap = (content: MatrixDirectAccountData) => {
directMap.clear();
for (const [userId, rooms] of Object.entries(content)) {
if (!Array.isArray(rooms)) continue;
const ids = rooms.map((roomId) => String(roomId).trim()).filter(Boolean);
if (ids.length === 0) continue;
directMap.set(userId, new Set(ids));
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
lastDmUpdateMs = now;
try {
await client.dms.update();
} catch (err) {
log(`matrix: dm cache refresh failed (${String(err)})`);
}
};
const initialDirect = client.getAccountData(EventType.Direct);
if (initialDirect) {
updateDirectMap(initialDirect.getContent<MatrixDirectAccountData>() ?? {});
}
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
const cached = memberCountCache.get(roomId);
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) => {
if (event.getType() !== EventType.Direct) return;
updateDirectMap(event.getContent<MatrixDirectAccountData>() ?? {});
});
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim();
if (!target) return false;
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;
} catch {
return false;
}
};
return {
isDirectMessage: (room: Room, senderId: string) => {
const roomId = room.roomId;
const directRooms = directMap.get(senderId);
const selfId = client.getUserId();
const isDirectByFlag = isDirectRoomByFlag({ room, senderId, selfId });
return (
Boolean(directRooms?.has(roomId)) ||
isDirectByFlag ||
isLikelyDirectRoom({ room, senderId, selfId })
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
await refreshDmCache();
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}
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 { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import type {
LocationMessageEventContent,
MatrixClient,
MessageEventContent,
} from "matrix-bot-sdk";
import { format } from "node:util";
import {
formatAllowlistMatchMeta,
formatLocationText,
mergeAllowlist,
summarizeMapping,
toLocationContext,
type NormalizedLocation,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
@@ -15,6 +21,7 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClient,
} from "../client.js";
import {
formatPollAsText,
@@ -22,7 +29,12 @@ import {
type PollStartContent,
parsePollStartContent,
} from "../poll-types.js";
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import {
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
@@ -38,6 +50,118 @@ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.
import { resolveMatrixTargets } from "../../resolve-targets.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 = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
@@ -56,13 +180,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
let cfg = core.config.loadConfig() as CoreConfig;
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 ?? {
log: console.log,
error: console.error,
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
logger.debug(message);
};
const normalizeUserEntry = (raw: string) =>
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();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
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) {
const entries = allowFrom
@@ -163,7 +298,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm,
allowFrom,
},
rooms: roomsConfig,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
@@ -185,13 +320,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client);
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 groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
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 startupMs = Date.now();
const startupGraceMs = 0;
const directTracker = createDirectRoomTracker(client);
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
const warnedCryptoMissingRooms = new Set<string>();
const handleTimeline = async (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline?: boolean,
const roomInfoCache = new Map<
string,
{ name?: string; canonicalAlias?: string; altAliases: string[] }
>();
// 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 {
if (!room) return;
if (toStartOfTimeline) return;
if (event.getType() === EventType.RoomMessageEncrypted || event.isDecryptionFailure()) {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
return;
}
const eventType = event.getType();
const isPollEvent = isPollStartType(eventType);
if (eventType !== EventType.RoomMessage && !isPollEvent) return;
if (event.isRedacted()) return;
const senderId = event.getSender();
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
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 === client.getUserId()) return;
const eventTs = event.getTs();
const eventAge = event.getAge();
const selfUserId = await client.getUserId();
if (senderId === selfUserId) return;
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
}
@@ -241,15 +414,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
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) {
const pollStartContent = event.getContent<PollStartContent>();
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.getId() ?? "";
pollSummary.roomId = room.roomId;
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
pollSummary.senderName = room.getMember(senderId)?.name ?? senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
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"];
if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) return;
}
const roomId = room.roomId;
const isDirectMessage = directTracker.isDirectMessage(room, senderId);
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
senderId,
selfUserId,
});
const isRoom = !isDirectMessage;
if (!isDirectMessage && groupPolicy === "disabled") return;
if (isRoom && groupPolicy === "disabled") return;
const roomAliases = [
room.getCanonicalAlias?.() ?? "",
...(room.getAltAliases?.() ?? []),
].filter(Boolean);
const roomName = room.name ?? undefined;
const roomConfigInfo = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
roomId,
aliases: roomAliases,
name: roomName,
});
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`;
const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases: roomAliases,
name: roomName,
})
: undefined;
const roomConfig = roomConfigInfo?.config;
const roomMatchMeta = roomConfigInfo
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`
: "matchKey=none matchSource=none";
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = room.getMember(senderId)?.name ?? senderId;
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeAllowListLower([
...groupAllowFrom,
...storeAllowFrom,
]);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
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({
allowList: normalizeAllowListLower(roomConfigInfo.config.users),
allowList: normalizeAllowListLower(roomUsers),
userId: senderId,
userName: senderName,
});
@@ -368,11 +564,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
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) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
const rawBody = locationPayload?.text
?? (typeof content.body === "string" ? content.body.trim() : "");
let media: {
path: string;
contentType?: string;
@@ -406,7 +618,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const { wasMentioned, hasExplicitMention } = resolveMentions({
content,
userId: client.getUserId(),
userId: selfUserId,
text: bodyText,
mentionRegexes,
});
@@ -420,10 +632,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userId: senderId,
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({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
});
if (
@@ -436,12 +665,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
const shouldRequireMention = isRoom
? roomConfigInfo.config?.autoReply === true
? roomConfig?.autoReply === true
? false
: roomConfigInfo.config?.autoReply === false
: roomConfig?.autoReply === false
? true
: typeof roomConfigInfo.config?.requireMention === "boolean"
? roomConfigInfo.config.requireMention
: typeof roomConfig?.requireMention === "boolean"
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
@@ -457,13 +686,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
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 threadTarget = resolveMatrixThreadTarget({
threadReplies,
messageId,
threadRootId,
isThreadRoot: event.isThreadRoot,
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
@@ -484,16 +714,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: event.getTs() ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
@@ -508,18 +738,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const,
Surface: "matrix" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (event.replyEventId ?? undefined),
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
MessageThreadId: threadTarget,
Timestamp: event.getTs() ?? undefined,
Timestamp: eventTs ?? undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...(locationPayload?.context ?? {}),
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
@@ -577,6 +808,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
if (messageId) {
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
@@ -606,7 +845,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfigInfo.config?.skills,
skillFilter: roomConfig?.skills,
},
});
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 });
runtime.log?.(`matrix: logged in as ${auth.userId}`);
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
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) => {
const onAbort = () => {
try {
client.stopClient();
logVerboseMessage("matrix: stopping client");
stopSharedClient();
} finally {
setActiveMatrixClient(null);
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";
// 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: {
client: MatrixClient;
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
const url = params.client.mxcUrlToHttp(
params.mxcUrl,
undefined,
undefined,
undefined,
false,
true,
true,
);
// matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
const token = params.client.getAccessToken();
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) {
throw new Error(`Matrix media download failed: HTTP ${res.status}`);
// Use the client's download method which handles auth
try {
const buffer = await params.client.downloadContent(params.mxcUrl);
if (buffer.byteLength > params.maxBytes) {
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");
}
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: {
@@ -37,16 +70,30 @@ export async function downloadMatrixMedia(params: {
mxcUrl: string;
contentType?: string;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
const fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
let fetched: { buffer: Buffer; headerType?: string } | null;
if (params.file) {
// 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;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
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";
// Type for room message content with mentions
type MessageContentWithMentions = {
msgtype: string;
body: string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
};
export function resolveMentions(params: {
content: RoomMessageEventContent;
content: MessageContentWithMentions;
userId?: string | null;
text?: string;
mentionRegexes: RegExp[];
}) {
const mentions = params.content["m.mentions"] as
| { user_ids?: string[]; room?: boolean }
| undefined;
const mentions = params.content["m.mentions"];
const mentionedUsers = Array.isArray(mentions?.user_ids)
? new Set(mentions.user_ids)
: 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 { 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";
export type MatrixRoomConfigResolved = {
@@ -10,7 +10,7 @@ export type MatrixRoomConfigResolved = {
};
export function resolveMatrixRoomConfig(params: {
rooms?: MatrixConfig["rooms"];
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
aliases: string[];
name?: string | null;

View File

@@ -1,6 +1,25 @@
import type { MatrixEvent } from "matrix-js-sdk";
import { RelationType } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
// Type for raw Matrix event from matrix-bot-sdk
type MatrixRawEvent = {
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: {
threadReplies: "off" | "inbound" | "always";
@@ -22,13 +41,9 @@ export function resolveMatrixThreadTarget(params: {
}
export function resolveMatrixThreadRootId(params: {
event: MatrixEvent;
event: MatrixRawEvent;
content: RoomMessageEventContent;
}): 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"];
if (!relates || typeof relates !== "object") return undefined;
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {

View File

@@ -7,8 +7,6 @@
* - 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";
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 TextContent = ExtensibleAnyMessageEventContent & {
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string;
};
@@ -53,7 +53,13 @@ export type LegacyPollStartContent = {
"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 = {
eventId: string;

View File

@@ -49,9 +49,10 @@ export async function probeMatrix(params: {
accessToken: params.accessToken,
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.userId = res.user_id ?? null;
result.userId = userId ?? null;
result.elapsedMs = Date.now() - started;
return result;
@@ -59,8 +60,8 @@ export async function probeMatrix(params: {
return {
...result,
status:
typeof err === "object" && err && "httpStatus" in err
? Number((err as { httpStatus?: number }).httpStatus)
typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: result.status,
error: err instanceof Error ? err.message : String(err),
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 { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-js-sdk", () => ({
EventType: {
Direct: "m.direct",
RoomMessage: "m.room.message",
Reaction: "m.reaction",
vi.mock("matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
MsgType: {
Text: "m.text",
File: "m.file",
Image: "m.image",
Audio: "m.audio",
Video: "m.video",
},
RelationType: {
Annotation: "m.annotation",
LogService: {
setLogger: vi.fn(),
},
MatrixClient: vi.fn(),
SimpleFsStorageProvider: vi.fn(),
RustSdkCryptoStorageProvider: vi.fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
@@ -52,14 +50,13 @@ const runtimeStub = {
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue({ event_id: "evt1" });
const uploadContent = vi.fn().mockResolvedValue({
content_uri: "mxc://example/file",
});
const sendMessage = vi.fn().mockResolvedValue("evt1");
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const client = {
sendMessage,
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 };
};
@@ -96,4 +93,41 @@ describe("sendMessageMatrix media", () => {
expect(content.formatted_body).toContain("caption");
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 {
RoomMessageEventContent,
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
DimensionalFileInfo,
EncryptedFile,
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 { getMatrixRuntime } from "../runtime.js";
@@ -13,7 +18,6 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
waitForMatrixSync,
} from "./client.js";
import { markdownToMatrixHtml } from "./format.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 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 = {
"m.in_reply_to": { event_id: string };
};
type MatrixMessageContent = Record<string, unknown> & {
msgtype: MsgType;
body: string;
type MatrixReplyMeta = {
"m.relates_to"?: MatrixReplyRelation;
};
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 = {
messageId: string;
@@ -83,13 +132,14 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
if (!trimmed.startsWith("@")) {
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
}
const directEvent = client.getAccountData(EventType.Direct);
const directContent = directEvent?.getContent<MatrixDirectAccountData>();
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) return list[0];
const server = await client.getAccountDataFromServer(EventType.Direct);
const serverList = Array.isArray(server?.[trimmed]) ? server[trimmed] : [];
if (serverList.length > 0) return serverList[0];
// matrix-bot-sdk: use getAccountData to retrieve m.direct
try {
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) return list[0];
} catch {
// Ignore errors, try fetching from server
}
throw new Error(
`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);
}
if (target.startsWith("#")) {
const resolved = await client.getRoomIdForAlias(target);
if (!resolved?.room_id) {
const resolved = await client.resolveRoom(target);
if (!resolved) {
throw new Error(`Matrix alias ${target} could not be resolved`);
}
return resolved.room_id;
return resolved;
}
return target;
}
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
type MatrixMediaMsgType =
| typeof MsgType.Image
| typeof MsgType.Audio
| typeof MsgType.Video
| typeof MsgType.File;
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
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: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
msgtype: MatrixMediaMsgType;
body: string;
url: string;
url?: string;
filename?: string;
mimetype?: string;
size: number;
relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent {
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = {
imageInfo?: DimensionalFileInfo;
file?: EncryptedFile; // For encrypted media
}): MatrixMediaContent {
const info = buildMatrixMediaInfo({
size: params.size,
mimetype: params.mimetype,
durationMs: params.durationMs,
imageInfo: params.imageInfo,
});
const base: MatrixMediaContent = {
msgtype: params.msgtype,
body: params.body,
filename: params.filename,
info,
url: params.url,
info: info ?? undefined,
};
// 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) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
if (typeof params.durationMs === "number") {
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
applyMatrixFormatting(base, params.body);
return base as RoomMessageEventContent;
return base;
}
function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMessageEventContent {
const content: MatrixMessageContent = relation
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
body,
@@ -196,10 +287,10 @@ function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMes
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 ?? "");
if (!formatted) return;
content.format = "org.matrix.custom.html";
@@ -215,7 +306,7 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
): MatrixMediaMsgType {
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
@@ -247,10 +338,10 @@ const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
}): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
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);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
@@ -261,11 +352,12 @@ async function prepareImageInfo(params: {
withoutEnlargement: true,
});
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
@@ -281,21 +373,76 @@ async function prepareImageInfo(params: {
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(
client: MatrixClient,
file: MatrixUploadContent | Buffer,
file: Buffer,
params: {
contentType?: string;
filename?: string;
includeFilename?: boolean;
},
): Promise<string> {
const upload = await client.uploadContent(file as MatrixUploadContent, {
type: params.contentType,
name: params.filename,
includeFilename: params.includeFilename,
});
return upload.content_uri;
return await client.uploadContent(file, params.contentType, params.filename);
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
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: {
@@ -318,14 +465,11 @@ async function resolveMatrixClient(opts: {
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
await client.startClient({
initialSyncLimit: 0,
lazyLoadMembers: true,
threadSupport: true,
});
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
// matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}
@@ -350,17 +494,26 @@ export async function sendMessageMatrix(
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
const sendContent = async (content: MatrixOutboundContent) => {
// matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
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,
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 { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
@@ -375,31 +528,33 @@ export async function sendMessageMatrix(
const content = buildMediaContent({
msgtype,
body,
url: contentUri,
url: uploaded.url,
file: uploaded.file,
filename: media.fileName,
mimetype: media.contentType,
size: media.buffer.byteLength,
durationMs,
relation,
isVoice: useVoice,
imageInfo,
});
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) continue;
const followup = buildTextContent(text);
const followupRes = await sendContent(followup);
lastMessageId = followupRes.event_id ?? lastMessageId;
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim();
if (!text) continue;
const content = buildTextContent(text, relation);
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
}
}
@@ -409,7 +564,7 @@ export async function sendMessageMatrix(
};
} finally {
if (stopOnDone) {
client.stopClient();
client.stop();
}
}
}
@@ -433,27 +588,16 @@ export async function sendPollMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId);
const response = threadId
? await client.sendEvent(
roomId,
threadId,
M_POLL_START,
pollContent,
)
: await client.sendEvent(
roomId,
M_POLL_START,
pollContent,
);
// matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
return {
eventId: response.event_id ?? "unknown",
eventId: eventId ?? "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stopClient();
client.stop();
}
}
}
@@ -470,10 +614,29 @@ export async function sendTypingMatrix(
});
try {
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
await resolved.sendTyping(roomId, typing, resolvedTimeoutMs);
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
} finally {
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);
} finally {
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> {
await prompter.note(
[
"Matrix requires a homeserver URL + user ID.",
"Use an access token or a password (password logs in and stores a token).",
"Matrix requires a homeserver URL.",
"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.",
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
].join("\n"),
@@ -146,8 +147,8 @@ function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist"
};
}
function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
@@ -155,7 +156,7 @@ function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
matrix: {
...cfg.channels?.matrix,
enabled: true,
rooms,
groups,
},
},
};
@@ -180,9 +181,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
return {
channel,
configured,
statusLines: [`Matrix: ${configured ? "configured" : "needs homeserver + user id"}`],
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install matrix-js-sdk"
? "install matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
@@ -208,7 +211,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
const envUserId = process.env.MATRIX_USER_ID?.trim();
const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim();
const envPassword = process.env.MATRIX_PASSWORD?.trim();
const envReady = Boolean(envHomeserver && envUserId && (envAccessToken || envPassword));
const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword)));
if (
envReady &&
@@ -252,22 +255,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}),
).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 password = existing.password ?? "";
let userId = existing.userId ?? "";
if (accessToken || password) {
const keep = await prompter.confirm({
@@ -277,15 +267,17 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
if (!keep) {
accessToken = "";
password = "";
userId = "";
}
}
if (!accessToken && !password) {
// Ask auth method FIRST before asking for user ID
const authMode = (await prompter.select({
message: "Matrix auth method",
options: [
{ value: "token", label: "Access token" },
{ value: "password", label: "Password (stores token)" },
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
],
})) as "token" | "password";
@@ -296,7 +288,24 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).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 {
// 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(
await prompter.text({
message: "Matrix password",
@@ -313,6 +322,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}),
).trim();
// Ask about E2EE encryption
const enableEncryption = await prompter.confirm({
message: "Enable end-to-end encryption (E2EE)?",
initialValue: existing.encryption ?? false,
});
next = {
...next,
channels: {
@@ -321,10 +336,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
...next.channels?.matrix,
enabled: true,
homeserver,
userId,
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},
},
};
@@ -333,13 +349,14 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(next.channels?.matrix?.rooms ?? {}),
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(next.channels?.matrix?.rooms),
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
@@ -398,7 +415,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixRoomAllowlist(next, roomKeys);
next = setMatrixGroupRooms(next, roomKeys);
}
}

View File

@@ -51,12 +51,16 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: matrix-js-sdk default). */
/** Initial sync limit for startup (default: matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
/** If true, enforce allowlists for groups + DMs regardless of policy. */
allowlistOnly?: boolean;
/** Group message policy (default: allowlist). */
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). */
replyToMode?: ReplyToMode;
/** How to handle thread replies (off|inbound|always). */
@@ -72,6 +76,8 @@ export type MatrixConfig = {
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** 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>;
/** Per-action tool gating (default: true for all). */
actions?: MatrixActionConfig;

View File

@@ -104,6 +104,8 @@ export {
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
export {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,