mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 13:40:20 +00:00
fix(matrix): align DM room session routing (#61373)
Merged via squash.
Prepared head SHA: 9529d2e161
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
55192e2d51
commit
dcd0cf9f98
@@ -31,6 +31,18 @@ describe("MatrixConfigSchema SecretInput", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts dm sessionScope overrides", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts room-level account assignments", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const MatrixConfigSchema = z.object({
|
||||
autoJoinAllowlist: AllowFromListSchema,
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
dm: buildNestedDmConfigSchema({
|
||||
sessionScope: z.enum(["per-user", "per-room"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
}),
|
||||
execApprovals: matrixExecApprovalsSchema,
|
||||
|
||||
@@ -31,6 +31,7 @@ type MatrixHandlerTestHarnessOptions = {
|
||||
replyToMode?: ReplyToMode;
|
||||
threadReplies?: "off" | "inbound" | "always";
|
||||
dmThreadReplies?: "off" | "inbound" | "always";
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
streaming?: "partial" | "off";
|
||||
blockStreamingEnabled?: boolean;
|
||||
dmEnabled?: boolean;
|
||||
@@ -214,6 +215,7 @@ export function createMatrixHandlerTestHarness(
|
||||
replyToMode: options.replyToMode ?? "off",
|
||||
threadReplies: options.threadReplies ?? "inbound",
|
||||
dmThreadReplies: options.dmThreadReplies,
|
||||
dmSessionScope: options.dmSessionScope,
|
||||
streaming: options.streaming ?? "off",
|
||||
blockStreamingEnabled: options.blockStreamingEnabled ?? false,
|
||||
dmEnabled: options.dmEnabled ?? true,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
registerSessionBindingAdapter,
|
||||
@@ -682,6 +686,423 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("posts a one-time notice when another Matrix DM room already owns the shared DM session", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:main",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:main",
|
||||
AccountId: "ops",
|
||||
ChatType: "direct",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:@user:example.org",
|
||||
To: "room:!other:example.org",
|
||||
NativeChannelId: "!other:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!other:example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).toHaveBeenCalledWith(
|
||||
"!dm:example.org",
|
||||
expect.objectContaining({
|
||||
msgtype: "m.notice",
|
||||
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm2",
|
||||
body: "again",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("checks flat DM collision notices against the current DM session key", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-flat-notice-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:matrix:direct:@user:example.org",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:matrix:direct:@user:example.org",
|
||||
AccountId: "ops",
|
||||
ChatType: "direct",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:@user:example.org",
|
||||
To: "room:!other:example.org",
|
||||
NativeChannelId: "!other:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!other:example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
resolveAgentRoute: () => ({
|
||||
agentId: "ops",
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
sessionKey: "agent:ops:matrix:direct:@user:example.org",
|
||||
mainSessionKey: "agent:ops:main",
|
||||
matchedBy: "binding.account" as const,
|
||||
}),
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm-flat-1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).toHaveBeenCalledWith(
|
||||
"!dm:example.org",
|
||||
expect.objectContaining({
|
||||
msgtype: "m.notice",
|
||||
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("checks threaded DM collision notices against the parent DM session", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-thread-notice-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:main",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:main",
|
||||
AccountId: "ops",
|
||||
ChatType: "direct",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:@user:example.org",
|
||||
To: "room:!other:example.org",
|
||||
NativeChannelId: "!other:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!other:example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
threadReplies: "always",
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
getEvent: async (_roomId, eventId) =>
|
||||
eventId === "$root"
|
||||
? createMatrixTextMessageEvent({
|
||||
eventId: "$root",
|
||||
sender: "@alice:example.org",
|
||||
body: "Root topic",
|
||||
})
|
||||
: ({ sender: "@bot:example.org" } as never),
|
||||
},
|
||||
getMemberDisplayName: async (_roomId, userId) =>
|
||||
userId === "@alice:example.org" ? "Alice" : "sender",
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$reply1",
|
||||
body: "follow up",
|
||||
relatesTo: {
|
||||
rel_type: "m.thread",
|
||||
event_id: "$root",
|
||||
"m.in_reply_to": { event_id: "$root" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).toHaveBeenCalledWith(
|
||||
"!dm:example.org",
|
||||
expect.objectContaining({
|
||||
msgtype: "m.notice",
|
||||
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the shared-session notice after user-target outbound metadata overwrites latest room fields", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-stable-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:main",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:main",
|
||||
AccountId: "ops",
|
||||
ChatType: "direct",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:@user:example.org",
|
||||
To: "room:!other:example.org",
|
||||
NativeChannelId: "!other:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!other:example.org",
|
||||
},
|
||||
});
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:main",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:main",
|
||||
AccountId: "ops",
|
||||
ChatType: "direct",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:@other:example.org",
|
||||
To: "room:@other:example.org",
|
||||
NativeDirectUserId: "@user:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:@other:example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).toHaveBeenCalledWith(
|
||||
"!dm:example.org",
|
||||
expect.objectContaining({
|
||||
msgtype: "m.notice",
|
||||
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the shared-session notice when the prior Matrix session metadata is not a DM", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-room-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
await recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: "agent:ops:main",
|
||||
ctx: {
|
||||
SessionKey: "agent:ops:main",
|
||||
AccountId: "ops",
|
||||
ChatType: "group",
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
From: "matrix:channel:!group:example.org",
|
||||
To: "room:!group:example.org",
|
||||
NativeChannelId: "!group:example.org",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!group:example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the shared-session notice when Matrix DMs are isolated per room", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-room-scope-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:ops:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!other:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
|
||||
try {
|
||||
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
dmSessionScope: "per-room",
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).not.toHaveBeenCalled();
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:ops:matrix:channel:!dm:example.org",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the shared-session notice when a Matrix DM is explicitly bound", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-bound-notice-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:bound:session-1": {
|
||||
sessionId: "sess-bound",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!other:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const sendNotice = vi.fn(async () => "$notice");
|
||||
const touch = vi.fn();
|
||||
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,
|
||||
});
|
||||
|
||||
try {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
sendMessage: sendNotice,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!dm:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$dm-bound-1",
|
||||
body: "follow up",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendNotice).not.toHaveBeenCalled();
|
||||
expect(touch).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses stable room ids instead of room-declared aliases in group context", async () => {
|
||||
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
|
||||
isDirectMessage: false,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveChannelContextVisibilityMode,
|
||||
resolveSessionStoreEntry,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
sendReadReceiptMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "../send.js";
|
||||
import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
|
||||
import { resolveMatrixMonitorAccessState } from "./access-state.js";
|
||||
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
|
||||
import { resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
@@ -68,6 +73,7 @@ import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
|
||||
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
|
||||
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
|
||||
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
|
||||
const MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES = 512;
|
||||
type MatrixAllowBotsMode = "off" | "mentions" | "all";
|
||||
|
||||
export type MatrixMonitorHandlerParams = {
|
||||
@@ -88,6 +94,8 @@ export type MatrixMonitorHandlerParams = {
|
||||
threadReplies: "off" | "inbound" | "always";
|
||||
/** DM-specific threadReplies override. Falls back to threadReplies when absent. */
|
||||
dmThreadReplies?: "off" | "inbound" | "always";
|
||||
/** DM session grouping behavior. */
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
streaming: "partial" | "off";
|
||||
blockStreamingEnabled: boolean;
|
||||
dmEnabled: boolean;
|
||||
@@ -163,6 +171,73 @@ function resolveMatrixInboundBodyText(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function markTrackedRoomIfFirst(set: Set<string>, roomId: string): boolean {
|
||||
if (set.has(roomId)) {
|
||||
return false;
|
||||
}
|
||||
set.add(roomId);
|
||||
if (set.size > MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES) {
|
||||
const oldest = set.keys().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveMatrixSharedDmContextNotice(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
roomId: string;
|
||||
accountId: string;
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
sentRooms: Set<string>;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): string | null {
|
||||
if ((params.dmSessionScope ?? "per-user") === "per-room") {
|
||||
return null;
|
||||
}
|
||||
if (params.sentRooms.has(params.roomId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const currentSession = resolveMatrixStoredSessionMeta(
|
||||
resolveSessionStoreEntry({
|
||||
store,
|
||||
sessionKey: params.sessionKey,
|
||||
}).existing,
|
||||
);
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
if (currentSession.channel && currentSession.channel !== "matrix") {
|
||||
return null;
|
||||
}
|
||||
if (currentSession.accountId && currentSession.accountId !== params.accountId) {
|
||||
return null;
|
||||
}
|
||||
if (!currentSession.directUserId) {
|
||||
return null;
|
||||
}
|
||||
if (!currentSession.roomId || currentSession.roomId === params.roomId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
"This Matrix DM is sharing a session with another Matrix DM room.",
|
||||
"Use /focus here for a one-off isolated thread session when thread bindings are enabled, or set",
|
||||
"channels.matrix.dm.sessionScope to per-room to isolate each Matrix DM room.",
|
||||
].join(" ");
|
||||
} catch (err) {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed checking shared DM session notice room=${params.roomId} (${String(err)})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixPendingHistoryText(params: {
|
||||
mentionPrecheckText: string;
|
||||
content: RoomMessageEventContent;
|
||||
@@ -214,6 +289,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
replyToMode,
|
||||
threadReplies,
|
||||
dmThreadReplies,
|
||||
dmSessionScope,
|
||||
streaming,
|
||||
blockStreamingEnabled,
|
||||
dmEnabled,
|
||||
@@ -252,6 +328,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
const roomHistoryTracker = createRoomHistoryTracker();
|
||||
const roomIngressTails = new Map<string, Promise<void>>();
|
||||
const sharedDmContextNoticeRooms = new Set<string>();
|
||||
|
||||
const readStoreAllowFrom = async (): Promise<string[]> => {
|
||||
const now = Date.now();
|
||||
@@ -672,10 +749,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
roomId,
|
||||
senderId,
|
||||
isDirectMessage,
|
||||
dmSessionScope,
|
||||
threadId: thread.threadId,
|
||||
eventTs: eventTs ?? undefined,
|
||||
resolveAgentRoute: core.channel.routing.resolveAgentRoute,
|
||||
});
|
||||
const hasExplicitSessionBinding = _configuredBinding !== null || _runtimeBindingId !== null;
|
||||
const agentMentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, _route.agentId);
|
||||
const selfDisplayName = content.formatted_body
|
||||
? await getMemberDisplayName(roomId, selfUserId).catch(() => undefined)
|
||||
@@ -870,6 +949,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
|
||||
return {
|
||||
route: _route,
|
||||
hasExplicitSessionBinding,
|
||||
roomConfig,
|
||||
isDirectMessage,
|
||||
isRoom,
|
||||
@@ -922,6 +1002,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
|
||||
const {
|
||||
route: _route,
|
||||
hasExplicitSessionBinding,
|
||||
roomConfig,
|
||||
isDirectMessage,
|
||||
isRoom,
|
||||
@@ -1023,6 +1104,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
storePath,
|
||||
sessionKey: _route.sessionKey,
|
||||
});
|
||||
const sharedDmNoticeSessionKey = threadTarget
|
||||
? _route.mainSessionKey || _route.sessionKey
|
||||
: _route.sessionKey;
|
||||
const sharedDmContextNotice = isDirectMessage
|
||||
? hasExplicitSessionBinding
|
||||
? null
|
||||
: resolveMatrixSharedDmContextNotice({
|
||||
storePath,
|
||||
sessionKey: sharedDmNoticeSessionKey,
|
||||
roomId,
|
||||
accountId: _route.accountId,
|
||||
dmSessionScope,
|
||||
sentRooms: sharedDmContextNoticeRooms,
|
||||
logVerboseMessage,
|
||||
})
|
||||
: null;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
@@ -1065,6 +1162,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
...locationPayload?.context,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "text" as const,
|
||||
NativeChannelId: roomId,
|
||||
NativeDirectUserId: isDirectMessage ? senderId : undefined,
|
||||
OriginatingChannel: "matrix" as const,
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
@@ -1090,6 +1189,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
},
|
||||
});
|
||||
|
||||
if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) {
|
||||
client
|
||||
.sendMessage(roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: sharedDmContextNotice,
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerboseMessage(
|
||||
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const dmSessionScope = dmConfig?.sessionScope ?? "per-user";
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix", effectiveAccountId);
|
||||
const globalGroupChatHistoryLimit = (
|
||||
cfg.messages as { groupChat?: { historyLimit?: number } } | undefined
|
||||
@@ -271,6 +272,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
replyToMode,
|
||||
threadReplies,
|
||||
dmThreadReplies,
|
||||
dmSessionScope,
|
||||
streaming,
|
||||
blockStreamingEnabled,
|
||||
dmEnabled,
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function handleInboundMatrixReaction(params: {
|
||||
roomId: params.roomId,
|
||||
senderId: params.senderId,
|
||||
isDirectMessage: params.isDirectMessage,
|
||||
dmSessionScope: accountConfig.dm?.sessionScope ?? "per-user",
|
||||
threadId: thread.threadId,
|
||||
eventTs: params.event.origin_server_ts,
|
||||
resolveAgentRoute: params.core.channel.routing.resolveAgentRoute,
|
||||
|
||||
@@ -17,13 +17,19 @@ const baseCfg = {
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
function resolveDmRoute(cfg: OpenClawConfig) {
|
||||
function resolveDmRoute(
|
||||
cfg: OpenClawConfig,
|
||||
opts: {
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
} = {},
|
||||
) {
|
||||
return resolveMatrixInboundRoute({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
roomId: "!dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
isDirectMessage: true,
|
||||
dmSessionScope: opts.dmSessionScope,
|
||||
resolveAgentRoute,
|
||||
});
|
||||
}
|
||||
@@ -97,6 +103,33 @@ describe("resolveMatrixInboundRoute", () => {
|
||||
expect(route.sessionKey).toBe("agent:room-agent:main");
|
||||
});
|
||||
|
||||
it("can isolate Matrix DMs per room without changing agent selection", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
agentId: "sender-agent",
|
||||
match: {
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
peer: { kind: "direct", id: "@alice:example.org" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const { route, configuredBinding } = resolveDmRoute(cfg, {
|
||||
dmSessionScope: "per-room",
|
||||
});
|
||||
|
||||
expect(configuredBinding).toBeNull();
|
||||
expect(route.agentId).toBe("sender-agent");
|
||||
expect(route.matchedBy).toBe("binding.peer");
|
||||
expect(route.sessionKey).toBe("agent:sender-agent:matrix:channel:!dm:example.org");
|
||||
expect(route.mainSessionKey).toBe("agent:sender-agent:main");
|
||||
expect(route.lastRoutePolicy).toBe("session");
|
||||
});
|
||||
|
||||
it("lets configured ACP room bindings override DM parent-peer routing", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
@@ -130,6 +163,42 @@ describe("resolveMatrixInboundRoute", () => {
|
||||
expect(route.lastRoutePolicy).toBe("session");
|
||||
});
|
||||
|
||||
it("keeps configured ACP room bindings ahead of per-room DM session scope", () => {
|
||||
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, {
|
||||
dmSessionScope: "per-room",
|
||||
});
|
||||
|
||||
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:");
|
||||
expect(route.sessionKey).not.toBe("agent:acp-agent:matrix:channel:!dm:example.org");
|
||||
expect(route.lastRoutePolicy).toBe("session");
|
||||
});
|
||||
|
||||
it("lets runtime conversation bindings override both sender and room route matches", () => {
|
||||
const touch = vi.fn();
|
||||
registerSessionBindingAdapter({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
||||
import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
resolveAgentIdFromSessionKey,
|
||||
@@ -10,12 +10,41 @@ import { resolveMatrixThreadSessionKeys } from "./threads.js";
|
||||
|
||||
type MatrixResolvedRoute = ReturnType<PluginRuntime["channel"]["routing"]["resolveAgentRoute"]>;
|
||||
|
||||
function resolveMatrixDmSessionKey(params: {
|
||||
accountId: string;
|
||||
agentId: string;
|
||||
roomId: string;
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
fallbackSessionKey: string;
|
||||
}): string {
|
||||
if (params.dmSessionScope !== "per-room") {
|
||||
return params.fallbackSessionKey;
|
||||
}
|
||||
return buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: params.roomId,
|
||||
},
|
||||
}).toLowerCase();
|
||||
}
|
||||
|
||||
function shouldApplyMatrixPerRoomDmSessionScope(params: {
|
||||
isDirectMessage: boolean;
|
||||
configuredSessionKey?: string;
|
||||
}): boolean {
|
||||
return params.isDirectMessage && !params.configuredSessionKey;
|
||||
}
|
||||
|
||||
export function resolveMatrixInboundRoute(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
isDirectMessage: boolean;
|
||||
dmSessionScope?: "per-user" | "per-room";
|
||||
threadId?: string;
|
||||
eventTs?: number;
|
||||
resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"];
|
||||
@@ -98,21 +127,42 @@ export function resolveMatrixInboundRoute(params: {
|
||||
}
|
||||
: baseRoute;
|
||||
|
||||
const dmSessionKey = shouldApplyMatrixPerRoomDmSessionScope({
|
||||
isDirectMessage: params.isDirectMessage,
|
||||
configuredSessionKey,
|
||||
})
|
||||
? resolveMatrixDmSessionKey({
|
||||
accountId: params.accountId,
|
||||
agentId: effectiveRoute.agentId,
|
||||
roomId: params.roomId,
|
||||
dmSessionScope: params.dmSessionScope,
|
||||
fallbackSessionKey: effectiveRoute.sessionKey,
|
||||
})
|
||||
: effectiveRoute.sessionKey;
|
||||
const routeWithDmScope =
|
||||
dmSessionKey === effectiveRoute.sessionKey
|
||||
? effectiveRoute
|
||||
: {
|
||||
...effectiveRoute,
|
||||
sessionKey: dmSessionKey,
|
||||
lastRoutePolicy: "session" as const,
|
||||
};
|
||||
|
||||
// When no binding overrides the session key, isolate threads into their own sessions.
|
||||
if (!configuredBinding && !configuredSessionKey && params.threadId) {
|
||||
const threadKeys = resolveMatrixThreadSessionKeys({
|
||||
baseSessionKey: effectiveRoute.sessionKey,
|
||||
baseSessionKey: routeWithDmScope.sessionKey,
|
||||
threadId: params.threadId,
|
||||
parentSessionKey: effectiveRoute.sessionKey,
|
||||
parentSessionKey: routeWithDmScope.sessionKey,
|
||||
});
|
||||
return {
|
||||
route: {
|
||||
...effectiveRoute,
|
||||
...routeWithDmScope,
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
mainSessionKey: threadKeys.parentSessionKey ?? effectiveRoute.sessionKey,
|
||||
mainSessionKey: threadKeys.parentSessionKey ?? routeWithDmScope.sessionKey,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
mainSessionKey: threadKeys.parentSessionKey ?? effectiveRoute.sessionKey,
|
||||
mainSessionKey: threadKeys.parentSessionKey ?? routeWithDmScope.sessionKey,
|
||||
}),
|
||||
},
|
||||
configuredBinding,
|
||||
@@ -121,7 +171,7 @@ export function resolveMatrixInboundRoute(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
route: effectiveRoute,
|
||||
route: routeWithDmScope,
|
||||
configuredBinding,
|
||||
runtimeBindingId: null,
|
||||
};
|
||||
|
||||
108
extensions/matrix/src/matrix/session-store-metadata.ts
Normal file
108
extensions/matrix/src/matrix/session-store-metadata.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity } from "./target-ids.js";
|
||||
|
||||
export function trimMaybeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveMatrixRoomTargetId(value: unknown): string | undefined {
|
||||
const trimmed = trimMaybeString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const target = resolveMatrixTargetIdentity(trimmed);
|
||||
return target?.kind === "room" && target.id.startsWith("!") ? target.id : undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixSessionAccountId(value: unknown): string | undefined {
|
||||
const trimmed = trimMaybeString(value);
|
||||
return trimmed ? normalizeAccountId(trimmed) : undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixStoredRoomId(params: {
|
||||
deliveryTo?: unknown;
|
||||
lastTo?: unknown;
|
||||
originNativeChannelId?: unknown;
|
||||
originTo?: unknown;
|
||||
}): string | undefined {
|
||||
return (
|
||||
resolveMatrixRoomTargetId(params.deliveryTo) ??
|
||||
resolveMatrixRoomTargetId(params.lastTo) ??
|
||||
resolveMatrixRoomTargetId(params.originNativeChannelId) ??
|
||||
resolveMatrixRoomTargetId(params.originTo)
|
||||
);
|
||||
}
|
||||
|
||||
type MatrixStoredSessionEntryLike = {
|
||||
deliveryContext?: {
|
||||
channel?: unknown;
|
||||
to?: unknown;
|
||||
accountId?: unknown;
|
||||
};
|
||||
origin?: {
|
||||
provider?: unknown;
|
||||
from?: unknown;
|
||||
to?: unknown;
|
||||
nativeChannelId?: unknown;
|
||||
nativeDirectUserId?: unknown;
|
||||
accountId?: unknown;
|
||||
chatType?: unknown;
|
||||
};
|
||||
lastChannel?: unknown;
|
||||
lastTo?: unknown;
|
||||
lastAccountId?: unknown;
|
||||
chatType?: unknown;
|
||||
};
|
||||
|
||||
export function resolveMatrixStoredSessionMeta(entry?: MatrixStoredSessionEntryLike): {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
roomId?: string;
|
||||
directUserId?: string;
|
||||
} | null {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const channel =
|
||||
trimMaybeString(entry.deliveryContext?.channel) ??
|
||||
trimMaybeString(entry.lastChannel) ??
|
||||
trimMaybeString(entry.origin?.provider);
|
||||
const accountId =
|
||||
resolveMatrixSessionAccountId(
|
||||
entry.deliveryContext?.accountId ?? entry.lastAccountId ?? entry.origin?.accountId,
|
||||
) ?? undefined;
|
||||
const roomId = resolveMatrixStoredRoomId({
|
||||
deliveryTo: entry.deliveryContext?.to,
|
||||
lastTo: entry.lastTo,
|
||||
originNativeChannelId: entry.origin?.nativeChannelId,
|
||||
originTo: entry.origin?.to,
|
||||
});
|
||||
const chatType =
|
||||
trimMaybeString(entry.origin?.chatType) ?? trimMaybeString(entry.chatType) ?? undefined;
|
||||
const directUserId =
|
||||
chatType === "direct"
|
||||
? (trimMaybeString(entry.origin?.nativeDirectUserId) ??
|
||||
resolveMatrixDirectUserId({
|
||||
from: trimMaybeString(entry.origin?.from),
|
||||
to:
|
||||
(roomId ? `room:${roomId}` : undefined) ??
|
||||
trimMaybeString(entry.deliveryContext?.to) ??
|
||||
trimMaybeString(entry.lastTo) ??
|
||||
trimMaybeString(entry.origin?.to),
|
||||
chatType,
|
||||
}))
|
||||
: undefined;
|
||||
if (!channel && !accountId && !roomId && !directUserId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(channel ? { channel } : {}),
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(roomId ? { roomId } : {}),
|
||||
...(directUserId ? { directUserId } : {}),
|
||||
};
|
||||
}
|
||||
483
extensions/matrix/src/session-route.test.ts
Normal file
483
extensions/matrix/src/session-route.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
function createTempStore(entries: Record<string, unknown>): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-route-"));
|
||||
tempDirs.add(tempDir);
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
fs.writeFileSync(storePath, JSON.stringify(entries), "utf8");
|
||||
return storePath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("resolveMatrixOutboundSessionRoute", () => {
|
||||
it("reuses the current DM room session for same-user sends when Matrix DMs are per-room", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "ops",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
peer: { kind: "channel", id: "!dm:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to user-scoped routing when the current session is for another DM peer", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@bob:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "ops",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:main",
|
||||
baseSessionKey: "agent:main:main",
|
||||
peer: { kind: "direct", id: "@alice:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:@alice:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to user-scoped routing when the current session belongs to another Matrix account", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "support",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:main",
|
||||
baseSessionKey: "agent:main:main",
|
||||
peer: { kind: "direct", id: "@alice:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:@alice:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the canonical DM room after user-target outbound metadata overwrites latest to fields", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@bob:example.org",
|
||||
to: "room:@bob:example.org",
|
||||
nativeChannelId: "!dm:example.org",
|
||||
nativeDirectUserId: "@alice:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:@bob:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
lastTo: "room:@bob:example.org",
|
||||
lastAccountId: "ops",
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "ops",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
peer: { kind: "channel", id: "!dm:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse the canonical DM room for a different Matrix user after latest metadata drift", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@bob:example.org",
|
||||
to: "room:@bob:example.org",
|
||||
nativeChannelId: "!dm:example.org",
|
||||
nativeDirectUserId: "@alice:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:@bob:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
lastTo: "room:@bob:example.org",
|
||||
lastAccountId: "ops",
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "ops",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@bob:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@bob:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:main",
|
||||
baseSessionKey: "agent:main:main",
|
||||
peer: { kind: "direct", id: "@bob:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@bob:example.org",
|
||||
to: "room:@bob:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse a room after the session metadata was overwritten by a non-DM Matrix send", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "channel",
|
||||
origin: {
|
||||
chatType: "channel",
|
||||
from: "matrix:channel:!ops:example.org",
|
||||
to: "room:!ops:example.org",
|
||||
nativeChannelId: "!ops:example.org",
|
||||
nativeDirectUserId: "@alice:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!ops:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
lastTo: "room:!ops:example.org",
|
||||
lastAccountId: "ops",
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
accountId: "ops",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:main",
|
||||
baseSessionKey: "agent:main:main",
|
||||
peer: { kind: "direct", id: "@alice:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:@alice:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the effective default Matrix account when accountId is omitted", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!dm:example.org",
|
||||
accountId: "ops",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
accounts: {
|
||||
ops: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
peer: { kind: "channel", id: "!dm:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the current DM room when stored account metadata is missing", () => {
|
||||
const storePath = createTempStore({
|
||||
"agent:main:matrix:channel:!dm:example.org": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "direct",
|
||||
origin: {
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
},
|
||||
deliveryContext: {
|
||||
channel: "matrix",
|
||||
to: "room:!dm:example.org",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
accounts: {
|
||||
ops: {
|
||||
dm: {
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const route = resolveMatrixOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
target: "@alice:example.org",
|
||||
resolvedTarget: {
|
||||
to: "@alice:example.org",
|
||||
kind: "user",
|
||||
source: "normalized",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
||||
peer: { kind: "channel", id: "!dm:example.org" },
|
||||
chatType: "direct",
|
||||
from: "matrix:@alice:example.org",
|
||||
to: "room:!dm:example.org",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,113 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
stripChannelTargetPrefix,
|
||||
stripTargetKindPrefix,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/account-config.js";
|
||||
import { resolveDefaultMatrixAccountId } from "./matrix/accounts.js";
|
||||
import { resolveMatrixStoredSessionMeta } from "./matrix/session-store-metadata.js";
|
||||
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
|
||||
|
||||
function resolveEffectiveMatrixAccountId(
|
||||
params: Pick<ChannelOutboundSessionRouteParams, "cfg" | "accountId">,
|
||||
): string {
|
||||
return normalizeAccountId(params.accountId ?? resolveDefaultMatrixAccountId(params.cfg));
|
||||
}
|
||||
|
||||
function resolveMatrixDmSessionScope(params: {
|
||||
cfg: ChannelOutboundSessionRouteParams["cfg"];
|
||||
accountId: string;
|
||||
}): "per-user" | "per-room" {
|
||||
return (
|
||||
resolveMatrixAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).dm?.sessionScope ?? "per-user"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixCurrentDmRoomId(params: {
|
||||
cfg: ChannelOutboundSessionRouteParams["cfg"];
|
||||
agentId: string;
|
||||
accountId: string;
|
||||
currentSessionKey?: string;
|
||||
targetUserId: string;
|
||||
}): string | undefined {
|
||||
const sessionKey = params.currentSessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const existing = resolveSessionStoreEntry({
|
||||
store,
|
||||
sessionKey,
|
||||
}).existing;
|
||||
const currentSession = resolveMatrixStoredSessionMeta(existing);
|
||||
if (!currentSession) {
|
||||
return undefined;
|
||||
}
|
||||
if (currentSession.accountId && currentSession.accountId !== params.accountId) {
|
||||
return undefined;
|
||||
}
|
||||
if (!currentSession.directUserId || currentSession.directUserId !== params.targetUserId) {
|
||||
return undefined;
|
||||
}
|
||||
return currentSession.roomId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMatrixOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const stripped = stripChannelTargetPrefix(params.target, "matrix");
|
||||
const isUser =
|
||||
params.resolvedTarget?.kind === "user" || stripped.startsWith("@") || /^user:/i.test(stripped);
|
||||
const rawId = stripTargetKindPrefix(stripped);
|
||||
if (!rawId) {
|
||||
const target =
|
||||
resolveMatrixTargetIdentity(params.resolvedTarget?.to ?? params.target) ??
|
||||
resolveMatrixTargetIdentity(params.target);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const effectiveAccountId = resolveEffectiveMatrixAccountId(params);
|
||||
const roomScopedDmId =
|
||||
target.kind === "user" &&
|
||||
resolveMatrixDmSessionScope({
|
||||
cfg: params.cfg,
|
||||
accountId: effectiveAccountId,
|
||||
}) === "per-room"
|
||||
? resolveMatrixCurrentDmRoomId({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
accountId: effectiveAccountId,
|
||||
currentSessionKey: params.currentSessionKey,
|
||||
targetUserId: target.id,
|
||||
})
|
||||
: undefined;
|
||||
const peer =
|
||||
roomScopedDmId !== undefined
|
||||
? { kind: "channel" as const, id: roomScopedDmId }
|
||||
: {
|
||||
kind: target.kind === "user" ? ("direct" as const) : ("channel" as const),
|
||||
id: target.id,
|
||||
};
|
||||
const chatType = target.kind === "user" ? "direct" : "channel";
|
||||
const from = target.kind === "user" ? `matrix:${target.id}` : `matrix:channel:${target.id}`;
|
||||
const to = `room:${roomScopedDmId ?? target.id}`;
|
||||
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isUser ? "direct" : "channel",
|
||||
id: rawId,
|
||||
},
|
||||
chatType: isUser ? "direct" : "channel",
|
||||
from: isUser ? `matrix:${rawId}` : `matrix:channel:${rawId}`,
|
||||
to: `room:${rawId}`,
|
||||
accountId: effectiveAccountId,
|
||||
peer,
|
||||
chatType,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ export type MatrixDmConfig = {
|
||||
policy?: DmPolicy;
|
||||
/** Allowlist for DM senders (matrix user IDs or "*"). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/**
|
||||
* How Matrix DMs map to sessions.
|
||||
* - `per-user` (default): all DM rooms with the same routed peer share one DM session.
|
||||
* - `per-room`: each Matrix DM room gets its own session key.
|
||||
*/
|
||||
sessionScope?: "per-user" | "per-room";
|
||||
/** Per-DM thread reply behavior override (off|inbound|always). Overrides top-level threadReplies for direct messages. */
|
||||
threadReplies?: "off" | "inbound" | "always";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user