Skip to content

Commit 2b61d49

Browse files
steipetemiorbnli
andcommitted
fix(nextcloud-talk): sanitize inbound replies
Co-authored-by: liyuanbin <[email protected]>
1 parent ccc41f1 commit 2b61d49

5 files changed

Lines changed: 154 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **Nextcloud Talk assistant replies:** strip internal tool-trace banners and XML scaffolding from both direct sends and inbound-triggered replies while preserving ordinary prose and code examples. (#101712) Thanks @Pick-cat.
2627
- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc.
2728
- **OAuth refresh contention diagnostics:** keep local lock paths out of user-facing refresh failures and avoid duplicate failure prefixes while preserving structured provider and profile classification. (#83383) Thanks @vincentkoc.
2829
- **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc.

extensions/nextcloud-talk/src/channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
// Nextcloud Talk plugin module implements channel behavior.
22
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
33
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
4-
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
54
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
65
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
76
import {
87
buildWebhookChannelStatusSummary,
98
createComputedAccountStatusAdapter,
109
createDefaultChannelRuntimeState,
1110
} from "openclaw/plugin-sdk/status-helpers";
11+
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
1212
import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js";
1313
import { nextcloudTalkApprovalAuth } from "./approval-auth.js";
1414
import { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.js";

extensions/nextcloud-talk/src/inbound.behavior.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Nextcloud Talk tests cover inbound.behavior plugin behavior.
22
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
33
import { beforeEach, describe, expect, it, vi } from "vitest";
4-
import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
4+
import type { OutboundReplyPayload, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
55
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
66
import { handleNextcloudTalkInbound } from "./inbound.js";
77
import { setNextcloudTalkRuntime } from "./runtime.js";
@@ -101,6 +101,23 @@ function requireFirstSendMessageCall(): [unknown, unknown, unknown] {
101101
return call as [unknown, unknown, unknown];
102102
}
103103

104+
type CapturedReplyDelivery = {
105+
preparePayload: (payload: OutboundReplyPayload) => OutboundReplyPayload;
106+
deliver: (payload: OutboundReplyPayload) => Promise<void>;
107+
};
108+
109+
function requireFirstReplyDelivery(mock: ReturnType<typeof vi.fn>): CapturedReplyDelivery {
110+
const assembledRequest = requireFirstMockArg(mock, "Nextcloud Talk assembled request") as {
111+
delivery?: Partial<CapturedReplyDelivery>;
112+
};
113+
const preparePayload = assembledRequest.delivery?.preparePayload;
114+
const deliver = assembledRequest.delivery?.deliver;
115+
if (!preparePayload || !deliver) {
116+
throw new Error("expected Nextcloud Talk reply delivery hooks");
117+
}
118+
return { preparePayload, deliver };
119+
}
120+
104121
function createAccount(
105122
overrides?: Partial<ResolvedNextcloudTalkAccount>,
106123
): ResolvedNextcloudTalkAccount {
@@ -307,4 +324,98 @@ describe("nextcloud-talk inbound behavior", () => {
307324
) as { replyPipeline?: unknown };
308325
expect(assembledRequest.replyPipeline).toEqual({});
309326
});
327+
328+
it("sanitizes inbound replies before local delivery while preserving reply metadata", async () => {
329+
const coreRuntime = createPluginRuntimeMock();
330+
setNextcloudTalkRuntime(coreRuntime as unknown as PluginRuntime);
331+
createChannelPairingControllerMock.mockReturnValue({
332+
readStoreForDmPolicy: vi.fn(async () => []),
333+
issueChallenge: vi.fn(),
334+
});
335+
sendMessageNextcloudTalkMock.mockResolvedValue(undefined);
336+
337+
const config = { channels: { "nextcloud-talk": {} } } as CoreConfig;
338+
await handleNextcloudTalkInbound({
339+
message: createMessage(),
340+
account: createAccount({
341+
config: {
342+
dmPolicy: "allowlist",
343+
allowFrom: ["user-1"],
344+
groupPolicy: "allowlist",
345+
groupAllowFrom: [],
346+
},
347+
}),
348+
config,
349+
runtime: createRuntimeEnv(),
350+
});
351+
352+
const delivery = requireFirstReplyDelivery(
353+
coreRuntime.channel.inbound.dispatchReply as ReturnType<typeof vi.fn>,
354+
);
355+
const cases = [
356+
{
357+
text: "Done.\n⚠️ 🛠️ `search repos (agent)` failed",
358+
expected: "Done.",
359+
},
360+
{
361+
text: '<tool_call>{"name":"exec"}</tool_call>Meeting notes sent.',
362+
expected: "Meeting notes sent.",
363+
},
364+
{
365+
text: [
366+
"Checking now.",
367+
"<function_response>",
368+
'Searching for: "agenda"',
369+
"</function_response>",
370+
"Meeting notes sent.",
371+
].join("\n"),
372+
expected: "Checking now.\n\nMeeting notes sent.",
373+
},
374+
{
375+
text: "The agenda has 3 open action items.",
376+
expected: "The agenda has 3 open action items.",
377+
},
378+
];
379+
for (const testCase of cases) {
380+
expect(delivery.preparePayload({ text: testCase.text })).toEqual({
381+
text: testCase.expected,
382+
});
383+
}
384+
385+
const mediaOnlyPayload = { mediaUrl: "https://example.com/a.png" };
386+
expect(delivery.preparePayload(mediaOnlyPayload)).toBe(mediaOnlyPayload);
387+
388+
const preparedPayload = delivery.preparePayload({
389+
text: "Done.\n⚠️ 🛠️ `search repos (agent)` failed",
390+
mediaUrls: ["https://example.com/a.png"],
391+
replyToId: "reply-1",
392+
});
393+
await delivery.deliver(preparedPayload);
394+
await delivery.deliver(
395+
delivery.preparePayload({
396+
text: '<tool_call>{"name":"exec"}</tool_call>Meeting notes sent.',
397+
}),
398+
);
399+
400+
expect(sendMessageNextcloudTalkMock).toHaveBeenCalledTimes(2);
401+
expect(requireFirstSendMessageCall()).toEqual([
402+
"room-1",
403+
"Done.\n\nAttachment: https://example.com/a.png",
404+
{
405+
cfg: config,
406+
accountId: "default",
407+
replyTo: "reply-1",
408+
},
409+
]);
410+
expect(sendMessageNextcloudTalkMock).toHaveBeenNthCalledWith(
411+
2,
412+
"room-1",
413+
"Meeting notes sent.",
414+
{
415+
cfg: config,
416+
accountId: "default",
417+
replyTo: undefined,
418+
},
419+
);
420+
});
310421
});

extensions/nextcloud-talk/src/inbound.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
normalizeOptionalString,
99
normalizeStringEntries,
1010
} from "openclaw/plugin-sdk/string-coerce-runtime";
11+
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
1112
import {
1213
GROUP_POLICY_BLOCKED_LABEL,
1314
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -361,6 +362,13 @@ export async function handleNextcloudTalkInbound(params: {
361362
dispatchReplyWithBufferedBlockDispatcher:
362363
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
363364
delivery: {
365+
preparePayload: (payload) =>
366+
payload.text === undefined
367+
? payload
368+
: {
369+
...payload,
370+
text: sanitizeAssistantVisibleText(payload.text),
371+
},
364372
deliver: async (payload) => {
365373
await deliverNextcloudTalkReply({
366374
cfg: config,
Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,45 @@
1-
// Nextcloud Talk outbound must strip assistant internal tool-trace scaffolding,
2-
// matching the sibling channel fixes tracked under #90684 (Slack / Signal /
3-
// Matrix / Telegram / Google Chat / QQBot / IRC / SMS / Feishu / LINE).
1+
// Nextcloud Talk outbound must strip assistant internal tool-trace scaffolding
2+
// before delivery, matching the shared channel sanitizer contract.
43
import { describe, expect, it } from "vitest";
54
import { nextcloudTalkPlugin } from "./channel.js";
65

6+
function sanitizeOutboundText(text: string): string {
7+
const sanitizeText = nextcloudTalkPlugin.outbound?.sanitizeText;
8+
if (!sanitizeText) {
9+
throw new Error("Expected Nextcloud Talk outbound sanitizeText hook");
10+
}
11+
return sanitizeText({ text, payload: { text } });
12+
}
13+
714
describe("nextcloud-talk outbound sanitizeText", () => {
815
it("strips internal tool-trace banners before outbound delivery", () => {
916
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
17+
expect(sanitizeOutboundText(text)).toBe("Done.");
18+
});
1019

11-
expect(
12-
nextcloudTalkPlugin.outbound?.sanitizeText?.({ text, payload: { text } }),
13-
).toBe("Done.");
20+
it("strips XML tool-call scaffolding leaked into assistant text", () => {
21+
const text = '<tool_call>{"name":"exec"}</tool_call>Meeting notes sent.';
22+
expect(sanitizeOutboundText(text)).toBe("Meeting notes sent.");
23+
});
24+
25+
it("strips multiline tool-response scaffolding leaked into assistant text", () => {
26+
const text = [
27+
"Checking now.",
28+
"<function_response>",
29+
'Searching for: "agenda"',
30+
"</function_response>",
31+
"Meeting notes sent.",
32+
].join("\n");
33+
expect(sanitizeOutboundText(text)).toBe("Checking now.\n\nMeeting notes sent.");
1434
});
1535

1636
it("preserves ordinary assistant prose while sanitizing", () => {
17-
const text = "The pipeline has 3 open deals.";
37+
const text = "The agenda has 3 open action items.";
38+
expect(sanitizeOutboundText(text)).toBe(text);
39+
});
1840

19-
expect(
20-
nextcloudTalkPlugin.outbound?.sanitizeText?.({ text, payload: { text } }),
21-
).toBe(text);
41+
it("preserves internal trace examples inside fenced code", () => {
42+
const text = ["Example:", "```", "⚠️ 🛠️ `search repos (agent)` failed", "```"].join("\n");
43+
expect(sanitizeOutboundText(text)).toBe(text);
2244
});
2345
});

0 commit comments

Comments
 (0)