diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts new file mode 100644 index 00000000000..259c0f9e99a --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/config.test.ts @@ -0,0 +1,149 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix"; +import { describe, expect, it, vi } from "vitest"; +import type { CoreConfig, MatrixRoomConfig } from "../../types.js"; +import { resolveMatrixMonitorConfig } from "./config.js"; + +type MatrixRoomsConfig = Record; + +function createRuntime() { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + return runtime; +} + +describe("resolveMatrixMonitorConfig", () => { + it("canonicalizes resolved user aliases and room keys without keeping stale aliases", async () => { + const runtime = createRuntime(); + const resolveTargets = vi.fn( + async ({ inputs, kind }: { inputs: string[]; kind: "user" | "group" }) => { + if (kind === "user") { + return inputs.map((input) => { + if (input === "Bob") { + return { input, resolved: true, id: "@bob:example.org" }; + } + if (input === "Dana") { + return { input, resolved: true, id: "@dana:example.org" }; + } + return { input, resolved: false }; + }); + } + return inputs.map((input) => + input === "General" + ? { input, resolved: true, id: "!general:example.org" } + : { input, resolved: false }, + ); + }, + ); + + const roomsConfig: MatrixRoomsConfig = { + "*": { allow: true }, + "room:!ops:example.org": { + allow: true, + users: ["Dana", "user:@Erin:Example.org"], + }, + General: { + allow: true, + }, + }; + + const result = await resolveMatrixMonitorConfig({ + cfg: {} as CoreConfig, + allowFrom: ["matrix:@Alice:Example.org", "Bob"], + groupAllowFrom: ["user:@Carol:Example.org"], + roomsConfig, + runtime, + resolveTargets, + }); + + expect(result.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]); + expect(result.groupAllowFrom).toEqual(["@carol:example.org"]); + expect(result.roomsConfig).toEqual({ + "*": { allow: true }, + "!ops:example.org": { + allow: true, + users: ["@dana:example.org", "@erin:example.org"], + }, + "!general:example.org": { + allow: true, + }, + }); + expect(resolveTargets).toHaveBeenCalledTimes(3); + expect(resolveTargets).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + kind: "user", + inputs: ["Bob"], + }), + ); + expect(resolveTargets).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + kind: "group", + inputs: ["General"], + }), + ); + expect(resolveTargets).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + kind: "user", + inputs: ["Dana"], + }), + ); + }); + + it("strips config prefixes before lookups and logs unresolved guidance once per section", async () => { + const runtime = createRuntime(); + const resolveTargets = vi.fn( + async ({ kind, inputs }: { inputs: string[]; kind: "user" | "group" }) => + inputs.map((input) => ({ + input, + resolved: false, + ...(kind === "group" ? { note: `missing ${input}` } : {}), + })), + ); + + const result = await resolveMatrixMonitorConfig({ + cfg: {} as CoreConfig, + allowFrom: ["user:Ghost"], + groupAllowFrom: ["matrix:@known:example.org"], + roomsConfig: { + "channel:Project X": { + allow: true, + users: ["matrix:Ghost"], + }, + }, + runtime, + resolveTargets, + }); + + expect(result.allowFrom).toEqual([]); + expect(result.groupAllowFrom).toEqual(["@known:example.org"]); + expect(result.roomsConfig).toEqual({}); + expect(resolveTargets).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + kind: "user", + inputs: ["Ghost"], + }), + ); + expect(resolveTargets).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + kind: "group", + inputs: ["Project X"], + }), + ); + expect(resolveTargets).toHaveBeenCalledTimes(2); + expect(runtime.log).toHaveBeenCalledWith("matrix dm allowlist unresolved: user:Ghost"); + expect(runtime.log).toHaveBeenCalledWith( + "matrix dm allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.", + ); + expect(runtime.log).toHaveBeenCalledWith("matrix rooms unresolved: channel:Project X"); + expect(runtime.log).toHaveBeenCalledWith( + "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts new file mode 100644 index 00000000000..bc711b7fa51 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -0,0 +1,295 @@ +import { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + patchAllowlistUsersInConfigEntries, + summarizeMapping, + type RuntimeEnv, +} from "openclaw/plugin-sdk/matrix"; +import { resolveMatrixTargets } from "../../resolve-targets.js"; +import type { CoreConfig, MatrixRoomConfig } from "../../types.js"; +import { normalizeMatrixUserId } from "./allowlist.js"; + +type MatrixRoomsConfig = Record; +type ResolveMatrixTargetsFn = typeof resolveMatrixTargets; + +function normalizeMatrixUserLookupEntry(raw: string): string { + return raw + .replace(/^matrix:/i, "") + .replace(/^user:/i, "") + .trim(); +} + +function normalizeMatrixRoomLookupEntry(raw: string): string { + return raw + .replace(/^matrix:/i, "") + .replace(/^(room|channel):/i, "") + .trim(); +} + +function isMatrixQualifiedUserId(value: string): boolean { + return value.startsWith("@") && value.includes(":"); +} + +function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] { + return entries.filter((entry) => { + const trimmed = entry.trim(); + if (!trimmed) { + return false; + } + if (trimmed === "*") { + return true; + } + return isMatrixQualifiedUserId(normalizeMatrixUserLookupEntry(trimmed)); + }); +} + +function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoomsConfig { + const nextEntries: MatrixRoomsConfig = { ...entries }; + for (const [roomKey, roomConfig] of Object.entries(entries)) { + const users = roomConfig?.users; + if (!Array.isArray(users)) { + continue; + } + nextEntries[roomKey] = { + ...roomConfig, + users: filterResolvedMatrixAllowlistEntries(users.map(String)), + }; + } + return nextEntries; +} + +async function resolveMatrixMonitorUserEntries(params: { + cfg: CoreConfig; + entries: Array; + runtime: RuntimeEnv; + resolveTargets: ResolveMatrixTargetsFn; +}) { + const directMatches: Array<{ input: string; resolved: boolean; id?: string }> = []; + const pending: Array<{ input: string; query: string }> = []; + + for (const entry of params.entries) { + const input = String(entry).trim(); + if (!input) { + continue; + } + const query = normalizeMatrixUserLookupEntry(input); + if (!query || query === "*") { + continue; + } + if (isMatrixQualifiedUserId(query)) { + directMatches.push({ + input, + resolved: true, + id: normalizeMatrixUserId(query), + }); + continue; + } + pending.push({ input, query }); + } + + const pendingResolved = + pending.length === 0 + ? [] + : await params.resolveTargets({ + cfg: params.cfg, + inputs: pending.map((entry) => entry.query), + kind: "user", + runtime: params.runtime, + }); + + pendingResolved.forEach((entry, index) => { + const source = pending[index]; + if (!source) { + return; + } + directMatches.push({ + input: source.input, + resolved: entry.resolved, + id: entry.id ? normalizeMatrixUserId(entry.id) : undefined, + }); + }); + + return buildAllowlistResolutionSummary(directMatches); +} + +async function resolveMatrixMonitorUserAllowlist(params: { + cfg: CoreConfig; + label: string; + list?: Array; + runtime: RuntimeEnv; + resolveTargets: ResolveMatrixTargetsFn; +}): Promise { + const allowList = (params.list ?? []).map(String); + if (allowList.length === 0) { + return allowList; + } + + const resolution = await resolveMatrixMonitorUserEntries({ + cfg: params.cfg, + entries: allowList, + runtime: params.runtime, + resolveTargets: params.resolveTargets, + }); + const canonicalized = canonicalizeAllowlistWithResolvedIds({ + existing: allowList, + resolvedMap: resolution.resolvedMap, + }); + + summarizeMapping(params.label, resolution.mapping, resolution.unresolved, params.runtime); + if (resolution.unresolved.length > 0) { + params.runtime.log?.( + `${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, + ); + } + + return filterResolvedMatrixAllowlistEntries(canonicalized); +} + +async function resolveMatrixMonitorRoomsConfig(params: { + cfg: CoreConfig; + roomsConfig?: MatrixRoomsConfig; + runtime: RuntimeEnv; + resolveTargets: ResolveMatrixTargetsFn; +}): Promise { + const roomsConfig = params.roomsConfig; + if (!roomsConfig || Object.keys(roomsConfig).length === 0) { + return roomsConfig; + } + + const mapping: string[] = []; + const unresolved: string[] = []; + const nextRooms: MatrixRoomsConfig = {}; + if (roomsConfig["*"]) { + nextRooms["*"] = roomsConfig["*"]; + } + + const pending: Array<{ input: string; query: string; config: MatrixRoomConfig }> = []; + for (const [entry, roomConfig] of Object.entries(roomsConfig)) { + if (entry === "*") { + continue; + } + const input = entry.trim(); + if (!input) { + continue; + } + const cleaned = normalizeMatrixRoomLookupEntry(input); + if (!cleaned) { + unresolved.push(entry); + continue; + } + if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) { + if (!nextRooms[cleaned]) { + nextRooms[cleaned] = roomConfig; + } + if (cleaned !== input) { + mapping.push(`${input}→${cleaned}`); + } + continue; + } + pending.push({ input, query: cleaned, config: roomConfig }); + } + + if (pending.length > 0) { + const resolved = await params.resolveTargets({ + cfg: params.cfg, + inputs: pending.map((entry) => entry.query), + kind: "group", + runtime: params.runtime, + }); + resolved.forEach((entry, index) => { + const source = pending[index]; + if (!source) { + return; + } + if (entry.resolved && entry.id) { + const roomKey = normalizeMatrixRoomLookupEntry(entry.id); + if (!nextRooms[roomKey]) { + nextRooms[roomKey] = source.config; + } + mapping.push(`${source.input}→${roomKey}`); + } else { + unresolved.push(source.input); + } + }); + } + + summarizeMapping("matrix rooms", mapping, unresolved, params.runtime); + if (unresolved.length > 0) { + params.runtime.log?.( + "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", + ); + } + + const roomUsers = new Set(); + for (const roomConfig of Object.values(nextRooms)) { + addAllowlistUserEntriesFromConfigEntry(roomUsers, roomConfig); + } + if (roomUsers.size === 0) { + return nextRooms; + } + + const resolution = await resolveMatrixMonitorUserEntries({ + cfg: params.cfg, + entries: Array.from(roomUsers), + runtime: params.runtime, + resolveTargets: params.resolveTargets, + }); + summarizeMapping("matrix room users", resolution.mapping, resolution.unresolved, params.runtime); + if (resolution.unresolved.length > 0) { + params.runtime.log?.( + "matrix room users entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.", + ); + } + + const patched = patchAllowlistUsersInConfigEntries({ + entries: nextRooms, + resolvedMap: resolution.resolvedMap, + strategy: "canonicalize", + }); + return sanitizeMatrixRoomUserAllowlists(patched); +} + +export async function resolveMatrixMonitorConfig(params: { + cfg: CoreConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + roomsConfig?: MatrixRoomsConfig; + runtime: RuntimeEnv; + resolveTargets?: ResolveMatrixTargetsFn; +}): Promise<{ + allowFrom: string[]; + groupAllowFrom: string[]; + roomsConfig?: MatrixRoomsConfig; +}> { + const resolveTargets = params.resolveTargets ?? resolveMatrixTargets; + + const [allowFrom, groupAllowFrom, roomsConfig] = await Promise.all([ + resolveMatrixMonitorUserAllowlist({ + cfg: params.cfg, + label: "matrix dm allowlist", + list: params.allowFrom, + runtime: params.runtime, + resolveTargets, + }), + resolveMatrixMonitorUserAllowlist({ + cfg: params.cfg, + label: "matrix group allowlist", + list: params.groupAllowFrom, + runtime: params.runtime, + resolveTargets, + }), + resolveMatrixMonitorRoomsConfig({ + cfg: params.cfg, + roomsConfig: params.roomsConfig, + runtime: params.runtime, + resolveTargets, + }), + ]); + + return { + allowFrom, + groupAllowFrom, + roomsConfig, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index e91aae25e2c..da0d8c99547 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,16 +1,13 @@ import { format } from "node:util"; import { GROUP_POLICY_BLOCKED_LABEL, - mergeAllowlist, resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, type RuntimeEnv, } from "openclaw/plugin-sdk/matrix"; -import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; import { resolveMatrixAccount } from "../accounts.js"; @@ -26,8 +23,8 @@ import { updateMatrixAccountConfig } from "../config-update.js"; import { summarizeMatrixDeviceHealth } from "../device-health.js"; import { syncMatrixOwnProfile } from "../profile.js"; import { createMatrixThreadBindingManager } from "../thread-bindings.js"; -import { normalizeMatrixUserId } from "./allowlist.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; +import { resolveMatrixMonitorConfig } from "./config.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; @@ -76,69 +73,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi logger.debug?.(message); }; - const normalizeUserEntry = (raw: string) => - raw - .replace(/^matrix:/i, "") - .replace(/^user:/i, "") - .trim(); - const normalizeRoomEntry = (raw: string) => - raw - .replace(/^matrix:/i, "") - .replace(/^(room|channel):/i, "") - .trim(); - const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); - const resolveUserAllowlist = async ( - label: string, - list?: Array, - ): Promise => { - let allowList = list ?? []; - if (allowList.length === 0) { - return allowList.map(String); - } - const entries = allowList - .map((entry) => normalizeUserEntry(String(entry))) - .filter((entry) => entry && entry !== "*"); - if (entries.length === 0) { - return allowList.map(String); - } - const mapping: string[] = []; - const unresolved: string[] = []; - const additions: string[] = []; - const pending: string[] = []; - for (const entry of entries) { - if (isMatrixUserId(entry)) { - additions.push(normalizeMatrixUserId(entry)); - continue; - } - pending.push(entry); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - runtime, - }); - for (const entry of resolved) { - if (entry.resolved && entry.id) { - const normalizedId = normalizeMatrixUserId(entry.id); - additions.push(normalizedId); - mapping.push(`${entry.input}→${normalizedId}`); - } else { - unresolved.push(entry.input); - } - } - } - allowList = mergeAllowlist({ existing: allowList, additions }); - summarizeMapping(label, mapping, unresolved, runtime); - if (unresolved.length > 0) { - runtime.log?.( - `${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, - ); - } - return allowList.map(String); - }; - const authContext = resolveMatrixAuthContext({ cfg, accountId: opts.accountId, @@ -154,82 +88,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); let roomsConfig = accountConfig.groups ?? accountConfig.rooms; - allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom); - groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); - - if (roomsConfig && Object.keys(roomsConfig).length > 0) { - const mapping: string[] = []; - const unresolved: string[] = []; - const nextRooms: Record = {}; - if (roomsConfig["*"]) { - nextRooms["*"] = roomsConfig["*"]; - } - const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> = - []; - for (const [entry, roomConfig] of Object.entries(roomsConfig)) { - if (entry === "*") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = normalizeRoomEntry(trimmed); - if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) { - if (!nextRooms[cleaned]) { - nextRooms[cleaned] = roomConfig; - } - if (cleaned !== entry) { - mapping.push(`${entry}→${cleaned}`); - } - continue; - } - pending.push({ input: entry, query: trimmed, config: roomConfig }); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg, - inputs: pending.map((entry) => entry.query), - kind: "group", - runtime, - }); - resolved.forEach((entry, index) => { - const source = pending[index]; - if (!source) { - return; - } - if (entry.resolved && entry.id) { - if (!nextRooms[entry.id]) { - nextRooms[entry.id] = source.config; - } - mapping.push(`${source.input}→${entry.id}`); - } else { - unresolved.push(source.input); - } - }); - } - roomsConfig = nextRooms; - summarizeMapping("matrix rooms", mapping, unresolved, runtime); - if (unresolved.length > 0) { - runtime.log?.( - "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", - ); - } - } - if (roomsConfig && Object.keys(roomsConfig).length > 0) { - const nextRooms = { ...roomsConfig }; - for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) { - const users = roomConfig?.users ?? []; - if (users.length === 0) { - continue; - } - const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users); - if (resolvedUsers !== users) { - nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers }; - } - } - roomsConfig = nextRooms; - } + ({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ + cfg, + allowFrom, + groupAllowFrom, + roomsConfig, + runtime, + })); cfg = { ...cfg,