Matrix: thread account-scoped target resolution

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 12:59:12 -04:00
parent 1cc7c6985e
commit 87834490e8
10 changed files with 411 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock("./resolve-targets.js", () => ({
resolveMatrixTargets: resolveMatrixTargetsMock,
}));
import { matrixPlugin } from "./channel.js";
describe("matrix resolver adapter", () => {
beforeEach(() => {
resolveMatrixTargetsMock.mockClear();
});
it("forwards accountId into Matrix target resolution", async () => {
await matrixPlugin.resolver?.resolveTargets({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
});
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: expect.objectContaining({
log: expect.any(Function),
error: expect.any(Function),
exit: expect.any(Function),
}),
});
});
});

View File

@@ -51,6 +51,7 @@ describe("resolveMatrixMonitorConfig", () => {
const result = await resolveMatrixMonitorConfig({
cfg: {} as CoreConfig,
accountId: "ops",
allowFrom: ["matrix:@Alice:Example.org", "Bob"],
groupAllowFrom: ["user:@Carol:Example.org"],
roomsConfig,
@@ -74,6 +75,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
accountId: "ops",
kind: "user",
inputs: ["Bob"],
}),
@@ -81,6 +83,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
accountId: "ops",
kind: "group",
inputs: ["General"],
}),
@@ -88,6 +91,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
accountId: "ops",
kind: "user",
inputs: ["Dana"],
}),
@@ -107,6 +111,7 @@ describe("resolveMatrixMonitorConfig", () => {
const result = await resolveMatrixMonitorConfig({
cfg: {} as CoreConfig,
accountId: "ops",
allowFrom: ["user:Ghost"],
groupAllowFrom: ["matrix:@known:example.org"],
roomsConfig: {
@@ -125,6 +130,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
accountId: "ops",
kind: "user",
inputs: ["Ghost"],
}),
@@ -132,6 +138,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
accountId: "ops",
kind: "group",
inputs: ["Project X"],
}),

View File

@@ -61,6 +61,7 @@ function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoo
async function resolveMatrixMonitorUserEntries(params: {
cfg: CoreConfig;
accountId?: string | null;
entries: Array<string | number>;
runtime: RuntimeEnv;
resolveTargets: ResolveMatrixTargetsFn;
@@ -93,6 +94,7 @@ async function resolveMatrixMonitorUserEntries(params: {
? []
: await params.resolveTargets({
cfg: params.cfg,
accountId: params.accountId,
inputs: pending.map((entry) => entry.query),
kind: "user",
runtime: params.runtime,
@@ -115,6 +117,7 @@ async function resolveMatrixMonitorUserEntries(params: {
async function resolveMatrixMonitorUserAllowlist(params: {
cfg: CoreConfig;
accountId?: string | null;
label: string;
list?: Array<string | number>;
runtime: RuntimeEnv;
@@ -127,6 +130,7 @@ async function resolveMatrixMonitorUserAllowlist(params: {
const resolution = await resolveMatrixMonitorUserEntries({
cfg: params.cfg,
accountId: params.accountId,
entries: allowList,
runtime: params.runtime,
resolveTargets: params.resolveTargets,
@@ -148,6 +152,7 @@ async function resolveMatrixMonitorUserAllowlist(params: {
async function resolveMatrixMonitorRoomsConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
roomsConfig?: MatrixRoomsConfig;
runtime: RuntimeEnv;
resolveTargets: ResolveMatrixTargetsFn;
@@ -193,6 +198,7 @@ async function resolveMatrixMonitorRoomsConfig(params: {
if (pending.length > 0) {
const resolved = await params.resolveTargets({
cfg: params.cfg,
accountId: params.accountId,
inputs: pending.map((entry) => entry.query),
kind: "group",
runtime: params.runtime,
@@ -231,6 +237,7 @@ async function resolveMatrixMonitorRoomsConfig(params: {
const resolution = await resolveMatrixMonitorUserEntries({
cfg: params.cfg,
accountId: params.accountId,
entries: Array.from(roomUsers),
runtime: params.runtime,
resolveTargets: params.resolveTargets,
@@ -252,6 +259,7 @@ async function resolveMatrixMonitorRoomsConfig(params: {
export async function resolveMatrixMonitorConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
roomsConfig?: MatrixRoomsConfig;
@@ -267,6 +275,7 @@ export async function resolveMatrixMonitorConfig(params: {
const [allowFrom, groupAllowFrom, roomsConfig] = await Promise.all([
resolveMatrixMonitorUserAllowlist({
cfg: params.cfg,
accountId: params.accountId,
label: "matrix dm allowlist",
list: params.allowFrom,
runtime: params.runtime,
@@ -274,6 +283,7 @@ export async function resolveMatrixMonitorConfig(params: {
}),
resolveMatrixMonitorUserAllowlist({
cfg: params.cfg,
accountId: params.accountId,
label: "matrix group allowlist",
list: params.groupAllowFrom,
runtime: params.runtime,
@@ -281,6 +291,7 @@ export async function resolveMatrixMonitorConfig(params: {
}),
resolveMatrixMonitorRoomsConfig({
cfg: params.cfg,
accountId: params.accountId,
roomsConfig: params.roomsConfig,
runtime: params.runtime,
resolveTargets,

View File

@@ -86,6 +86,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({
cfg,
accountId: effectiveAccountId,
allowFrom,
groupAllowFrom,
roomsConfig,

View File

@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../../../../src/infra/outbound/session-binding-service.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { resolveMatrixInboundRoute } from "./route.js";
const baseCfg = {
session: { mainKey: "main" },
agents: {
list: [{ id: "main" }, { id: "sender-agent" }, { id: "room-agent" }, { id: "acp-agent" }],
},
} satisfies OpenClawConfig;
function resolveDmRoute(cfg: OpenClawConfig) {
return resolveMatrixInboundRoute({
cfg,
accountId: "ops",
roomId: "!dm:example.org",
senderId: "@alice:example.org",
isDirectMessage: true,
messageId: "$msg1",
resolveAgentRoute,
});
}
describe("resolveMatrixInboundRoute", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
});
it("prefers sender-bound DM routing over DM room fallback bindings", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
{
agentId: "sender-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "direct", id: "@alice:example.org" },
},
},
],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg);
expect(configuredBinding).toBeNull();
expect(route.agentId).toBe("sender-agent");
expect(route.matchedBy).toBe("binding.peer");
expect(route.sessionKey).toBe("agent:sender-agent:main");
});
it("uses the DM room as a parent-peer fallback before account-level bindings", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "acp-agent",
match: {
channel: "matrix",
accountId: "ops",
},
},
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg);
expect(configuredBinding).toBeNull();
expect(route.agentId).toBe("room-agent");
expect(route.matchedBy).toBe("binding.peer.parent");
expect(route.sessionKey).toBe("agent:room-agent:main");
});
it("lets configured ACP room bindings override DM parent-peer routing", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
{
type: "acp",
agentId: "acp-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg);
expect(configuredBinding?.spec.agentId).toBe("acp-agent");
expect(route.agentId).toBe("acp-agent");
expect(route.matchedBy).toBe("binding.channel");
expect(route.sessionKey).toContain("agent:acp-agent:acp:binding:matrix:ops:");
});
it("lets runtime conversation bindings override both sender and room route matches", () => {
registerSessionBindingAdapter({
channel: "matrix",
accountId: "ops",
listBySession: () => [],
resolveByConversation: (ref) =>
ref.conversationId === "!dm:example.org"
? {
bindingId: "ops:!dm:example.org",
targetSessionKey: "agent:bound:session-1",
targetKind: "session",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "!dm:example.org",
},
status: "active",
boundAt: Date.now(),
metadata: { boundBy: "user-1" },
}
: null,
touch: vi.fn(),
});
const cfg = {
...baseCfg,
bindings: [
{
agentId: "sender-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "direct", id: "@alice:example.org" },
},
},
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg);
expect(configuredBinding).toBeNull();
expect(route.agentId).toBe("bound");
expect(route.matchedBy).toBe("binding.channel");
expect(route.sessionKey).toBe("agent:bound:session-1");
});
});

View File

@@ -30,6 +30,14 @@ export function resolveMatrixInboundRoute(params: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.senderId : params.roomId,
},
// Matrix DMs are still sender-addressed first, but the room ID remains a
// useful fallback binding key for generic route matching.
parentPeer: params.isDirectMessage
? {
kind: "channel",
id: params.roomId,
}
: undefined,
});
const bindingConversationId =
params.threadRootId && params.threadRootId !== params.messageId

View File

@@ -0,0 +1,112 @@
import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "./types.js";
const resolveMatrixTargetsMock = vi.hoisted(() =>
vi.fn(async () => [{ input: "Alice", resolved: true, id: "@alice:example.org" }]),
);
vi.mock("./resolve-targets.js", () => ({
resolveMatrixTargets: resolveMatrixTargetsMock,
}));
import { matrixOnboardingAdapter } from "./onboarding.js";
import { setMatrixRuntime } from "./runtime.js";
describe("matrix onboarding account-scoped resolution", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as never);
resolveMatrixTargetsMock.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("passes accountId into Matrix allowlist target resolution during onboarding", async () => {
const prompter = {
note: vi.fn(async () => {}),
select: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix already configured. What do you want to do?") {
return "add-account";
}
if (message === "Matrix auth method") {
return "token";
}
if (message === "Matrix rooms access") {
return "allowlist";
}
throw new Error(`unexpected select prompt: ${message}`);
}),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix account name") {
return "ops";
}
if (message === "Matrix homeserver URL") {
return "https://matrix.ops.example.org";
}
if (message === "Matrix access token") {
return "ops-token";
}
if (message === "Matrix device name (optional)") {
return "";
}
if (message === "Matrix allowFrom (full @user:server; display name only if unique)") {
return "Alice";
}
if (message === "Matrix rooms allowlist (comma-separated)") {
return "";
}
throw new Error(`unexpected text prompt: ${message}`);
}),
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enable end-to-end encryption (E2EE)?") {
return false;
}
if (message === "Configure Matrix rooms access?") {
return true;
}
return false;
}),
} as unknown as WizardPrompter;
const result = await matrixOnboardingAdapter.configureInteractive!({
cfg: {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.main.example.org",
accessToken: "main-token",
},
},
},
},
} as CoreConfig,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
prompter,
options: undefined,
accountOverrides: {},
shouldPromptAccountIds: true,
forceAllowFrom: true,
configured: true,
label: "Matrix",
});
expect(result).not.toBe("skip");
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
cfg: expect.any(Object),
accountId: "ops",
inputs: ["Alice"],
kind: "user",
});
});
});

View File

@@ -119,6 +119,7 @@ async function promptMatrixAllowFrom(params: {
if (pending.length > 0) {
const results = await resolveMatrixTargets({
cfg,
accountId,
inputs: pending,
kind: "user",
}).catch(() => []);

View File

@@ -28,6 +28,12 @@ describe("resolveMatrixTargets (users)", () => {
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("@alice:example.org");
expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({
cfg: {},
accountId: undefined,
query: "Alice",
limit: 5,
});
});
it("does not resolve ambiguous or non-exact matches", async () => {
@@ -63,6 +69,47 @@ describe("resolveMatrixTargets (users)", () => {
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("!two:example.org");
expect(result?.note).toBeUndefined();
expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({
cfg: {},
accountId: undefined,
query: "#team",
limit: 5,
});
});
it("threads accountId into live Matrix target lookups", async () => {
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([
{ kind: "user", id: "@alice:example.org", name: "Alice" },
]);
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([
{ kind: "group", id: "!team:example.org", name: "Team", handle: "#team" },
]);
await resolveMatrixTargets({
cfg: {},
accountId: "ops",
inputs: ["Alice"],
kind: "user",
});
await resolveMatrixTargets({
cfg: {},
accountId: "ops",
inputs: ["#team"],
kind: "group",
});
expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({
cfg: {},
accountId: "ops",
query: "Alice",
limit: 5,
});
expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({
cfg: {},
accountId: "ops",
query: "#team",
limit: 5,
});
});
it("reuses directory lookups for normalized duplicate inputs", async () => {

View File

@@ -99,6 +99,7 @@ async function readCachedMatches(
export async function resolveMatrixTargets(params: {
cfg: unknown;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime?: RuntimeEnv;
@@ -123,6 +124,7 @@ export async function resolveMatrixTargets(params: {
const matches = await readCachedMatches(userLookupCache, trimmed, (query) =>
listMatrixDirectoryPeersLive({
cfg: params.cfg,
accountId: params.accountId,
query,
limit: 5,
}),
@@ -150,6 +152,7 @@ export async function resolveMatrixTargets(params: {
const matches = await readCachedMatches(groupLookupCache, trimmed, (query) =>
listMatrixDirectoryGroupsLive({
cfg: params.cfg,
accountId: params.accountId,
query,
limit: 5,
}),