mirror of https://github.com/openclaw/openclaw.git
fix(matrix): resolve reply context body and sender for quoted messages (#55056)
Merged via squash.
Prepared head SHA: 6fd580bb03
Co-authored-by: alberthild <3729342+alberthild@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
65ad45a37f
commit
c7fbd51890
|
|
@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.
|
||||
- Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
|
||||
- Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.
|
||||
- Matrix/replies: include quoted poll question/options in inbound reply context so the agent sees the original poll content when users reply to Matrix poll messages. (#55056) Thanks @alberthild.
|
||||
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
|
||||
- Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman.
|
||||
- Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
formatMatrixMessageText,
|
||||
resolveMatrixMessageAttachment,
|
||||
resolveMatrixMessageBody,
|
||||
} from "../media-text.js";
|
||||
import {
|
||||
formatPollAsText,
|
||||
isPollStartType,
|
||||
parsePollStartContent,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
export function trimMatrixMaybeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function summarizeMatrixMessageContextEvent(event: MatrixRawEvent): string | undefined {
|
||||
if (isPollStartType(event.type)) {
|
||||
const pollSummary = parsePollStartContent(event.content as PollStartContent);
|
||||
if (pollSummary) {
|
||||
return formatPollAsText(pollSummary);
|
||||
}
|
||||
}
|
||||
|
||||
const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown };
|
||||
return formatMatrixMessageText({
|
||||
body: resolveMatrixMessageBody({
|
||||
body: trimMatrixMaybeString(content.body),
|
||||
filename: trimMatrixMaybeString(content.filename),
|
||||
msgtype: trimMatrixMaybeString(content.msgtype),
|
||||
}),
|
||||
attachment: resolveMatrixMessageAttachment({
|
||||
body: trimMatrixMaybeString(content.body),
|
||||
filename: trimMatrixMaybeString(content.filename),
|
||||
msgtype: trimMatrixMaybeString(content.msgtype),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -123,4 +123,118 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records reply context for quoted poll start events inside always-threaded replies", async () => {
|
||||
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
|
||||
client: {
|
||||
getEvent: async (_roomId: string, eventId: string) => {
|
||||
if (eventId === "$thread-root") {
|
||||
return createMatrixTextMessageEvent({
|
||||
eventId: "$thread-root",
|
||||
sender: "@bob:example.org",
|
||||
body: "Root topic",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Pizza" },
|
||||
{ id: "a2", "m.text": "Sushi" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies MatrixRawEvent;
|
||||
},
|
||||
} as unknown as Partial<MatrixClient>,
|
||||
isDirectMessage: false,
|
||||
threadReplies: "always",
|
||||
getMemberDisplayName: async (_roomId, userId) => {
|
||||
if (userId === "@alice:example.org") {
|
||||
return "Alice";
|
||||
}
|
||||
if (userId === "@bob:example.org") {
|
||||
return "Bob";
|
||||
}
|
||||
return "sender";
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$reply1",
|
||||
body: "@room follow up",
|
||||
relatesTo: {
|
||||
rel_type: "m.thread",
|
||||
event_id: "$thread-root",
|
||||
"m.in_reply_to": { event_id: "$poll" },
|
||||
},
|
||||
mentions: { room: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MessageThreadId: "$thread-root",
|
||||
ReplyToId: undefined,
|
||||
ReplyToSender: "Alice",
|
||||
ReplyToBody: "[Poll]\nLunch?\n\n1. Pizza\n2. Sushi",
|
||||
ThreadStarterBody: "Matrix thread root $thread-root from Bob:\nRoot topic",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the fetched thread root when reply context points at the same event", async () => {
|
||||
const getEvent = vi.fn(async () =>
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$thread-root",
|
||||
sender: "@alice:example.org",
|
||||
body: "Root topic",
|
||||
}),
|
||||
);
|
||||
const getMemberDisplayName = vi.fn(async (_roomId: string, userId: string) =>
|
||||
userId === "@alice:example.org" ? "Alice" : "sender",
|
||||
);
|
||||
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
|
||||
client: { getEvent },
|
||||
isDirectMessage: false,
|
||||
threadReplies: "always",
|
||||
getMemberDisplayName,
|
||||
});
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: "$reply1",
|
||||
body: "@room follow up",
|
||||
relatesTo: {
|
||||
rel_type: "m.thread",
|
||||
event_id: "$thread-root",
|
||||
"m.in_reply_to": { event_id: "$thread-root" },
|
||||
},
|
||||
mentions: { room: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MessageThreadId: "$thread-root",
|
||||
ReplyToId: undefined,
|
||||
ReplyToSender: "Alice",
|
||||
ReplyToBody: "Root topic",
|
||||
ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic",
|
||||
}),
|
||||
);
|
||||
expect(getEvent).toHaveBeenCalledTimes(1);
|
||||
expect(getMemberDisplayName).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { downloadMatrixMedia } from "./media.js";
|
|||
import { resolveMentions } from "./mentions.js";
|
||||
import { handleInboundMatrixReaction } from "./reaction-events.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
import { createMatrixReplyContextResolver } from "./reply-context.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixInboundRoute } from "./route.js";
|
||||
import { createMatrixThreadContextResolver } from "./thread-context.js";
|
||||
|
|
@ -182,6 +183,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
getMemberDisplayName,
|
||||
logVerboseMessage,
|
||||
});
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage,
|
||||
});
|
||||
|
||||
const readStoreAllowFrom = async (): Promise<string[]> => {
|
||||
const now = Date.now();
|
||||
|
|
@ -707,6 +713,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
? await resolveThreadContext({ roomId, threadRootId: _threadRootId })
|
||||
: undefined;
|
||||
|
||||
// Resolve the body and sender of the replied-to message so the agent
|
||||
// can see what is being replied to, not just the event ID.
|
||||
// Note: resolve even when threadTarget is set (e.g. threadReplies: "always")
|
||||
// because the user may still be quoting a specific message within the thread.
|
||||
const replyContext =
|
||||
replyToEventId && replyToEventId === _threadRootId && threadContext?.summary
|
||||
? {
|
||||
replyToBody: threadContext.summary,
|
||||
replyToSender: threadContext.senderLabel,
|
||||
}
|
||||
: replyToEventId
|
||||
? await resolveReplyContext({ roomId, eventId: replyToEventId })
|
||||
: undefined;
|
||||
|
||||
if (_configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpBindingReady({
|
||||
cfg,
|
||||
|
|
@ -766,6 +786,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
WasMentioned: isRoom ? wasMentioned : undefined,
|
||||
MessageSid: _messageId,
|
||||
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
||||
ReplyToBody: replyContext?.replyToBody,
|
||||
ReplyToSender: replyContext?.replyToSender,
|
||||
MessageThreadId: threadTarget,
|
||||
ThreadStarterBody: threadContext?.threadStarterBody,
|
||||
Timestamp: eventTs ?? undefined,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMatrixReplyContextResolver, summarizeMatrixReplyEvent } from "./reply-context.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
describe("matrix reply context", () => {
|
||||
it("summarizes reply events from body text", () => {
|
||||
expect(
|
||||
summarizeMatrixReplyEvent({
|
||||
event_id: "$original",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: " Some quoted message ",
|
||||
},
|
||||
} as MatrixRawEvent),
|
||||
).toBe("Some quoted message");
|
||||
});
|
||||
|
||||
it("truncates long reply bodies", () => {
|
||||
const longBody = "x".repeat(600);
|
||||
const result = summarizeMatrixReplyEvent({
|
||||
event_id: "$original",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: longBody,
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.length).toBeLessThanOrEqual(500);
|
||||
expect(result!.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles media-only reply events", () => {
|
||||
expect(
|
||||
summarizeMatrixReplyEvent({
|
||||
event_id: "$original",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "photo.jpg",
|
||||
},
|
||||
} as MatrixRawEvent),
|
||||
).toBe("[matrix image attachment]");
|
||||
});
|
||||
|
||||
it("summarizes poll start events from poll content", () => {
|
||||
expect(
|
||||
summarizeMatrixReplyEvent({
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Pizza" },
|
||||
{ id: "a2", "m.text": "Sushi" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as MatrixRawEvent),
|
||||
).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
|
||||
});
|
||||
|
||||
it("resolves and caches reply context", async () => {
|
||||
const getEvent = vi.fn(async () => ({
|
||||
event_id: "$original",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "This is the original message",
|
||||
},
|
||||
}));
|
||||
const getMemberDisplayName = vi.fn(async () => "Alice");
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: {
|
||||
getEvent,
|
||||
} as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
const result = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$original",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
replyToBody: "This is the original message",
|
||||
replyToSender: "Alice",
|
||||
});
|
||||
|
||||
// Second call should use cache
|
||||
await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$original",
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledTimes(1);
|
||||
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns empty context when event fetch fails", async () => {
|
||||
const getEvent = vi.fn().mockRejectedValueOnce(new Error("not found"));
|
||||
const getMemberDisplayName = vi.fn(async () => "Alice");
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: {
|
||||
getEvent,
|
||||
} as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
const result = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$missing",
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty context for redacted events", async () => {
|
||||
const getEvent = vi.fn(async () => ({
|
||||
event_id: "$redacted",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
unsigned: {
|
||||
redacted_because: { type: "m.room.redaction" },
|
||||
},
|
||||
content: {},
|
||||
}));
|
||||
const getMemberDisplayName = vi.fn(async () => "Alice");
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: {
|
||||
getEvent,
|
||||
} as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
const result = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$redacted",
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(getMemberDisplayName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not cache fetch failures so retries can succeed", async () => {
|
||||
const getEvent = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce({
|
||||
event_id: "$original",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Recovered message",
|
||||
},
|
||||
});
|
||||
const getMemberDisplayName = vi.fn(async () => "Bob");
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: {
|
||||
getEvent,
|
||||
} as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
// First call fails
|
||||
const first = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$original",
|
||||
});
|
||||
expect(first).toEqual({});
|
||||
|
||||
// Second call succeeds (should retry, not use cached failure)
|
||||
const second = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$original",
|
||||
});
|
||||
expect(second).toEqual({
|
||||
replyToBody: "Recovered message",
|
||||
replyToSender: "Bob",
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to senderId when display name resolution fails", async () => {
|
||||
const getEvent = vi.fn(async () => ({
|
||||
event_id: "$original",
|
||||
sender: "@charlie:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Hello",
|
||||
},
|
||||
}));
|
||||
const getMemberDisplayName = vi.fn().mockRejectedValueOnce(new Error("unknown member"));
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: {
|
||||
getEvent,
|
||||
} as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
const result = await resolveReplyContext({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$original",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
replyToBody: "Hello",
|
||||
replyToSender: "@charlie:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses LRU eviction — recently accessed entries survive over older ones", async () => {
|
||||
let callCount = 0;
|
||||
const getEvent = vi.fn().mockImplementation((_roomId: string, eventId: string) => {
|
||||
callCount++;
|
||||
return Promise.resolve({
|
||||
event_id: eventId,
|
||||
sender: `@user${callCount}:example.org`,
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: { msgtype: "m.text", body: `msg-${eventId}` },
|
||||
});
|
||||
});
|
||||
const getMemberDisplayName = vi
|
||||
.fn()
|
||||
.mockImplementation((_r: string, userId: string) => Promise.resolve(userId));
|
||||
|
||||
// Use a small cache by testing the eviction pattern:
|
||||
// The actual MAX_CACHED_REPLY_CONTEXTS is 256. We cannot override it easily,
|
||||
// but we can verify that a cache hit reorders entries (delete + re-insert).
|
||||
const resolveReplyContext = createMatrixReplyContextResolver({
|
||||
client: { getEvent } as never,
|
||||
getMemberDisplayName,
|
||||
logVerboseMessage: () => {},
|
||||
});
|
||||
|
||||
// Populate cache with two entries
|
||||
await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
|
||||
await resolveReplyContext({ roomId: "!r:e", eventId: "$B" });
|
||||
expect(getEvent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Access $A again — should be a cache hit (no new getEvent call)
|
||||
// and should move $A to the end of the Map for LRU.
|
||||
const hitResult = await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
|
||||
expect(getEvent).toHaveBeenCalledTimes(2); // Still 2 — cache hit
|
||||
expect(hitResult.replyToBody).toBe("msg-$A");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import type { MatrixClient } from "../sdk.js";
|
||||
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
const MAX_CACHED_REPLY_CONTEXTS = 256;
|
||||
const MAX_REPLY_BODY_LENGTH = 500;
|
||||
|
||||
export type MatrixReplyContext = {
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
};
|
||||
|
||||
function truncateReplyBody(value: string): string {
|
||||
if (value.length <= MAX_REPLY_BODY_LENGTH) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, MAX_REPLY_BODY_LENGTH - 3)}...`;
|
||||
}
|
||||
|
||||
export function summarizeMatrixReplyEvent(event: MatrixRawEvent): string | undefined {
|
||||
const body = summarizeMatrixMessageContextEvent(event);
|
||||
return body ? truncateReplyBody(body) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cached resolver that fetches the body and sender of a replied-to
|
||||
* Matrix event. This allows the agent to see the content of the message being
|
||||
* replied to, not just its event ID.
|
||||
*/
|
||||
export function createMatrixReplyContextResolver(params: {
|
||||
client: MatrixClient;
|
||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}) {
|
||||
const cache = new Map<string, MatrixReplyContext>();
|
||||
|
||||
const remember = (key: string, value: MatrixReplyContext): MatrixReplyContext => {
|
||||
cache.set(key, value);
|
||||
if (cache.size > MAX_CACHED_REPLY_CONTEXTS) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return async (input: { roomId: string; eventId: string }): Promise<MatrixReplyContext> => {
|
||||
const cacheKey = `${input.roomId}:${input.eventId}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
// Move to end for LRU semantics so frequently accessed entries survive eviction.
|
||||
cache.delete(cacheKey);
|
||||
cache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const event = await params.client.getEvent(input.roomId, input.eventId).catch((err) => {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed resolving reply context room=${input.roomId} id=${input.eventId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
if (!event) {
|
||||
// Do not cache failures so transient errors can be retried on the next
|
||||
// message that references the same event.
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawEvent = event as MatrixRawEvent;
|
||||
if (rawEvent.unsigned?.redacted_because) {
|
||||
return remember(cacheKey, {});
|
||||
}
|
||||
|
||||
const replyToBody = summarizeMatrixReplyEvent(rawEvent);
|
||||
if (!replyToBody) {
|
||||
return remember(cacheKey, {});
|
||||
}
|
||||
|
||||
const senderId = trimMatrixMaybeString(rawEvent.sender);
|
||||
const senderName =
|
||||
senderId &&
|
||||
(await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
|
||||
|
||||
return remember(cacheKey, {
|
||||
replyToBody,
|
||||
replyToSender: senderName ?? senderId,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -63,6 +63,8 @@ describe("matrix thread context", () => {
|
|||
}),
|
||||
).resolves.toEqual({
|
||||
threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
|
||||
senderLabel: "Alice",
|
||||
summary: "Root topic",
|
||||
});
|
||||
|
||||
await resolveThreadContext({
|
||||
|
|
@ -113,9 +115,33 @@ describe("matrix thread context", () => {
|
|||
}),
|
||||
).resolves.toEqual({
|
||||
threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic",
|
||||
senderLabel: "Alice",
|
||||
summary: "Recovered topic",
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledTimes(2);
|
||||
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("summarizes poll start thread roots from poll content", () => {
|
||||
expect(
|
||||
summarizeMatrixThreadStarterEvent({
|
||||
event_id: "$root",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Pizza" },
|
||||
{ id: "a2", "m.text": "Sushi" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as MatrixRawEvent),
|
||||
).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import {
|
||||
formatMatrixMessageText,
|
||||
resolveMatrixMessageAttachment,
|
||||
resolveMatrixMessageBody,
|
||||
} from "../media-text.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
const MAX_TRACKED_THREAD_STARTERS = 256;
|
||||
|
|
@ -11,16 +7,10 @@ const MAX_THREAD_STARTER_BODY_LENGTH = 500;
|
|||
|
||||
type MatrixThreadContext = {
|
||||
threadStarterBody?: string;
|
||||
senderLabel?: string;
|
||||
summary?: 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;
|
||||
|
|
@ -29,27 +19,16 @@ function truncateThreadStarterBody(value: string): string {
|
|||
}
|
||||
|
||||
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {
|
||||
const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown };
|
||||
const body = formatMatrixMessageText({
|
||||
body: resolveMatrixMessageBody({
|
||||
body: trimMaybeString(content.body),
|
||||
filename: trimMaybeString(content.filename),
|
||||
msgtype: trimMaybeString(content.msgtype),
|
||||
}),
|
||||
attachment: resolveMatrixMessageAttachment({
|
||||
body: trimMaybeString(content.body),
|
||||
filename: trimMaybeString(content.filename),
|
||||
msgtype: trimMaybeString(content.msgtype),
|
||||
}),
|
||||
});
|
||||
const body = summarizeMatrixMessageContextEvent(event);
|
||||
if (body) {
|
||||
return truncateThreadStarterBody(body);
|
||||
}
|
||||
const msgtype = trimMaybeString(content.msgtype);
|
||||
const content = event.content as { msgtype?: unknown };
|
||||
const msgtype = trimMatrixMaybeString(content.msgtype);
|
||||
if (msgtype) {
|
||||
return `Matrix ${msgtype} message`;
|
||||
}
|
||||
const eventType = trimMaybeString(event.type);
|
||||
const eventType = trimMatrixMaybeString(event.type);
|
||||
return eventType ? `Matrix ${eventType} event` : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -107,17 +86,21 @@ export function createMatrixThreadContextResolver(params: {
|
|||
}
|
||||
|
||||
const rawEvent = rootEvent as MatrixRawEvent;
|
||||
const senderId = trimMaybeString(rawEvent.sender);
|
||||
const senderId = trimMatrixMaybeString(rawEvent.sender);
|
||||
const senderName =
|
||||
senderId &&
|
||||
(await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
|
||||
const senderLabel = senderName ?? senderId;
|
||||
const summary = summarizeMatrixThreadStarterEvent(rawEvent);
|
||||
return remember(cacheKey, {
|
||||
threadStarterBody: formatMatrixThreadStarterBody({
|
||||
threadRootId: input.threadRootId,
|
||||
senderId,
|
||||
senderName,
|
||||
summary: summarizeMatrixThreadStarterEvent(rawEvent),
|
||||
summary,
|
||||
}),
|
||||
senderLabel,
|
||||
summary,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,8 +480,6 @@ importers:
|
|||
specifier: ^1.2.10
|
||||
version: 1.2.10
|
||||
|
||||
extensions/microsoft-foundry: {}
|
||||
|
||||
extensions/minimax: {}
|
||||
|
||||
extensions/mistral: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue