mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 01:17:26 +00:00
refactor(channels): dedupe monitor message test flows
This commit is contained in:
@@ -119,6 +119,13 @@ vi.mock("../../config/sessions.js", () => ({
|
|||||||
const { processDiscordMessage } = await import("./message-handler.process.js");
|
const { processDiscordMessage } = await import("./message-handler.process.js");
|
||||||
|
|
||||||
const createBaseContext = createBaseDiscordMessageContext;
|
const createBaseContext = createBaseDiscordMessageContext;
|
||||||
|
const BASE_CHANNEL_ROUTE = {
|
||||||
|
agentId: "main",
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
sessionKey: "agent:main:discord:channel:c1",
|
||||||
|
mainSessionKey: "agent:main:main",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) {
|
function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) {
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
@@ -127,6 +134,10 @@ function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createNoQueuedDispatchResult() {
|
||||||
|
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
async function processStreamOffDiscordMessage() {
|
async function processStreamOffDiscordMessage() {
|
||||||
const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } });
|
const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } });
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
@@ -144,10 +155,7 @@ beforeEach(() => {
|
|||||||
recordInboundSession.mockClear();
|
recordInboundSession.mockClear();
|
||||||
readSessionUpdatedAt.mockClear();
|
readSessionUpdatedAt.mockClear();
|
||||||
resolveStorePath.mockClear();
|
resolveStorePath.mockClear();
|
||||||
dispatchInboundMessage.mockResolvedValue({
|
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
|
||||||
queuedFinal: false,
|
|
||||||
counts: { final: 0, tool: 0, block: 0 },
|
|
||||||
});
|
|
||||||
recordInboundSession.mockResolvedValue(undefined);
|
recordInboundSession.mockResolvedValue(undefined);
|
||||||
readSessionUpdatedAt.mockReturnValue(undefined);
|
readSessionUpdatedAt.mockReturnValue(undefined);
|
||||||
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
|
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
|
||||||
@@ -193,6 +201,28 @@ async function runInPartialStreamMode(): Promise<void> {
|
|||||||
await runProcessDiscordMessage(ctx);
|
await runProcessDiscordMessage(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReactionEmojis(): string[] {
|
||||||
|
return (
|
||||||
|
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
||||||
|
).map((call) => call[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDraftStreamForTest() {
|
||||||
|
const draftStream = createMockDraftStream();
|
||||||
|
createDiscordDraftStream.mockReturnValueOnce(draftStream);
|
||||||
|
return draftStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSinglePreviewEdit() {
|
||||||
|
expect(editMessageDiscord).toHaveBeenCalledWith(
|
||||||
|
"c1",
|
||||||
|
"preview-1",
|
||||||
|
{ content: "Hello\nWorld" },
|
||||||
|
{ rest: {} },
|
||||||
|
);
|
||||||
|
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
describe("processDiscordMessage ack reactions", () => {
|
describe("processDiscordMessage ack reactions", () => {
|
||||||
it("skips ack reactions for group-mentions when mentions are not required", async () => {
|
it("skips ack reactions for group-mentions when mentions are not required", async () => {
|
||||||
const ctx = await createBaseContext({
|
const ctx = await createBaseContext({
|
||||||
@@ -245,7 +275,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onReasoningStream?.();
|
await params?.replyOptions?.onReasoningStream?.();
|
||||||
await params?.replyOptions?.onToolStart?.({ name: "exec" });
|
await params?.replyOptions?.onToolStart?.({ name: "exec" });
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await createBaseContext();
|
const ctx = await createBaseContext();
|
||||||
@@ -253,9 +283,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
await processDiscordMessage(ctx as any);
|
await processDiscordMessage(ctx as any);
|
||||||
|
|
||||||
const emojis = (
|
const emojis = getReactionEmojis();
|
||||||
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
|
||||||
).map((call) => call[2]);
|
|
||||||
expect(emojis).toContain("👀");
|
expect(emojis).toContain("👀");
|
||||||
expect(emojis).toContain(DEFAULT_EMOJIS.done);
|
expect(emojis).toContain(DEFAULT_EMOJIS.done);
|
||||||
expect(emojis).not.toContain(DEFAULT_EMOJIS.thinking);
|
expect(emojis).not.toContain(DEFAULT_EMOJIS.thinking);
|
||||||
@@ -270,7 +298,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
});
|
});
|
||||||
dispatchInboundMessage.mockImplementationOnce(async () => {
|
dispatchInboundMessage.mockImplementationOnce(async () => {
|
||||||
await dispatchGate;
|
await dispatchGate;
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await createBaseContext();
|
const ctx = await createBaseContext();
|
||||||
@@ -293,7 +321,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
it("applies status reaction emoji/timing overrides from config", async () => {
|
it("applies status reaction emoji/timing overrides from config", async () => {
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onReasoningStream?.();
|
await params?.replyOptions?.onReasoningStream?.();
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await createBaseContext({
|
const ctx = await createBaseContext({
|
||||||
@@ -312,9 +340,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
await processDiscordMessage(ctx as any);
|
await processDiscordMessage(ctx as any);
|
||||||
|
|
||||||
const emojis = (
|
const emojis = getReactionEmojis();
|
||||||
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
|
||||||
).map((call) => call[2]);
|
|
||||||
expect(emojis).toContain("🟦");
|
expect(emojis).toContain("🟦");
|
||||||
expect(emojis).toContain("🏁");
|
expect(emojis).toContain("🏁");
|
||||||
});
|
});
|
||||||
@@ -347,13 +373,7 @@ describe("processDiscordMessage session routing", () => {
|
|||||||
it("stores group lastRoute with channel target", async () => {
|
it("stores group lastRoute with channel target", async () => {
|
||||||
const ctx = await createBaseContext({
|
const ctx = await createBaseContext({
|
||||||
baseSessionKey: "agent:main:discord:channel:c1",
|
baseSessionKey: "agent:main:discord:channel:c1",
|
||||||
route: {
|
route: BASE_CHANNEL_ROUTE,
|
||||||
agentId: "main",
|
|
||||||
channel: "discord",
|
|
||||||
accountId: "default",
|
|
||||||
sessionKey: "agent:main:discord:channel:c1",
|
|
||||||
mainSessionKey: "agent:main:main",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
@@ -389,13 +409,7 @@ describe("processDiscordMessage session routing", () => {
|
|||||||
threadChannel: { id: "thread-1", name: "subagent-thread" },
|
threadChannel: { id: "thread-1", name: "subagent-thread" },
|
||||||
boundSessionKey: "agent:main:subagent:child",
|
boundSessionKey: "agent:main:subagent:child",
|
||||||
threadBindings,
|
threadBindings,
|
||||||
route: {
|
route: BASE_CHANNEL_ROUTE,
|
||||||
agentId: "main",
|
|
||||||
channel: "discord",
|
|
||||||
accountId: "default",
|
|
||||||
sessionKey: "agent:main:discord:channel:c1",
|
|
||||||
mainSessionKey: "agent:main:main",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
@@ -446,26 +460,12 @@ describe("processDiscordMessage draft streaming", () => {
|
|||||||
|
|
||||||
it("finalizes via preview edit when final fits one chunk", async () => {
|
it("finalizes via preview edit when final fits one chunk", async () => {
|
||||||
await runSingleChunkFinalScenario({ streamMode: "partial", maxLinesPerMessage: 5 });
|
await runSingleChunkFinalScenario({ streamMode: "partial", maxLinesPerMessage: 5 });
|
||||||
|
expectSinglePreviewEdit();
|
||||||
expect(editMessageDiscord).toHaveBeenCalledWith(
|
|
||||||
"c1",
|
|
||||||
"preview-1",
|
|
||||||
{ content: "Hello\nWorld" },
|
|
||||||
{ rest: {} },
|
|
||||||
);
|
|
||||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts streaming=true alias for partial preview mode", async () => {
|
it("accepts streaming=true alias for partial preview mode", async () => {
|
||||||
await runSingleChunkFinalScenario({ streaming: true, maxLinesPerMessage: 5 });
|
await runSingleChunkFinalScenario({ streaming: true, maxLinesPerMessage: 5 });
|
||||||
|
expectSinglePreviewEdit();
|
||||||
expect(editMessageDiscord).toHaveBeenCalledWith(
|
|
||||||
"c1",
|
|
||||||
"preview-1",
|
|
||||||
{ content: "Hello\nWorld" },
|
|
||||||
{ rest: {} },
|
|
||||||
);
|
|
||||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to standard send when final needs multiple chunks", async () => {
|
it("falls back to standard send when final needs multiple chunks", async () => {
|
||||||
@@ -508,12 +508,11 @@ describe("processDiscordMessage draft streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("streams block previews using draft chunking", async () => {
|
it("streams block previews using draft chunking", async () => {
|
||||||
const draftStream = createMockDraftStream();
|
const draftStream = createMockDraftStreamForTest();
|
||||||
createDiscordDraftStream.mockReturnValueOnce(draftStream);
|
|
||||||
|
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onPartialReply?.({ text: "HelloWorld" });
|
await params?.replyOptions?.onPartialReply?.({ text: "HelloWorld" });
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await createBlockModeContext();
|
const ctx = await createBlockModeContext();
|
||||||
@@ -526,13 +525,12 @@ describe("processDiscordMessage draft streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forces new preview messages on assistant boundaries in block mode", async () => {
|
it("forces new preview messages on assistant boundaries in block mode", async () => {
|
||||||
const draftStream = createMockDraftStream();
|
const draftStream = createMockDraftStreamForTest();
|
||||||
createDiscordDraftStream.mockReturnValueOnce(draftStream);
|
|
||||||
|
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onPartialReply?.({ text: "Hello" });
|
await params?.replyOptions?.onPartialReply?.({ text: "Hello" });
|
||||||
await params?.replyOptions?.onAssistantMessageStart?.();
|
await params?.replyOptions?.onAssistantMessageStart?.();
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await createBlockModeContext();
|
const ctx = await createBlockModeContext();
|
||||||
@@ -544,14 +542,13 @@ describe("processDiscordMessage draft streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips reasoning tags from partial stream updates", async () => {
|
it("strips reasoning tags from partial stream updates", async () => {
|
||||||
const draftStream = createMockDraftStream();
|
const draftStream = createMockDraftStreamForTest();
|
||||||
createDiscordDraftStream.mockReturnValueOnce(draftStream);
|
|
||||||
|
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onPartialReply?.({
|
await params?.replyOptions?.onPartialReply?.({
|
||||||
text: "<thinking>Let me think about this</thinking>\nThe answer is 42",
|
text: "<thinking>Let me think about this</thinking>\nThe answer is 42",
|
||||||
});
|
});
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
await runInPartialStreamMode();
|
await runInPartialStreamMode();
|
||||||
@@ -563,14 +560,13 @@ describe("processDiscordMessage draft streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips pure-reasoning partial updates without updating draft", async () => {
|
it("skips pure-reasoning partial updates without updating draft", async () => {
|
||||||
const draftStream = createMockDraftStream();
|
const draftStream = createMockDraftStreamForTest();
|
||||||
createDiscordDraftStream.mockReturnValueOnce(draftStream);
|
|
||||||
|
|
||||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||||
await params?.replyOptions?.onPartialReply?.({
|
await params?.replyOptions?.onPartialReply?.({
|
||||||
text: "Reasoning:\nThe user asked about X so I need to consider Y",
|
text: "Reasoning:\nThe user asked about X so I need to consider Y",
|
||||||
});
|
});
|
||||||
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
|
return createNoQueuedDispatchResult();
|
||||||
});
|
});
|
||||||
|
|
||||||
await runInPartialStreamMode();
|
await runInPartialStreamMode();
|
||||||
|
|||||||
@@ -106,6 +106,50 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runChannelMessageEvent(
|
||||||
|
text: string,
|
||||||
|
overrides: Partial<SlackMessageEvent> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await runSlackMessageOnce(monitorSlackProvider, {
|
||||||
|
event: makeSlackMessageEvent({
|
||||||
|
text,
|
||||||
|
channel_type: "channel",
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHistoryCaptureConfig(channels: Record<string, unknown>) {
|
||||||
|
slackTestState.config = {
|
||||||
|
messages: { ackReactionScope: "group-mentions" },
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
historyLimit: 5,
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
channels,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureReplyContexts<T extends Record<string, unknown>>() {
|
||||||
|
const contexts: T[] = [];
|
||||||
|
replyMock.mockImplementation(async (ctx: unknown) => {
|
||||||
|
contexts.push((ctx ?? {}) as T);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
return contexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMonitoredSlackMessages(events: SlackMessageEvent[]) {
|
||||||
|
const { controller, run } = startSlackMonitor(monitorSlackProvider);
|
||||||
|
const handler = await getSlackHandlerOrThrow("message");
|
||||||
|
for (const event of events) {
|
||||||
|
await handler({ event });
|
||||||
|
}
|
||||||
|
await stopSlackMonitor({ controller, run });
|
||||||
|
}
|
||||||
|
|
||||||
function setPairingOnlyDirectMessages() {
|
function setPairingOnlyDirectMessages() {
|
||||||
const currentConfig = slackTestState.config as {
|
const currentConfig = slackTestState.config as {
|
||||||
channels?: { slack?: Record<string, unknown> };
|
channels?: { slack?: Record<string, unknown> };
|
||||||
@@ -122,6 +166,61 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOpenChannelDirectMessages(params?: {
|
||||||
|
bindings?: Array<Record<string, unknown>>;
|
||||||
|
groupPolicy?: "open";
|
||||||
|
includeAckReactionConfig?: boolean;
|
||||||
|
replyToMode?: "off" | "all" | "first";
|
||||||
|
threadInheritParent?: boolean;
|
||||||
|
}) {
|
||||||
|
const slackChannelConfig: Record<string, unknown> = {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
channels: { C1: { allow: true, requireMention: false } },
|
||||||
|
...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
|
||||||
|
...(params?.replyToMode ? { replyToMode: params.replyToMode } : {}),
|
||||||
|
...(params?.threadInheritParent ? { thread: { inheritParent: true } } : {}),
|
||||||
|
};
|
||||||
|
slackTestState.config = {
|
||||||
|
messages: params?.includeAckReactionConfig
|
||||||
|
? {
|
||||||
|
responsePrefix: "PFX",
|
||||||
|
ackReaction: "👀",
|
||||||
|
ackReactionScope: "group-mentions",
|
||||||
|
}
|
||||||
|
: { responsePrefix: "PFX" },
|
||||||
|
channels: { slack: slackChannelConfig },
|
||||||
|
...(params?.bindings ? { bindings: params.bindings } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstReplySessionCtx(): {
|
||||||
|
SessionKey?: string;
|
||||||
|
ParentSessionKey?: string;
|
||||||
|
ThreadStarterBody?: string;
|
||||||
|
ThreadLabel?: string;
|
||||||
|
} {
|
||||||
|
return (replyMock.mock.calls[0]?.[0] ?? {}) as {
|
||||||
|
SessionKey?: string;
|
||||||
|
ParentSessionKey?: string;
|
||||||
|
ThreadStarterBody?: string;
|
||||||
|
ThreadLabel?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSingleSendWithThread(threadTs: string | undefined) {
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDefaultMessageAndExpectSentText(expectedText: string) {
|
||||||
|
replyMock.mockResolvedValue({ text: expectedText.replace(/^PFX /, "") });
|
||||||
|
await runSlackMessageOnce(monitorSlackProvider, {
|
||||||
|
event: makeSlackMessageEvent(),
|
||||||
|
});
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock.mock.calls[0][1]).toBe(expectedText);
|
||||||
|
}
|
||||||
|
|
||||||
it("skips socket startup when Slack channel is disabled", async () => {
|
it("skips socket startup when Slack channel is disabled", async () => {
|
||||||
slackTestState.config = {
|
slackTestState.config = {
|
||||||
channels: {
|
channels: {
|
||||||
@@ -149,14 +248,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips tool summaries with responsePrefix", async () => {
|
it("skips tool summaries with responsePrefix", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "final reply" });
|
await runDefaultMessageAndExpectSentText("PFX final reply");
|
||||||
|
|
||||||
await runSlackMessageOnce(monitorSlackProvider, {
|
|
||||||
event: makeSlackMessageEvent(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops events with mismatched api_app_id", async () => {
|
it("drops events with mismatched api_app_id", async () => {
|
||||||
@@ -213,127 +305,56 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
replyMock.mockResolvedValue({ text: "final reply" });
|
await runDefaultMessageAndExpectSentText("final reply");
|
||||||
|
|
||||||
await runSlackMessageOnce(monitorSlackProvider, {
|
|
||||||
event: makeSlackMessageEvent(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendMock.mock.calls[0][1]).toBe("final reply");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves RawBody without injecting processed room history", async () => {
|
it("preserves RawBody without injecting processed room history", async () => {
|
||||||
slackTestState.config = {
|
setHistoryCaptureConfig({ "*": { requireMention: false } });
|
||||||
messages: { ackReactionScope: "group-mentions" },
|
const capturedCtx = captureReplyContexts<{
|
||||||
channels: {
|
Body?: string;
|
||||||
slack: {
|
RawBody?: string;
|
||||||
historyLimit: 5,
|
CommandBody?: string;
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
}>();
|
||||||
channels: { "*": { requireMention: false } },
|
await runMonitoredSlackMessages([
|
||||||
},
|
makeSlackMessageEvent({ user: "U1", text: "first", ts: "123", channel_type: "channel" }),
|
||||||
},
|
makeSlackMessageEvent({ user: "U2", text: "second", ts: "124", channel_type: "channel" }),
|
||||||
};
|
]);
|
||||||
|
|
||||||
let capturedCtx: { Body?: string; RawBody?: string; CommandBody?: string } = {};
|
|
||||||
replyMock.mockImplementation(async (ctx: unknown) => {
|
|
||||||
capturedCtx = ctx ?? {};
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { controller, run } = startSlackMonitor(monitorSlackProvider);
|
|
||||||
const handler = await getSlackHandlerOrThrow("message");
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
event: {
|
|
||||||
type: "message",
|
|
||||||
user: "U1",
|
|
||||||
text: "first",
|
|
||||||
ts: "123",
|
|
||||||
channel: "C1",
|
|
||||||
channel_type: "channel",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
event: {
|
|
||||||
type: "message",
|
|
||||||
user: "U2",
|
|
||||||
text: "second",
|
|
||||||
ts: "124",
|
|
||||||
channel: "C1",
|
|
||||||
channel_type: "channel",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await stopSlackMonitor({ controller, run });
|
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(2);
|
expect(replyMock).toHaveBeenCalledTimes(2);
|
||||||
expect(capturedCtx.Body).not.toContain(HISTORY_CONTEXT_MARKER);
|
const latestCtx = capturedCtx.at(-1) ?? {};
|
||||||
expect(capturedCtx.Body).not.toContain(CURRENT_MESSAGE_MARKER);
|
expect(latestCtx.Body).not.toContain(HISTORY_CONTEXT_MARKER);
|
||||||
expect(capturedCtx.Body).not.toContain("first");
|
expect(latestCtx.Body).not.toContain(CURRENT_MESSAGE_MARKER);
|
||||||
expect(capturedCtx.RawBody).toBe("second");
|
expect(latestCtx.Body).not.toContain("first");
|
||||||
expect(capturedCtx.CommandBody).toBe("second");
|
expect(latestCtx.RawBody).toBe("second");
|
||||||
|
expect(latestCtx.CommandBody).toBe("second");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scopes thread history to the thread by default", async () => {
|
it("scopes thread history to the thread by default", async () => {
|
||||||
slackTestState.config = {
|
setHistoryCaptureConfig({ C1: { allow: true, requireMention: true } });
|
||||||
messages: { ackReactionScope: "group-mentions" },
|
const capturedCtx = captureReplyContexts<{ Body?: string }>();
|
||||||
channels: {
|
await runMonitoredSlackMessages([
|
||||||
slack: {
|
makeSlackMessageEvent({
|
||||||
historyLimit: 5,
|
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
|
||||||
channels: { C1: { allow: true, requireMention: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const capturedCtx: Array<{ Body?: string }> = [];
|
|
||||||
replyMock.mockImplementation(async (ctx: unknown) => {
|
|
||||||
capturedCtx.push(ctx ?? {});
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { controller, run } = startSlackMonitor(monitorSlackProvider);
|
|
||||||
const handler = await getSlackHandlerOrThrow("message");
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
event: {
|
|
||||||
type: "message",
|
|
||||||
user: "U1",
|
user: "U1",
|
||||||
text: "thread-a-one",
|
text: "thread-a-one",
|
||||||
ts: "200",
|
ts: "200",
|
||||||
thread_ts: "100",
|
thread_ts: "100",
|
||||||
channel: "C1",
|
|
||||||
channel_type: "channel",
|
channel_type: "channel",
|
||||||
},
|
}),
|
||||||
});
|
makeSlackMessageEvent({
|
||||||
|
|
||||||
await handler({
|
|
||||||
event: {
|
|
||||||
type: "message",
|
|
||||||
user: "U1",
|
user: "U1",
|
||||||
text: "<@bot-user> thread-a-two",
|
text: "<@bot-user> thread-a-two",
|
||||||
ts: "201",
|
ts: "201",
|
||||||
thread_ts: "100",
|
thread_ts: "100",
|
||||||
channel: "C1",
|
|
||||||
channel_type: "channel",
|
channel_type: "channel",
|
||||||
},
|
}),
|
||||||
});
|
makeSlackMessageEvent({
|
||||||
|
|
||||||
await handler({
|
|
||||||
event: {
|
|
||||||
type: "message",
|
|
||||||
user: "U2",
|
user: "U2",
|
||||||
text: "<@bot-user> thread-b-one",
|
text: "<@bot-user> thread-b-one",
|
||||||
ts: "301",
|
ts: "301",
|
||||||
thread_ts: "300",
|
thread_ts: "300",
|
||||||
channel: "C1",
|
|
||||||
channel_type: "channel",
|
channel_type: "channel",
|
||||||
},
|
}),
|
||||||
});
|
]);
|
||||||
|
|
||||||
await stopSlackMonitor({ controller, run });
|
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(2);
|
expect(replyMock).toHaveBeenCalledTimes(2);
|
||||||
expect(capturedCtx[0]?.Body).toContain("thread-a-one");
|
expect(capturedCtx[0]?.Body).toContain("thread-a-one");
|
||||||
@@ -438,13 +459,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
|
|
||||||
it("treats control commands as mentions for group bypass", async () => {
|
it("treats control commands as mentions for group bypass", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "ok" });
|
replyMock.mockResolvedValue({ text: "ok" });
|
||||||
|
await runChannelMessageEvent("/elevated off");
|
||||||
await runSlackMessageOnce(monitorSlackProvider, {
|
|
||||||
event: makeSlackMessageEvent({
|
|
||||||
text: "/elevated off",
|
|
||||||
channel_type: "channel",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(firstReplyCtx().WasMentioned).toBe(true);
|
expect(firstReplyCtx().WasMentioned).toBe(true);
|
||||||
@@ -452,25 +467,14 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
|
|
||||||
it("threads replies when incoming message is in a thread", async () => {
|
it("threads replies when incoming message is in a thread", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
slackTestState.config = {
|
setOpenChannelDirectMessages({
|
||||||
messages: {
|
includeAckReactionConfig: true,
|
||||||
responsePrefix: "PFX",
|
groupPolicy: "open",
|
||||||
ackReaction: "👀",
|
replyToMode: "off",
|
||||||
ackReactionScope: "group-mentions",
|
});
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
|
||||||
groupPolicy: "open",
|
|
||||||
replyToMode: "off",
|
|
||||||
channels: { C1: { allow: true, requireMention: false } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await runChannelThreadReplyEvent();
|
await runChannelThreadReplyEvent();
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread("111.222");
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "111.222" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores replyToId directive when replyToMode is off", async () => {
|
it("ignores replyToId directive when replyToMode is off", async () => {
|
||||||
@@ -497,8 +501,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread(undefined);
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps replyToId directive threading when replyToMode is all", async () => {
|
it("keeps replyToId directive threading when replyToMode is all", async () => {
|
||||||
@@ -511,8 +514,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread("555");
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "555" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
|
||||||
@@ -581,8 +583,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
setDirectMessageReplyMode("all");
|
setDirectMessageReplyMode("all");
|
||||||
await runDirectMessageEvent("123");
|
await runDirectMessageEvent("123");
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread("123");
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "123" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
|
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
|
||||||
@@ -596,27 +597,14 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
const ctx = getFirstReplySessionCtx();
|
||||||
SessionKey?: string;
|
|
||||||
ParentSessionKey?: string;
|
|
||||||
};
|
|
||||||
expect(ctx.SessionKey).toBe("agent:main:main:thread:123");
|
expect(ctx.SessionKey).toBe("agent:main:main:thread:123");
|
||||||
expect(ctx.ParentSessionKey).toBeUndefined();
|
expect(ctx.ParentSessionKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps thread parent inheritance opt-in", async () => {
|
it("keeps thread parent inheritance opt-in", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
|
setOpenChannelDirectMessages({ threadInheritParent: true });
|
||||||
slackTestState.config = {
|
|
||||||
messages: { responsePrefix: "PFX" },
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
|
||||||
channels: { C1: { allow: true, requireMention: false } },
|
|
||||||
thread: { inheritParent: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await runSlackMessageOnce(monitorSlackProvider, {
|
await runSlackMessageOnce(monitorSlackProvider, {
|
||||||
event: makeSlackMessageEvent({
|
event: makeSlackMessageEvent({
|
||||||
@@ -626,10 +614,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
const ctx = getFirstReplySessionCtx();
|
||||||
SessionKey?: string;
|
|
||||||
ParentSessionKey?: string;
|
|
||||||
};
|
|
||||||
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
|
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
|
||||||
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1");
|
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1");
|
||||||
});
|
});
|
||||||
@@ -649,25 +634,12 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
slackTestState.config = {
|
setOpenChannelDirectMessages();
|
||||||
messages: { responsePrefix: "PFX" },
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
|
||||||
channels: { C1: { allow: true, requireMention: false } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await runChannelThreadReplyEvent();
|
await runChannelThreadReplyEvent();
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
const ctx = getFirstReplySessionCtx();
|
||||||
SessionKey?: string;
|
|
||||||
ParentSessionKey?: string;
|
|
||||||
ThreadStarterBody?: string;
|
|
||||||
ThreadLabel?: string;
|
|
||||||
};
|
|
||||||
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
|
expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222");
|
||||||
expect(ctx.ParentSessionKey).toBeUndefined();
|
expect(ctx.ParentSessionKey).toBeUndefined();
|
||||||
expect(ctx.ThreadStarterBody).toContain("starter message");
|
expect(ctx.ThreadStarterBody).toContain("starter message");
|
||||||
@@ -676,16 +648,9 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
|
|
||||||
it("scopes thread session keys to the routed agent", async () => {
|
it("scopes thread session keys to the routed agent", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "ok" });
|
replyMock.mockResolvedValue({ text: "ok" });
|
||||||
slackTestState.config = {
|
setOpenChannelDirectMessages({
|
||||||
messages: { responsePrefix: "PFX" },
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
|
||||||
channels: { C1: { allow: true, requireMention: false } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bindings: [{ agentId: "support", match: { channel: "slack", teamId: "T1" } }],
|
bindings: [{ agentId: "support", match: { channel: "slack", teamId: "T1" } }],
|
||||||
};
|
});
|
||||||
|
|
||||||
const client = getSlackClient();
|
const client = getSlackClient();
|
||||||
if (client?.auth?.test) {
|
if (client?.auth?.test) {
|
||||||
@@ -703,10 +668,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
await runChannelThreadReplyEvent();
|
await runChannelThreadReplyEvent();
|
||||||
|
|
||||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
const ctx = getFirstReplySessionCtx();
|
||||||
SessionKey?: string;
|
|
||||||
ParentSessionKey?: string;
|
|
||||||
};
|
|
||||||
expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222");
|
expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222");
|
||||||
expect(ctx.ParentSessionKey).toBeUndefined();
|
expect(ctx.ParentSessionKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -716,8 +678,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
setDirectMessageReplyMode("off");
|
setDirectMessageReplyMode("off");
|
||||||
await runDirectMessageEvent("789");
|
await runDirectMessageEvent("789");
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread(undefined);
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("threads first reply when replyToMode is first and message is not threaded", async () => {
|
it("threads first reply when replyToMode is first and message is not threaded", async () => {
|
||||||
@@ -725,8 +686,6 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
setDirectMessageReplyMode("first");
|
setDirectMessageReplyMode("first");
|
||||||
await runDirectMessageEvent("789");
|
await runDirectMessageEvent("789");
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expectSingleSendWithThread("789");
|
||||||
// First reply starts a thread under the incoming message
|
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,141 +2,152 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||||
|
|
||||||
const transcribeFirstAudioMock = vi.fn();
|
const transcribeFirstAudioMock = vi.fn();
|
||||||
|
const DEFAULT_MODEL = "anthropic/claude-opus-4-5";
|
||||||
|
const DEFAULT_WORKSPACE = "/tmp/openclaw";
|
||||||
|
const DEFAULT_MENTION_PATTERN = "\\bbot\\b";
|
||||||
|
|
||||||
vi.mock("../media-understanding/audio-preflight.js", () => ({
|
vi.mock("../media-understanding/audio-preflight.js", () => ({
|
||||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
async function buildGroupVoiceContext(params: {
|
||||||
|
messageId: number;
|
||||||
|
chatId: number;
|
||||||
|
title: string;
|
||||||
|
date: number;
|
||||||
|
fromId: number;
|
||||||
|
firstName: string;
|
||||||
|
fileId: string;
|
||||||
|
mediaPath: string;
|
||||||
|
groupDisableAudioPreflight?: boolean;
|
||||||
|
topicDisableAudioPreflight?: boolean;
|
||||||
|
}) {
|
||||||
|
const groupConfig = {
|
||||||
|
requireMention: true,
|
||||||
|
...(params.groupDisableAudioPreflight === undefined
|
||||||
|
? {}
|
||||||
|
: { disableAudioPreflight: params.groupDisableAudioPreflight }),
|
||||||
|
};
|
||||||
|
const topicConfig =
|
||||||
|
params.topicDisableAudioPreflight === undefined
|
||||||
|
? undefined
|
||||||
|
: { disableAudioPreflight: params.topicDisableAudioPreflight };
|
||||||
|
|
||||||
|
return buildTelegramMessageContextForTest({
|
||||||
|
message: {
|
||||||
|
message_id: params.messageId,
|
||||||
|
chat: { id: params.chatId, type: "supergroup", title: params.title },
|
||||||
|
date: params.date,
|
||||||
|
text: undefined,
|
||||||
|
from: { id: params.fromId, first_name: params.firstName },
|
||||||
|
voice: { file_id: params.fileId },
|
||||||
|
},
|
||||||
|
allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }],
|
||||||
|
options: { forceWasMentioned: true },
|
||||||
|
cfg: {
|
||||||
|
agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } },
|
||||||
|
channels: { telegram: {} },
|
||||||
|
messages: { groupChat: { mentionPatterns: [DEFAULT_MENTION_PATTERN] } },
|
||||||
|
},
|
||||||
|
resolveGroupActivation: () => true,
|
||||||
|
resolveGroupRequireMention: () => true,
|
||||||
|
resolveTelegramGroupConfig: () => ({
|
||||||
|
groupConfig,
|
||||||
|
topicConfig,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectTranscriptRendered(
|
||||||
|
ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>,
|
||||||
|
transcript: string,
|
||||||
|
) {
|
||||||
|
expect(ctx).not.toBeNull();
|
||||||
|
expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript);
|
||||||
|
expect(ctx?.ctxPayload?.Body).toContain(transcript);
|
||||||
|
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectAudioPlaceholderRendered(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
|
||||||
|
expect(ctx).not.toBeNull();
|
||||||
|
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildTelegramMessageContext audio transcript body", () => {
|
describe("buildTelegramMessageContext audio transcript body", () => {
|
||||||
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
|
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
|
||||||
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
|
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
|
||||||
|
|
||||||
const ctx = await buildTelegramMessageContextForTest({
|
const ctx = await buildGroupVoiceContext({
|
||||||
message: {
|
messageId: 1,
|
||||||
message_id: 1,
|
chatId: -1001234567890,
|
||||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
title: "Test Group",
|
||||||
date: 1700000000,
|
date: 1700000000,
|
||||||
text: undefined,
|
fromId: 42,
|
||||||
from: { id: 42, first_name: "Alice" },
|
firstName: "Alice",
|
||||||
voice: { file_id: "voice-1" },
|
fileId: "voice-1",
|
||||||
},
|
mediaPath: "/tmp/voice.ogg",
|
||||||
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
|
|
||||||
options: { forceWasMentioned: true },
|
|
||||||
cfg: {
|
|
||||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
|
||||||
channels: { telegram: {} },
|
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
|
||||||
},
|
|
||||||
resolveGroupActivation: () => true,
|
|
||||||
resolveGroupRequireMention: () => true,
|
|
||||||
resolveTelegramGroupConfig: () => ({
|
|
||||||
groupConfig: { requireMention: true },
|
|
||||||
topicConfig: undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).not.toBeNull();
|
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ctx?.ctxPayload?.BodyForAgent).toBe("hey bot please help");
|
expectTranscriptRendered(ctx, "hey bot please help");
|
||||||
expect(ctx?.ctxPayload?.Body).toContain("hey bot please help");
|
|
||||||
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips preflight transcription when disableAudioPreflight is true", async () => {
|
it("skips preflight transcription when disableAudioPreflight is true", async () => {
|
||||||
transcribeFirstAudioMock.mockClear();
|
transcribeFirstAudioMock.mockClear();
|
||||||
|
|
||||||
const ctx = await buildTelegramMessageContextForTest({
|
const ctx = await buildGroupVoiceContext({
|
||||||
message: {
|
messageId: 2,
|
||||||
message_id: 2,
|
chatId: -1001234567891,
|
||||||
chat: { id: -1001234567891, type: "supergroup", title: "Test Group 2" },
|
title: "Test Group 2",
|
||||||
date: 1700000100,
|
date: 1700000100,
|
||||||
text: undefined,
|
fromId: 43,
|
||||||
from: { id: 43, first_name: "Bob" },
|
firstName: "Bob",
|
||||||
voice: { file_id: "voice-2" },
|
fileId: "voice-2",
|
||||||
},
|
mediaPath: "/tmp/voice2.ogg",
|
||||||
allMedia: [{ path: "/tmp/voice2.ogg", contentType: "audio/ogg" }],
|
groupDisableAudioPreflight: true,
|
||||||
options: { forceWasMentioned: true },
|
|
||||||
cfg: {
|
|
||||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
|
||||||
channels: { telegram: {} },
|
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
|
||||||
},
|
|
||||||
resolveGroupActivation: () => true,
|
|
||||||
resolveGroupRequireMention: () => true,
|
|
||||||
resolveTelegramGroupConfig: () => ({
|
|
||||||
groupConfig: { requireMention: true, disableAudioPreflight: true },
|
|
||||||
topicConfig: undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).not.toBeNull();
|
|
||||||
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
||||||
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
|
expectAudioPlaceholderRendered(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => {
|
it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => {
|
||||||
transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript");
|
transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript");
|
||||||
|
|
||||||
const ctx = await buildTelegramMessageContextForTest({
|
const ctx = await buildGroupVoiceContext({
|
||||||
message: {
|
messageId: 3,
|
||||||
message_id: 3,
|
chatId: -1001234567892,
|
||||||
chat: { id: -1001234567892, type: "supergroup", title: "Test Group 3" },
|
title: "Test Group 3",
|
||||||
date: 1700000200,
|
date: 1700000200,
|
||||||
text: undefined,
|
fromId: 44,
|
||||||
from: { id: 44, first_name: "Cara" },
|
firstName: "Cara",
|
||||||
voice: { file_id: "voice-3" },
|
fileId: "voice-3",
|
||||||
},
|
mediaPath: "/tmp/voice3.ogg",
|
||||||
allMedia: [{ path: "/tmp/voice3.ogg", contentType: "audio/ogg" }],
|
groupDisableAudioPreflight: true,
|
||||||
options: { forceWasMentioned: true },
|
topicDisableAudioPreflight: false,
|
||||||
cfg: {
|
|
||||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
|
||||||
channels: { telegram: {} },
|
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
|
||||||
},
|
|
||||||
resolveGroupActivation: () => true,
|
|
||||||
resolveGroupRequireMention: () => true,
|
|
||||||
resolveTelegramGroupConfig: () => ({
|
|
||||||
groupConfig: { requireMention: true, disableAudioPreflight: true },
|
|
||||||
topicConfig: { disableAudioPreflight: false },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).not.toBeNull();
|
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ctx?.ctxPayload?.BodyForAgent).toBe("topic override transcript");
|
expectTranscriptRendered(ctx, "topic override transcript");
|
||||||
expect(ctx?.ctxPayload?.Body).toContain("topic override transcript");
|
|
||||||
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => {
|
it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => {
|
||||||
transcribeFirstAudioMock.mockClear();
|
transcribeFirstAudioMock.mockClear();
|
||||||
|
|
||||||
const ctx = await buildTelegramMessageContextForTest({
|
const ctx = await buildGroupVoiceContext({
|
||||||
message: {
|
messageId: 4,
|
||||||
message_id: 4,
|
chatId: -1001234567893,
|
||||||
chat: { id: -1001234567893, type: "supergroup", title: "Test Group 4" },
|
title: "Test Group 4",
|
||||||
date: 1700000300,
|
date: 1700000300,
|
||||||
text: undefined,
|
fromId: 45,
|
||||||
from: { id: 45, first_name: "Dan" },
|
firstName: "Dan",
|
||||||
voice: { file_id: "voice-4" },
|
fileId: "voice-4",
|
||||||
},
|
mediaPath: "/tmp/voice4.ogg",
|
||||||
allMedia: [{ path: "/tmp/voice4.ogg", contentType: "audio/ogg" }],
|
groupDisableAudioPreflight: false,
|
||||||
options: { forceWasMentioned: true },
|
topicDisableAudioPreflight: true,
|
||||||
cfg: {
|
|
||||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
|
||||||
channels: { telegram: {} },
|
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
|
||||||
},
|
|
||||||
resolveGroupActivation: () => true,
|
|
||||||
resolveGroupRequireMention: () => true,
|
|
||||||
resolveTelegramGroupConfig: () => ({
|
|
||||||
groupConfig: { requireMention: true, disableAudioPreflight: false },
|
|
||||||
topicConfig: { disableAudioPreflight: true },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).not.toBeNull();
|
|
||||||
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
||||||
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
|
expectAudioPlaceholderRendered(ctx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("web monitor inbox", () => {
|
|||||||
const sock = getSock();
|
const sock = getSock();
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
sock.ev.emit("messages.upsert", upsert);
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
return { onMessage, listener };
|
return { onMessage, listener, sock };
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSingleGroupMessage(
|
function expectSingleGroupMessage(
|
||||||
@@ -44,10 +44,7 @@ describe("web monitor inbox", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("captures media path for image messages", async () => {
|
it("captures media path for image messages", async () => {
|
||||||
const onMessage = vi.fn();
|
const { onMessage, listener, sock } = await runSingleUpsertAndCapture({
|
||||||
const listener = await openMonitor(onMessage);
|
|
||||||
const sock = getSock();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
type: "notify",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -56,10 +53,7 @@ describe("web monitor inbox", () => {
|
|||||||
messageTimestamp: 1_700_000_100,
|
messageTimestamp: 1_700_000_100,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledWith(
|
expect(onMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -116,10 +110,7 @@ describe("web monitor inbox", () => {
|
|||||||
const logPath = path.join(os.tmpdir(), `openclaw-log-test-${crypto.randomUUID()}.log`);
|
const logPath = path.join(os.tmpdir(), `openclaw-log-test-${crypto.randomUUID()}.log`);
|
||||||
setLoggerOverride({ level: "trace", file: logPath });
|
setLoggerOverride({ level: "trace", file: logPath });
|
||||||
|
|
||||||
const onMessage = vi.fn();
|
const { listener } = await runSingleUpsertAndCapture({
|
||||||
const listener = await openMonitor(onMessage);
|
|
||||||
const sock = getSock();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
type: "notify",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -129,10 +120,7 @@ describe("web monitor inbox", () => {
|
|||||||
pushName: "Tester",
|
pushName: "Tester",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitFor(
|
||||||
() => {
|
() => {
|
||||||
@@ -147,10 +135,7 @@ describe("web monitor inbox", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes participant when marking group messages read", async () => {
|
it("includes participant when marking group messages read", async () => {
|
||||||
const onMessage = vi.fn();
|
const { listener, sock } = await runSingleUpsertAndCapture({
|
||||||
const listener = await openMonitor(onMessage);
|
|
||||||
const sock = getSock();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
type: "notify",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -163,10 +148,7 @@ describe("web monitor inbox", () => {
|
|||||||
message: { conversation: "group ping" },
|
message: { conversation: "group ping" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
@@ -180,10 +162,7 @@ describe("web monitor inbox", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes through group messages with participant metadata", async () => {
|
it("passes through group messages with participant metadata", async () => {
|
||||||
const onMessage = vi.fn();
|
const { onMessage, listener } = await runSingleUpsertAndCapture({
|
||||||
const listener = await openMonitor(onMessage);
|
|
||||||
const sock = getSock();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
type: "notify",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -203,10 +182,7 @@ describe("web monitor inbox", () => {
|
|||||||
messageTimestamp: 1_700_000_000,
|
messageTimestamp: 1_700_000_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledWith(
|
expect(onMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
Reference in New Issue
Block a user