Skip to content

Commit 1c300ce

Browse files
authored
fix(auto-reply): keep group visible replies deliverable (#75382)
Summary: - The PR updates auto-reply message-tool availability and fallback policy, qa-channel group target support, qa-lab scenario coverage, generated config metadata, docs, and the changelog for group visible replies. ClawSweeper fixups: - No separate fixup commits were needed after automerge opt-in. Validation: - ClawSweeper review passed for head adbec93. - Required merge gates passed before the squash merge. Prepared head SHA: adbec93 Review: #75382 (comment) Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 76930da commit 1c300ce

24 files changed

Lines changed: 712 additions & 31 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- CLI/Voice Call: scope `voicecall` command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc.
1818
- Doctor/plugins: warn when restrictive `plugins.allow` is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty.
1919
- Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf.
20+
- Auto-reply/group chats: keep the `message` tool available for message-tool-only visible replies and apply group-scoped tool policy before deciding fallback delivery, so Discord/Slack-style rooms reply visibly in the correct channel after upgrades. Fixes #74842; refs #75207. Thanks @davelutztx and @aa-on-ai.
2021
- Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07.
2122
- Telegram/agents: keep typing indicators and optional generation tools off the reply critical path, so fresh Telegram replies no longer stall while provider catalogs and media models load. (#75360) Thanks @obviyus.
2223
- Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337.

docs/channels/qa-channel.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ read_when:
1414
- Slack-class target grammar:
1515
- `dm:<user>`
1616
- `channel:<room>`
17+
- `group:<room>`
1718
- `thread:<room>/<thread>`
19+
- Shared `channel:` and `group:` conversations are surfaced to agents as group/channel room turns, so they exercise the same visible-reply and message-tool routing policy used by Discord, Slack, Telegram, and similar transports.
1820
- HTTP-backed synthetic bus for inbound message injection, outbound transcript capture, thread creation, reactions, edits, deletes, and search/read actions.
1921
- Host-side self-check runner that writes a Markdown report to `.artifacts/qa-e2e/`.
2022

extensions/qa-channel/src/bus-client.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createServer } from "node:http";
22
import { afterEach, describe, expect, it } from "vitest";
3-
import { getQaBusState, pollQaBus } from "./bus-client.js";
3+
import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js";
44

55
async function startJsonServer(
66
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
@@ -40,6 +40,19 @@ describe("qa-bus client", () => {
4040
await Promise.all(stops.splice(0).map((stop) => stop()));
4141
});
4242

43+
it("roundtrips explicit group targets", () => {
44+
expect(parseQaTarget("group:ops-room")).toEqual({
45+
chatType: "group",
46+
conversationId: "ops-room",
47+
});
48+
expect(
49+
buildQaTarget({
50+
chatType: "group",
51+
conversationId: "ops-room",
52+
}),
53+
).toBe("group:ops-room");
54+
});
55+
4356
it("rejects malformed JSON responses instead of throwing from the stream callback", async () => {
4457
const server = await startJsonServer(() => ({
4558
body: '{"cursor":1,"events":[',

extensions/qa-channel/src/bus-client.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function normalizeQaTarget(raw: string): string | undefined {
118118
}
119119

120120
export function parseQaTarget(raw: string): {
121-
chatType: "direct" | "channel";
121+
chatType: "direct" | "channel" | "group";
122122
conversationId: string;
123123
threadId?: string;
124124
} {
@@ -144,6 +144,12 @@ export function parseQaTarget(raw: string): {
144144
conversationId: normalized.slice("channel:".length),
145145
};
146146
}
147+
if (normalized.startsWith("group:")) {
148+
return {
149+
chatType: "group",
150+
conversationId: normalized.slice("group:".length),
151+
};
152+
}
147153
if (normalized.startsWith("dm:")) {
148154
return {
149155
chatType: "direct",
@@ -157,14 +163,14 @@ export function parseQaTarget(raw: string): {
157163
}
158164

159165
export function buildQaTarget(params: {
160-
chatType: "direct" | "channel";
166+
chatType: "direct" | "channel" | "group";
161167
conversationId: string;
162168
threadId?: string | null;
163169
}) {
164170
if (params.threadId) {
165171
return `thread:${params.conversationId}/${params.threadId}`;
166172
}
167-
return `${params.chatType === "direct" ? "dm" : "channel"}:${params.conversationId}`;
173+
return `${params.chatType === "direct" ? "dm" : params.chatType}:${params.conversationId}`;
168174
}
169175

170176
export async function pollQaBus(params: {

extensions/qa-channel/src/channel-actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function readQaSendTarget(params: Record<string, unknown>) {
6565
if (!target) {
6666
return undefined;
6767
}
68-
if (/^(dm|channel):|^thread:[^/]+\/.+/i.test(target)) {
68+
if (/^(dm|channel|group):|^thread:[^/]+\/.+/i.test(target)) {
6969
return target;
7070
}
7171
return buildQaTarget({ chatType: "channel", conversationId: target });

extensions/qa-channel/src/channel.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ function createMockQaRuntime(params?: {
2626
const sessionUpdatedAt = new Map<string, number>();
2727
return {
2828
channel: {
29+
mentions: {
30+
buildMentionRegexes() {
31+
return [/^@openclaw\b/i];
32+
},
33+
matchesMentionPatterns(text: string, patterns: RegExp[]) {
34+
return patterns.some((pattern) => pattern.test(text));
35+
},
36+
},
2937
routing: {
3038
resolveAgentRoute({
3139
accountId,
@@ -142,6 +150,35 @@ describe("qa-channel plugin", () => {
142150
expect(route?.threadId).toBeUndefined();
143151
});
144152

153+
it("derives group outbound session routes from explicit group targets", async () => {
154+
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
155+
cfg: {},
156+
agentId: "main",
157+
accountId: "default",
158+
target: "group:qa-room",
159+
});
160+
161+
expect(route).toMatchObject({
162+
sessionKey: "agent:main:qa-channel:group:group:qa-room",
163+
baseSessionKey: "agent:main:qa-channel:group:group:qa-room",
164+
chatType: "group",
165+
to: "group:qa-room",
166+
});
167+
});
168+
169+
it("normalizes explicit group targets for session group policy lookup", () => {
170+
const resolved = qaChannelPlugin.messaging?.resolveSessionConversation?.({
171+
kind: "group",
172+
rawId: "group:qa-room",
173+
});
174+
175+
expect(resolved).toMatchObject({
176+
id: "qa-room",
177+
baseConversationId: "qa-room",
178+
parentConversationCandidates: ["qa-room"],
179+
});
180+
});
181+
145182
it("recovers thread-aware outbound session routes from currentSessionKey", async () => {
146183
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
147184
cfg: {},
@@ -197,6 +234,53 @@ describe("qa-channel plugin", () => {
197234
}
198235
});
199236

237+
it(
238+
"surfaces shared group traffic with the room target as From",
239+
{ timeout: 20_000 },
240+
async () => {
241+
let dispatchedCtx: Record<string, unknown> | null = null;
242+
const harness = await startQaChannelTestHarness({
243+
allowFrom: ["*"],
244+
runtime: createMockQaRuntime({
245+
onDispatch: (ctx) => {
246+
dispatchedCtx = ctx;
247+
},
248+
}),
249+
});
250+
251+
try {
252+
harness.state.addInboundMessage({
253+
conversation: { id: "qa-room", kind: "group", title: "QA Room" },
254+
senderId: "alice",
255+
senderName: "Alice",
256+
text: "@openclaw hello",
257+
});
258+
259+
const outbound = await harness.state.waitFor({
260+
kind: "message-text",
261+
textIncludes: "qa-echo: @openclaw hello",
262+
direction: "outbound",
263+
timeoutMs: 15_000,
264+
});
265+
266+
expect(dispatchedCtx).toMatchObject({
267+
ChatType: "group",
268+
From: "group:qa-room",
269+
To: "group:qa-room",
270+
SessionKey: "qa-agent:group:group:qa-room",
271+
SenderId: "alice",
272+
GroupSubject: "QA Room",
273+
});
274+
expect("conversation" in outbound && outbound.conversation).toMatchObject({
275+
id: "qa-room",
276+
kind: "group",
277+
});
278+
} finally {
279+
await harness.stop();
280+
}
281+
},
282+
);
283+
200284
it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => {
201285
let dispatchedCtx: Record<string, unknown> | null = null;
202286
const harness = await startQaChannelTestHarness({
@@ -396,4 +480,41 @@ describe("qa-channel plugin", () => {
396480
await bus.stop();
397481
}
398482
});
483+
484+
it("routes group send targets to group qa bus conversations", async () => {
485+
installQaChannelTestRegistry();
486+
const state = createQaBusState();
487+
const bus = await startQaBusServer({ state });
488+
489+
try {
490+
const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl });
491+
492+
const result = await qaChannelPlugin.actions?.handleAction?.({
493+
channel: "qa-channel",
494+
action: "send",
495+
cfg,
496+
accountId: "default",
497+
params: {
498+
target: "group:qa-room",
499+
message: "hello group",
500+
},
501+
});
502+
const payload = extractToolPayload(result);
503+
expect(payload).toMatchObject({ message: { text: "hello group" } });
504+
505+
const outbound = await state.waitFor({
506+
kind: "message-text",
507+
direction: "outbound",
508+
textIncludes: "hello group",
509+
timeoutMs: 5_000,
510+
});
511+
expect("conversation" in outbound).toBe(true);
512+
if (!("conversation" in outbound)) {
513+
throw new Error("expected outbound message match");
514+
}
515+
expect(outbound.conversation).toMatchObject({ id: "qa-room", kind: "group" });
516+
} finally {
517+
await bus.stop();
518+
}
519+
});
399520
});

extensions/qa-channel/src/channel.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
6464
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
6565
targetResolver: {
6666
looksLikeId: (raw) =>
67-
/^((dm|channel):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
68-
hint: "<dm:user|channel:room|thread:room/thread>",
67+
/^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
68+
hint: "<dm:user|channel:room|group:room|thread:room/thread>",
6969
},
7070
resolveOutboundSessionRoute: ({
7171
cfg,
@@ -83,7 +83,12 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
8383
channel: CHANNEL_ID,
8484
accountId,
8585
peer: {
86-
kind: parsed.chatType === "direct" ? "direct" : "channel",
86+
kind:
87+
parsed.chatType === "direct"
88+
? "direct"
89+
: parsed.chatType === "group"
90+
? "group"
91+
: "channel",
8792
id: buildQaTarget(parsed),
8893
},
8994
chatType: parsed.chatType,
@@ -99,6 +104,18 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
99104
route.chatType !== "direct" || (cfg.session?.dmScope ?? "main") !== "main",
100105
});
101106
},
107+
resolveSessionConversation: ({ rawId }) => {
108+
const parsed = parseQaTarget(rawId);
109+
if (parsed.chatType === "direct") {
110+
return null;
111+
}
112+
return {
113+
id: parsed.conversationId,
114+
threadId: parsed.threadId,
115+
baseConversationId: parsed.conversationId,
116+
parentConversationCandidates: [parsed.conversationId],
117+
};
118+
},
102119
},
103120
status: qaChannelStatus,
104121
gateway: {

extensions/qa-channel/src/config-schema.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
1+
import {
2+
ToolPolicySchema,
3+
buildChannelConfigSchema,
4+
} from "openclaw/plugin-sdk/channel-config-schema";
25
import { z } from "openclaw/plugin-sdk/zod";
36

47
const QaChannelActionConfigSchema = z
@@ -10,6 +13,14 @@ const QaChannelActionConfigSchema = z
1013
})
1114
.strict();
1215

16+
const QaChannelGroupConfigSchema = z
17+
.object({
18+
requireMention: z.boolean().optional(),
19+
tools: ToolPolicySchema.optional(),
20+
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
21+
})
22+
.strict();
23+
1324
export const QaChannelAccountConfigSchema = z
1425
.object({
1526
name: z.string().optional(),
@@ -19,6 +30,9 @@ export const QaChannelAccountConfigSchema = z
1930
botDisplayName: z.string().optional(),
2031
pollTimeoutMs: z.number().int().min(100).max(30_000).optional(),
2132
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
33+
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
34+
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
35+
groups: z.record(z.string(), QaChannelGroupConfigSchema).optional(),
2236
defaultTo: z.string().optional(),
2337
actions: QaChannelActionConfigSchema.optional(),
2438
})

extensions/qa-channel/src/inbound.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ export async function handleQaInbound(params: {
7777
channel: params.channelId,
7878
accountId: params.account.accountId,
7979
peer: {
80-
kind: inbound.conversation.kind === "direct" ? "direct" : "channel",
80+
kind:
81+
inbound.conversation.kind === "direct"
82+
? "direct"
83+
: inbound.conversation.kind === "group"
84+
? "group"
85+
: "channel",
8186
id: target,
8287
},
8388
});
@@ -113,10 +118,7 @@ export async function handleQaInbound(params: {
113118
BodyForAgent: inbound.text,
114119
RawBody: inbound.text,
115120
CommandBody: inbound.text,
116-
From: buildQaTarget({
117-
chatType: inbound.conversation.kind,
118-
conversationId: inbound.senderId,
119-
}),
121+
From: target,
120122
To: target,
121123
SessionKey: route.sessionKey,
122124
AccountId: route.accountId ?? params.account.accountId,
@@ -127,10 +129,9 @@ export async function handleQaInbound(params: {
127129
inbound.conversation.title ||
128130
inbound.senderName ||
129131
inbound.conversation.id,
130-
GroupSubject:
131-
inbound.conversation.kind === "channel"
132-
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
133-
: undefined,
132+
GroupSubject: isGroup
133+
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
134+
: undefined,
134135
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
135136
NativeChannelId: inbound.conversation.id,
136137
MessageThreadId: inbound.threadId,

extensions/qa-channel/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ export type QaChannelAccountConfig = {
1313
botDisplayName?: string;
1414
pollTimeoutMs?: number;
1515
allowFrom?: Array<string | number>;
16+
groupPolicy?: "open" | "allowlist" | "disabled";
17+
groupAllowFrom?: Array<string | number>;
18+
groups?: Record<
19+
string,
20+
{
21+
requireMention?: boolean;
22+
tools?: Record<string, unknown>;
23+
toolsBySender?: Record<string, Record<string, unknown>>;
24+
}
25+
>;
1626
defaultTo?: string;
1727
actions?: QaChannelActionConfig;
1828
};

0 commit comments

Comments
 (0)