mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 04:47:26 +00:00
matrix-js: improve thread context and auto-threading
This commit is contained in:
@@ -225,6 +225,16 @@ Inbound SAS requests are auto-confirmed by the bot device, so once the user conf
|
|||||||
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
|
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
|
||||||
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
||||||
|
|
||||||
|
## Threads
|
||||||
|
|
||||||
|
Matrix-js supports native Matrix threads for both automatic replies and message-tool sends.
|
||||||
|
|
||||||
|
- `threadReplies: "off"` keeps replies top-level.
|
||||||
|
- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread.
|
||||||
|
- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message.
|
||||||
|
- Inbound threaded messages include the thread root message as extra agent context.
|
||||||
|
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
|
||||||
|
|
||||||
## Reactions
|
## Reactions
|
||||||
|
|
||||||
Matrix-js supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
|
Matrix-js supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
|
||||||
|
|||||||
@@ -104,6 +104,56 @@ describe("matrix directory", () => {
|
|||||||
).toBe("off");
|
).toBe("off");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("only exposes real Matrix thread ids in tool context", () => {
|
||||||
|
expect(
|
||||||
|
matrixPlugin.threading?.buildToolContext?.({
|
||||||
|
context: {
|
||||||
|
To: "room:!room:example.org",
|
||||||
|
ReplyToId: "$reply",
|
||||||
|
},
|
||||||
|
hasRepliedRef: { value: false },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
currentChannelId: "room:!room:example.org",
|
||||||
|
currentThreadTs: undefined,
|
||||||
|
hasRepliedRef: { value: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matrixPlugin.threading?.buildToolContext?.({
|
||||||
|
context: {
|
||||||
|
To: "room:!room:example.org",
|
||||||
|
ReplyToId: "$reply",
|
||||||
|
MessageThreadId: "$thread",
|
||||||
|
},
|
||||||
|
hasRepliedRef: { value: true },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
currentChannelId: "room:!room:example.org",
|
||||||
|
currentThreadTs: "$thread",
|
||||||
|
hasRepliedRef: { value: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes Matrix direct user id in dm tool context", () => {
|
||||||
|
expect(
|
||||||
|
matrixPlugin.threading?.buildToolContext?.({
|
||||||
|
context: {
|
||||||
|
From: "matrix:@alice:example.org",
|
||||||
|
To: "room:!dm:example.org",
|
||||||
|
ChatType: "direct",
|
||||||
|
MessageThreadId: "$thread",
|
||||||
|
},
|
||||||
|
hasRepliedRef: { value: false },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
currentChannelId: "room:!dm:example.org",
|
||||||
|
currentThreadTs: "$thread",
|
||||||
|
currentDirectUserId: "@alice:example.org",
|
||||||
|
hasRepliedRef: { value: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves group mention policy from account config", () => {
|
it("resolves group mention policy from account config", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
|||||||
return stripped || undefined;
|
return stripped || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMatrixDirectUserId(params: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
chatType?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
if (params.chatType !== "direct") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const from = params.from?.trim();
|
||||||
|
const to = params.to?.trim();
|
||||||
|
if (!from || !to || !/^room:/i.test(to)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = from
|
||||||
|
.replace(/^matrix:/i, "")
|
||||||
|
.replace(/^user:/i, "")
|
||||||
|
.trim();
|
||||||
|
return normalized.startsWith("@") ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
|
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
|
||||||
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
|
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
|
||||||
const trimmed = avatarUrl?.trim();
|
const trimmed = avatarUrl?.trim();
|
||||||
@@ -181,7 +201,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
return {
|
return {
|
||||||
currentChannelId: currentTarget?.trim() || undefined,
|
currentChannelId: currentTarget?.trim() || undefined,
|
||||||
currentThreadTs:
|
currentThreadTs:
|
||||||
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||||
|
currentDirectUserId: resolveMatrixDirectUserId({
|
||||||
|
from: context.From,
|
||||||
|
to: context.To,
|
||||||
|
chatType: context.ChatType,
|
||||||
|
}),
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -390,6 +390,131 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records thread starter context for inbound thread replies", async () => {
|
||||||
|
const recordInboundSession = vi.fn(async () => {});
|
||||||
|
const finalizeInboundContext = vi.fn((ctx) => ctx);
|
||||||
|
|
||||||
|
const handler = createMatrixRoomMessageHandler({
|
||||||
|
client: {
|
||||||
|
getUserId: async () => "@bot:example.org",
|
||||||
|
getEvent: async () => ({
|
||||||
|
event_id: "$root",
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Root topic",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
core: {
|
||||||
|
channel: {
|
||||||
|
pairing: {
|
||||||
|
readAllowFromStore: async () => [] as string[],
|
||||||
|
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
shouldHandleTextCommands: () => false,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
hasControlCommand: () => false,
|
||||||
|
resolveMarkdownTableMode: () => "preserve",
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
|
resolveAgentRoute: () => ({
|
||||||
|
agentId: "ops",
|
||||||
|
channel: "matrix-js",
|
||||||
|
accountId: "ops",
|
||||||
|
sessionKey: "agent:ops:main",
|
||||||
|
mainSessionKey: "agent:ops:main",
|
||||||
|
matchedBy: "binding.account",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
resolveStorePath: () => "/tmp/session-store",
|
||||||
|
readSessionUpdatedAt: () => undefined,
|
||||||
|
recordInboundSession,
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
resolveEnvelopeFormatOptions: () => ({}),
|
||||||
|
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||||
|
finalizeInboundContext,
|
||||||
|
createReplyDispatcherWithTyping: () => ({
|
||||||
|
dispatcher: {},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: () => {},
|
||||||
|
}),
|
||||||
|
resolveHumanDelayConfig: () => undefined,
|
||||||
|
dispatchReplyFromConfig: async () => ({
|
||||||
|
queuedFinal: false,
|
||||||
|
counts: { final: 0, block: 0, tool: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
reactions: {
|
||||||
|
shouldAckReaction: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
cfg: {} as never,
|
||||||
|
accountId: "ops",
|
||||||
|
runtime: {
|
||||||
|
error: () => {},
|
||||||
|
} as never,
|
||||||
|
logger: {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
} as never,
|
||||||
|
logVerboseMessage: () => {},
|
||||||
|
allowFrom: [],
|
||||||
|
mentionRegexes: [],
|
||||||
|
groupPolicy: "open",
|
||||||
|
replyToMode: "off",
|
||||||
|
threadReplies: "inbound",
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
textLimit: 8_000,
|
||||||
|
mediaMaxBytes: 10_000_000,
|
||||||
|
startupMs: 0,
|
||||||
|
startupGraceMs: 0,
|
||||||
|
directTracker: {
|
||||||
|
isDirectMessage: async () => false,
|
||||||
|
},
|
||||||
|
getRoomInfo: async () => ({ altAliases: [] }),
|
||||||
|
getMemberDisplayName: async (_roomId, userId) =>
|
||||||
|
userId === "@alice:example.org" ? "Alice" : "sender",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler("!room:example.org", {
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
sender: "@user:example.org",
|
||||||
|
event_id: "$reply1",
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "follow up",
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.thread",
|
||||||
|
event_id: "$root",
|
||||||
|
"m.in_reply_to": { event_id: "$root" },
|
||||||
|
},
|
||||||
|
"m.mentions": { room: true },
|
||||||
|
},
|
||||||
|
} as MatrixRawEvent);
|
||||||
|
|
||||||
|
expect(finalizeInboundContext).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MessageThreadId: "$root",
|
||||||
|
ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionKey: "agent:ops:main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("enqueues system events for reactions on bot-authored messages", async () => {
|
it("enqueues system events for reactions on bot-authored messages", async () => {
|
||||||
const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness();
|
const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { resolveMentions } from "./mentions.js";
|
|||||||
import { handleInboundMatrixReaction } from "./reaction-events.js";
|
import { handleInboundMatrixReaction } from "./reaction-events.js";
|
||||||
import { deliverMatrixReplies } from "./replies.js";
|
import { deliverMatrixReplies } from "./replies.js";
|
||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||||
|
import { createMatrixThreadContextResolver } from "./thread-context.js";
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||||
import { EventType, RelationType } from "./types.js";
|
import { EventType, RelationType } from "./types.js";
|
||||||
@@ -108,6 +109,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
expiresAtMs: number;
|
expiresAtMs: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
const pairingReplySentAtMsBySender = new Map<string, number>();
|
const pairingReplySentAtMsBySender = new Map<string, number>();
|
||||||
|
const resolveThreadContext = createMatrixThreadContextResolver({
|
||||||
|
client,
|
||||||
|
getMemberDisplayName,
|
||||||
|
logVerboseMessage,
|
||||||
|
});
|
||||||
|
|
||||||
const readStoreAllowFrom = async (): Promise<string[]> => {
|
const readStoreAllowFrom = async (): Promise<string[]> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -523,6 +529,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
threadRootId,
|
threadRootId,
|
||||||
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
|
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
|
||||||
});
|
});
|
||||||
|
const threadContext = threadRootId
|
||||||
|
? await resolveThreadContext({ roomId, threadRootId })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -575,6 +584,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
MessageSid: messageId,
|
MessageSid: messageId,
|
||||||
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
||||||
MessageThreadId: threadTarget,
|
MessageThreadId: threadTarget,
|
||||||
|
ThreadStarterBody: threadContext?.threadStarterBody,
|
||||||
Timestamp: eventTs ?? undefined,
|
Timestamp: eventTs ?? undefined,
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
|
|||||||
106
extensions/matrix-js/src/matrix/monitor/thread-context.test.ts
Normal file
106
extensions/matrix-js/src/matrix/monitor/thread-context.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createMatrixThreadContextResolver,
|
||||||
|
summarizeMatrixThreadStarterEvent,
|
||||||
|
} from "./thread-context.js";
|
||||||
|
import type { MatrixRawEvent } from "./types.js";
|
||||||
|
|
||||||
|
describe("matrix thread context", () => {
|
||||||
|
it("summarizes thread starter events from body text", () => {
|
||||||
|
expect(
|
||||||
|
summarizeMatrixThreadStarterEvent({
|
||||||
|
event_id: "$root",
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
type: "m.room.message",
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: " Thread starter body ",
|
||||||
|
},
|
||||||
|
} as MatrixRawEvent),
|
||||||
|
).toBe("Thread starter body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves and caches thread starter context", async () => {
|
||||||
|
const getEvent = vi.fn(async () => ({
|
||||||
|
event_id: "$root",
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
type: "m.room.message",
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Root topic",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const getMemberDisplayName = vi.fn(async () => "Alice");
|
||||||
|
const resolveThreadContext = createMatrixThreadContextResolver({
|
||||||
|
client: {
|
||||||
|
getEvent,
|
||||||
|
} as never,
|
||||||
|
getMemberDisplayName,
|
||||||
|
logVerboseMessage: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveThreadContext({
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
threadRootId: "$root",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveThreadContext({
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
threadRootId: "$root",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache thread starter fetch failures", async () => {
|
||||||
|
const getEvent = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
event_id: "$root",
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
type: "m.room.message",
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Recovered topic",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const getMemberDisplayName = vi.fn(async () => "Alice");
|
||||||
|
const resolveThreadContext = createMatrixThreadContextResolver({
|
||||||
|
client: {
|
||||||
|
getEvent,
|
||||||
|
} as never,
|
||||||
|
getMemberDisplayName,
|
||||||
|
logVerboseMessage: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveThreadContext({
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
threadRootId: "$root",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
threadStarterBody: "Matrix thread root $root",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveThreadContext({
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
threadRootId: "$root",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
extensions/matrix-js/src/matrix/monitor/thread-context.ts
Normal file
107
extensions/matrix-js/src/matrix/monitor/thread-context.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { MatrixClient } from "../sdk.js";
|
||||||
|
import type { MatrixRawEvent } from "./types.js";
|
||||||
|
|
||||||
|
const MAX_TRACKED_THREAD_STARTERS = 256;
|
||||||
|
const MAX_THREAD_STARTER_BODY_LENGTH = 500;
|
||||||
|
|
||||||
|
type MatrixThreadContext = {
|
||||||
|
threadStarterBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimMaybeString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateThreadStarterBody(value: string): string {
|
||||||
|
if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {
|
||||||
|
const content = event.content as { body?: unknown; msgtype?: unknown };
|
||||||
|
const body = trimMaybeString(content.body);
|
||||||
|
if (body) {
|
||||||
|
return truncateThreadStarterBody(body);
|
||||||
|
}
|
||||||
|
const msgtype = trimMaybeString(content.msgtype);
|
||||||
|
if (msgtype) {
|
||||||
|
return `Matrix ${msgtype} message`;
|
||||||
|
}
|
||||||
|
const eventType = trimMaybeString(event.type);
|
||||||
|
return eventType ? `Matrix ${eventType} event` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMatrixThreadStarterBody(params: {
|
||||||
|
threadRootId: string;
|
||||||
|
senderName?: string;
|
||||||
|
senderId?: string;
|
||||||
|
summary?: string;
|
||||||
|
}): string {
|
||||||
|
const senderLabel = params.senderName ?? params.senderId ?? "unknown sender";
|
||||||
|
const lines = [`Matrix thread root ${params.threadRootId} from ${senderLabel}:`];
|
||||||
|
if (params.summary) {
|
||||||
|
lines.push(params.summary);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMatrixThreadContextResolver(params: {
|
||||||
|
client: MatrixClient;
|
||||||
|
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||||
|
logVerboseMessage: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const cache = new Map<string, MatrixThreadContext>();
|
||||||
|
|
||||||
|
const remember = (key: string, value: MatrixThreadContext): MatrixThreadContext => {
|
||||||
|
cache.set(key, value);
|
||||||
|
if (cache.size > MAX_TRACKED_THREAD_STARTERS) {
|
||||||
|
const oldest = cache.keys().next().value;
|
||||||
|
if (typeof oldest === "string") {
|
||||||
|
cache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (input: { roomId: string; threadRootId: string }): Promise<MatrixThreadContext> => {
|
||||||
|
const cacheKey = `${input.roomId}:${input.threadRootId}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootEvent = await params.client
|
||||||
|
.getEvent(input.roomId, input.threadRootId)
|
||||||
|
.catch((err) => {
|
||||||
|
params.logVerboseMessage(
|
||||||
|
`matrix: failed resolving thread root room=${input.roomId} id=${input.threadRootId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (!rootEvent) {
|
||||||
|
return {
|
||||||
|
threadStarterBody: `Matrix thread root ${input.threadRootId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEvent = rootEvent as MatrixRawEvent;
|
||||||
|
const senderId = trimMaybeString(rawEvent.sender);
|
||||||
|
const senderName =
|
||||||
|
senderId &&
|
||||||
|
(await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
|
||||||
|
return remember(cacheKey, {
|
||||||
|
threadStarterBody: formatMatrixThreadStarterBody({
|
||||||
|
threadRootId: input.threadRootId,
|
||||||
|
senderId,
|
||||||
|
senderName,
|
||||||
|
summary: summarizeMatrixThreadStarterEvent(rawEvent),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -273,6 +273,12 @@ export type ChannelThreadingToolContext = {
|
|||||||
currentChannelProvider?: ChannelId;
|
currentChannelProvider?: ChannelId;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
currentMessageId?: string | number;
|
currentMessageId?: string | number;
|
||||||
|
/**
|
||||||
|
* Optional direct-chat participant identifier for channels whose outbound
|
||||||
|
* tool targets can address either the backing conversation id or the direct
|
||||||
|
* participant id.
|
||||||
|
*/
|
||||||
|
currentDirectUserId?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -71,6 +71,49 @@ export function resolveTelegramAutoThreadId(params: {
|
|||||||
return context.currentThreadTs;
|
return context.currentThreadTs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMatrixThreadTarget(raw: string): string | undefined {
|
||||||
|
let normalized = raw.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (normalized.toLowerCase().startsWith("matrix:")) {
|
||||||
|
normalized = normalized.slice("matrix:".length).trim();
|
||||||
|
}
|
||||||
|
normalized = normalized.replace(/^(room|channel|user):/i, "").trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatrixDirectUserTarget(raw: string): string | undefined {
|
||||||
|
const normalized = normalizeMatrixThreadTarget(raw);
|
||||||
|
return normalized?.startsWith("@") ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatrixAutoThreadId(params: {
|
||||||
|
to: string;
|
||||||
|
toolContext?: ChannelThreadingToolContext;
|
||||||
|
}): string | undefined {
|
||||||
|
const context = params.toolContext;
|
||||||
|
if (!context?.currentThreadTs || !context.currentChannelId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const target = normalizeMatrixThreadTarget(params.to);
|
||||||
|
const currentChannel = normalizeMatrixThreadTarget(context.currentChannelId);
|
||||||
|
if (!target || !currentChannel) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (target.toLowerCase() !== currentChannel.toLowerCase()) {
|
||||||
|
const directTarget = normalizeMatrixDirectUserTarget(params.to);
|
||||||
|
const currentDirectUserId = normalizeMatrixDirectUserTarget(context.currentDirectUserId ?? "");
|
||||||
|
if (!directTarget || !currentDirectUserId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (directTarget.toLowerCase() !== currentDirectUserId.toLowerCase()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.currentThreadTs;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAttachmentMaxBytes(params: {
|
function resolveAttachmentMaxBytes(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { matrixPlugin } from "../../../extensions/matrix-js/src/channel.js";
|
||||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
@@ -49,6 +50,15 @@ const telegramConfig = {
|
|||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const matrixConfig = {
|
||||||
|
channels: {
|
||||||
|
"matrix-js": {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "matrix-test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
async function runThreadingAction(params: {
|
async function runThreadingAction(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
actionParams: Record<string, unknown>;
|
actionParams: Record<string, unknown>;
|
||||||
@@ -80,23 +90,42 @@ const defaultTelegramToolContext = {
|
|||||||
currentThreadTs: "42",
|
currentThreadTs: "42",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const defaultMatrixToolContext = {
|
||||||
|
currentChannelId: "room:!room:example.org",
|
||||||
|
currentThreadTs: "$thread",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const defaultMatrixDmToolContext = {
|
||||||
|
currentChannelId: "room:!dm:example.org",
|
||||||
|
currentThreadTs: "$thread",
|
||||||
|
currentDirectUserId: "@alice:example.org",
|
||||||
|
} as const;
|
||||||
|
|
||||||
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
|
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
|
||||||
|
let setMatrixRuntime: typeof import("../../../extensions/matrix-js/src/runtime.js").setMatrixRuntime;
|
||||||
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
|
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
|
||||||
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
||||||
|
|
||||||
describe("runMessageAction threading auto-injection", () => {
|
describe("runMessageAction threading auto-injection", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
|
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
|
||||||
|
({ setMatrixRuntime } = await import("../../../extensions/matrix-js/src/runtime.js"));
|
||||||
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
|
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
|
||||||
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
|
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const runtime = createPluginRuntime();
|
const runtime = createPluginRuntime();
|
||||||
|
setMatrixRuntime(runtime);
|
||||||
setSlackRuntime(runtime);
|
setSlackRuntime(runtime);
|
||||||
setTelegramRuntime(runtime);
|
setTelegramRuntime(runtime);
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "matrix-js",
|
||||||
|
source: "test",
|
||||||
|
plugin: matrixPlugin,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
pluginId: "slack",
|
pluginId: "slack",
|
||||||
source: "test",
|
source: "test",
|
||||||
@@ -221,4 +250,96 @@ describe("runMessageAction threading auto-injection", () => {
|
|||||||
expect(call?.replyToId).toBe("777");
|
expect(call?.replyToId).toBe("777");
|
||||||
expect(call?.ctx?.params?.replyTo).toBe("777");
|
expect(call?.ctx?.params?.replyTo).toBe("777");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "injects threadId for bare room id",
|
||||||
|
target: "!room:example.org",
|
||||||
|
expectedThreadId: "$thread",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "injects threadId for room target prefix",
|
||||||
|
target: "room:!room:example.org",
|
||||||
|
expectedThreadId: "$thread",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "injects threadId for matrix room target",
|
||||||
|
target: "matrix:room:!room:example.org",
|
||||||
|
expectedThreadId: "$thread",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips threadId when target room differs",
|
||||||
|
target: "!other:example.org",
|
||||||
|
expectedThreadId: undefined,
|
||||||
|
},
|
||||||
|
] as const)("matrix auto-threading: $name", async (testCase) => {
|
||||||
|
mockHandledSendAction();
|
||||||
|
|
||||||
|
const call = await runThreadingAction({
|
||||||
|
cfg: matrixConfig,
|
||||||
|
actionParams: {
|
||||||
|
channel: "matrix-js",
|
||||||
|
target: testCase.target,
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
toolContext: defaultMatrixToolContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(call?.ctx?.params?.threadId).toBe(testCase.expectedThreadId);
|
||||||
|
if (testCase.expectedThreadId !== undefined) {
|
||||||
|
expect(call?.threadId).toBe(testCase.expectedThreadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit matrix threadId when provided", async () => {
|
||||||
|
mockHandledSendAction();
|
||||||
|
|
||||||
|
const call = await runThreadingAction({
|
||||||
|
cfg: matrixConfig,
|
||||||
|
actionParams: {
|
||||||
|
channel: "matrix-js",
|
||||||
|
target: "room:!room:example.org",
|
||||||
|
message: "hi",
|
||||||
|
threadId: "$explicit",
|
||||||
|
},
|
||||||
|
toolContext: defaultMatrixToolContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(call?.threadId).toBe("$explicit");
|
||||||
|
expect(call?.ctx?.params?.threadId).toBe("$explicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects threadId for matching Matrix dm user target", async () => {
|
||||||
|
mockHandledSendAction();
|
||||||
|
|
||||||
|
const call = await runThreadingAction({
|
||||||
|
cfg: matrixConfig,
|
||||||
|
actionParams: {
|
||||||
|
channel: "matrix-js",
|
||||||
|
target: "user:@alice:example.org",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
toolContext: defaultMatrixDmToolContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(call?.threadId).toBe("$thread");
|
||||||
|
expect(call?.ctx?.params?.threadId).toBe("$thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips threadId for different Matrix dm user target", async () => {
|
||||||
|
mockHandledSendAction();
|
||||||
|
|
||||||
|
const call = await runThreadingAction({
|
||||||
|
cfg: matrixConfig,
|
||||||
|
actionParams: {
|
||||||
|
channel: "matrix-js",
|
||||||
|
target: "user:@bob:example.org",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
toolContext: defaultMatrixDmToolContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(call?.threadId).toBeUndefined();
|
||||||
|
expect(call?.ctx?.params?.threadId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
parseComponentsParam,
|
parseComponentsParam,
|
||||||
readBooleanParam,
|
readBooleanParam,
|
||||||
resolveAttachmentMediaPolicy,
|
resolveAttachmentMediaPolicy,
|
||||||
|
resolveMatrixAutoThreadId,
|
||||||
resolveSlackAutoThreadId,
|
resolveSlackAutoThreadId,
|
||||||
resolveTelegramAutoThreadId,
|
resolveTelegramAutoThreadId,
|
||||||
} from "./message-action-params.js";
|
} from "./message-action-params.js";
|
||||||
@@ -78,7 +79,11 @@ function resolveAndApplyOutboundThreadId(
|
|||||||
ctx.channel === "telegram" && !threadId
|
ctx.channel === "telegram" && !threadId
|
||||||
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
|
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
|
||||||
: undefined;
|
: undefined;
|
||||||
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
|
const matrixAutoThreadId =
|
||||||
|
ctx.channel === "matrix-js" && !threadId
|
||||||
|
? resolveMatrixAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
|
||||||
|
: undefined;
|
||||||
|
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId ?? matrixAutoThreadId;
|
||||||
// Write auto-resolved threadId back into params so downstream dispatch
|
// Write auto-resolved threadId back into params so downstream dispatch
|
||||||
// (plugin `readStringParam(params, "threadId")`) picks it up.
|
// (plugin `readStringParam(params, "threadId")`) picks it up.
|
||||||
if (resolved && !params.threadId) {
|
if (resolved && !params.threadId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user