mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(auto-reply): keep group visible replies deliverable
This commit is contained in:
committed by
clawsweeper-repair
parent
eabab1f64f
commit
adbec93b8a
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -14,7 +14,9 @@ read_when:
|
||||
- Slack-class target grammar:
|
||||
- `dm:<user>`
|
||||
- `channel:<room>`
|
||||
- `group:<room>`
|
||||
- `thread:<room>/<thread>`
|
||||
- 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.
|
||||
- HTTP-backed synthetic bus for inbound message injection, outbound transcript capture, thread creation, reactions, edits, deletes, and search/read actions.
|
||||
- Host-side self-check runner that writes a Markdown report to `.artifacts/qa-e2e/`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getQaBusState, pollQaBus } from "./bus-client.js";
|
||||
import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js";
|
||||
|
||||
async function startJsonServer(
|
||||
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
|
||||
@@ -40,6 +40,19 @@ describe("qa-bus client", () => {
|
||||
await Promise.all(stops.splice(0).map((stop) => stop()));
|
||||
});
|
||||
|
||||
it("roundtrips explicit group targets", () => {
|
||||
expect(parseQaTarget("group:ops-room")).toEqual({
|
||||
chatType: "group",
|
||||
conversationId: "ops-room",
|
||||
});
|
||||
expect(
|
||||
buildQaTarget({
|
||||
chatType: "group",
|
||||
conversationId: "ops-room",
|
||||
}),
|
||||
).toBe("group:ops-room");
|
||||
});
|
||||
|
||||
it("rejects malformed JSON responses instead of throwing from the stream callback", async () => {
|
||||
const server = await startJsonServer(() => ({
|
||||
body: '{"cursor":1,"events":[',
|
||||
|
||||
@@ -118,7 +118,7 @@ export function normalizeQaTarget(raw: string): string | undefined {
|
||||
}
|
||||
|
||||
export function parseQaTarget(raw: string): {
|
||||
chatType: "direct" | "channel";
|
||||
chatType: "direct" | "channel" | "group";
|
||||
conversationId: string;
|
||||
threadId?: string;
|
||||
} {
|
||||
@@ -144,6 +144,12 @@ export function parseQaTarget(raw: string): {
|
||||
conversationId: normalized.slice("channel:".length),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("group:")) {
|
||||
return {
|
||||
chatType: "group",
|
||||
conversationId: normalized.slice("group:".length),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("dm:")) {
|
||||
return {
|
||||
chatType: "direct",
|
||||
@@ -157,14 +163,14 @@ export function parseQaTarget(raw: string): {
|
||||
}
|
||||
|
||||
export function buildQaTarget(params: {
|
||||
chatType: "direct" | "channel";
|
||||
chatType: "direct" | "channel" | "group";
|
||||
conversationId: string;
|
||||
threadId?: string | null;
|
||||
}) {
|
||||
if (params.threadId) {
|
||||
return `thread:${params.conversationId}/${params.threadId}`;
|
||||
}
|
||||
return `${params.chatType === "direct" ? "dm" : "channel"}:${params.conversationId}`;
|
||||
return `${params.chatType === "direct" ? "dm" : params.chatType}:${params.conversationId}`;
|
||||
}
|
||||
|
||||
export async function pollQaBus(params: {
|
||||
|
||||
@@ -65,7 +65,7 @@ function readQaSendTarget(params: Record<string, unknown>) {
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(dm|channel):|^thread:[^/]+\/.+/i.test(target)) {
|
||||
if (/^(dm|channel|group):|^thread:[^/]+\/.+/i.test(target)) {
|
||||
return target;
|
||||
}
|
||||
return buildQaTarget({ chatType: "channel", conversationId: target });
|
||||
|
||||
@@ -26,6 +26,14 @@ function createMockQaRuntime(params?: {
|
||||
const sessionUpdatedAt = new Map<string, number>();
|
||||
return {
|
||||
channel: {
|
||||
mentions: {
|
||||
buildMentionRegexes() {
|
||||
return [/^@openclaw\b/i];
|
||||
},
|
||||
matchesMentionPatterns(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute({
|
||||
accountId,
|
||||
@@ -142,6 +150,35 @@ describe("qa-channel plugin", () => {
|
||||
expect(route?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives group outbound session routes from explicit group targets", async () => {
|
||||
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
target: "group:qa-room",
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
sessionKey: "agent:main:qa-channel:group:group:qa-room",
|
||||
baseSessionKey: "agent:main:qa-channel:group:group:qa-room",
|
||||
chatType: "group",
|
||||
to: "group:qa-room",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes explicit group targets for session group policy lookup", () => {
|
||||
const resolved = qaChannelPlugin.messaging?.resolveSessionConversation?.({
|
||||
kind: "group",
|
||||
rawId: "group:qa-room",
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
id: "qa-room",
|
||||
baseConversationId: "qa-room",
|
||||
parentConversationCandidates: ["qa-room"],
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers thread-aware outbound session routes from currentSessionKey", async () => {
|
||||
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
@@ -197,6 +234,53 @@ describe("qa-channel plugin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"surfaces shared group traffic with the room target as From",
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
let dispatchedCtx: Record<string, unknown> | null = null;
|
||||
const harness = await startQaChannelTestHarness({
|
||||
allowFrom: ["*"],
|
||||
runtime: createMockQaRuntime({
|
||||
onDispatch: (ctx) => {
|
||||
dispatchedCtx = ctx;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.addInboundMessage({
|
||||
conversation: { id: "qa-room", kind: "group", title: "QA Room" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "@openclaw hello",
|
||||
});
|
||||
|
||||
const outbound = await harness.state.waitFor({
|
||||
kind: "message-text",
|
||||
textIncludes: "qa-echo: @openclaw hello",
|
||||
direction: "outbound",
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
expect(dispatchedCtx).toMatchObject({
|
||||
ChatType: "group",
|
||||
From: "group:qa-room",
|
||||
To: "group:qa-room",
|
||||
SessionKey: "qa-agent:group:group:qa-room",
|
||||
SenderId: "alice",
|
||||
GroupSubject: "QA Room",
|
||||
});
|
||||
expect("conversation" in outbound && outbound.conversation).toMatchObject({
|
||||
id: "qa-room",
|
||||
kind: "group",
|
||||
});
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => {
|
||||
let dispatchedCtx: Record<string, unknown> | null = null;
|
||||
const harness = await startQaChannelTestHarness({
|
||||
@@ -396,4 +480,41 @@ describe("qa-channel plugin", () => {
|
||||
await bus.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes group send targets to group qa bus conversations", async () => {
|
||||
installQaChannelTestRegistry();
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
|
||||
try {
|
||||
const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl });
|
||||
|
||||
const result = await qaChannelPlugin.actions?.handleAction?.({
|
||||
channel: "qa-channel",
|
||||
action: "send",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
target: "group:qa-room",
|
||||
message: "hello group",
|
||||
},
|
||||
});
|
||||
const payload = extractToolPayload(result);
|
||||
expect(payload).toMatchObject({ message: { text: "hello group" } });
|
||||
|
||||
const outbound = await state.waitFor({
|
||||
kind: "message-text",
|
||||
direction: "outbound",
|
||||
textIncludes: "hello group",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect("conversation" in outbound).toBe(true);
|
||||
if (!("conversation" in outbound)) {
|
||||
throw new Error("expected outbound message match");
|
||||
}
|
||||
expect(outbound.conversation).toMatchObject({ id: "qa-room", kind: "group" });
|
||||
} finally {
|
||||
await bus.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,8 +64,8 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) =>
|
||||
/^((dm|channel):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
|
||||
hint: "<dm:user|channel:room|thread:room/thread>",
|
||||
/^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
|
||||
hint: "<dm:user|channel:room|group:room|thread:room/thread>",
|
||||
},
|
||||
resolveOutboundSessionRoute: ({
|
||||
cfg,
|
||||
@@ -83,7 +83,12 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
channel: CHANNEL_ID,
|
||||
accountId,
|
||||
peer: {
|
||||
kind: parsed.chatType === "direct" ? "direct" : "channel",
|
||||
kind:
|
||||
parsed.chatType === "direct"
|
||||
? "direct"
|
||||
: parsed.chatType === "group"
|
||||
? "group"
|
||||
: "channel",
|
||||
id: buildQaTarget(parsed),
|
||||
},
|
||||
chatType: parsed.chatType,
|
||||
@@ -99,6 +104,18 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
route.chatType !== "direct" || (cfg.session?.dmScope ?? "main") !== "main",
|
||||
});
|
||||
},
|
||||
resolveSessionConversation: ({ rawId }) => {
|
||||
const parsed = parseQaTarget(rawId);
|
||||
if (parsed.chatType === "direct") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: parsed.conversationId,
|
||||
threadId: parsed.threadId,
|
||||
baseConversationId: parsed.conversationId,
|
||||
parentConversationCandidates: [parsed.conversationId],
|
||||
};
|
||||
},
|
||||
},
|
||||
status: qaChannelStatus,
|
||||
gateway: {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import {
|
||||
ToolPolicySchema,
|
||||
buildChannelConfigSchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
const QaChannelActionConfigSchema = z
|
||||
@@ -10,6 +13,14 @@ const QaChannelActionConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const QaChannelGroupConfigSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema.optional(),
|
||||
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const QaChannelAccountConfigSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -19,6 +30,9 @@ export const QaChannelAccountConfigSchema = z
|
||||
botDisplayName: z.string().optional(),
|
||||
pollTimeoutMs: z.number().int().min(100).max(30_000).optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groups: z.record(z.string(), QaChannelGroupConfigSchema).optional(),
|
||||
defaultTo: z.string().optional(),
|
||||
actions: QaChannelActionConfigSchema.optional(),
|
||||
})
|
||||
|
||||
@@ -77,7 +77,12 @@ export async function handleQaInbound(params: {
|
||||
channel: params.channelId,
|
||||
accountId: params.account.accountId,
|
||||
peer: {
|
||||
kind: inbound.conversation.kind === "direct" ? "direct" : "channel",
|
||||
kind:
|
||||
inbound.conversation.kind === "direct"
|
||||
? "direct"
|
||||
: inbound.conversation.kind === "group"
|
||||
? "group"
|
||||
: "channel",
|
||||
id: target,
|
||||
},
|
||||
});
|
||||
@@ -113,10 +118,7 @@ export async function handleQaInbound(params: {
|
||||
BodyForAgent: inbound.text,
|
||||
RawBody: inbound.text,
|
||||
CommandBody: inbound.text,
|
||||
From: buildQaTarget({
|
||||
chatType: inbound.conversation.kind,
|
||||
conversationId: inbound.senderId,
|
||||
}),
|
||||
From: target,
|
||||
To: target,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? params.account.accountId,
|
||||
@@ -127,10 +129,9 @@ export async function handleQaInbound(params: {
|
||||
inbound.conversation.title ||
|
||||
inbound.senderName ||
|
||||
inbound.conversation.id,
|
||||
GroupSubject:
|
||||
inbound.conversation.kind === "channel"
|
||||
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
||||
: undefined,
|
||||
GroupSubject: isGroup
|
||||
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
||||
: undefined,
|
||||
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
|
||||
NativeChannelId: inbound.conversation.id,
|
||||
MessageThreadId: inbound.threadId,
|
||||
|
||||
@@ -13,6 +13,16 @@ export type QaChannelAccountConfig = {
|
||||
botDisplayName?: string;
|
||||
pollTimeoutMs?: number;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
tools?: Record<string, unknown>;
|
||||
toolsBySender?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
>;
|
||||
defaultTo?: string;
|
||||
actions?: QaChannelActionConfig;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,11 @@ export function normalizeConversationFromTarget(target: string): {
|
||||
conversation: { id: trimmed.slice("channel:".length), kind: "channel" },
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("group:")) {
|
||||
return {
|
||||
conversation: { id: trimmed.slice("group:".length), kind: "group" },
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("dm:")) {
|
||||
return {
|
||||
conversation: { id: trimmed.slice("dm:".length), kind: "direct" },
|
||||
|
||||
@@ -149,6 +149,9 @@ const QA_STREAMING_PROMPT_RE = /(?:partial|quiet) streaming qa check/i;
|
||||
const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
|
||||
const QA_TOOL_PROGRESS_ERROR_PROMPT_RE = /tool progress error qa check/i;
|
||||
const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i;
|
||||
const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i;
|
||||
const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE =
|
||||
/qa group message unavailable fallback check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
|
||||
@@ -1325,6 +1328,21 @@ async function buildResponsesPayload(
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE.test(allInputText)) {
|
||||
const marker = exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-TOOL-OK";
|
||||
if (!toolOutput && hasDeclaredTool(body, "message")) {
|
||||
return buildToolCallEventsWithArgs("message", {
|
||||
action: "send",
|
||||
message: marker,
|
||||
});
|
||||
}
|
||||
return buildAssistantEvents("");
|
||||
}
|
||||
if (QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE.test(allInputText)) {
|
||||
return buildAssistantEvents(
|
||||
exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK",
|
||||
);
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
|
||||
return buildAssistantEvents(exactReplyDirective);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
export type {
|
||||
QaBusAttachment,
|
||||
QaBusConversation,
|
||||
QaBusConversationKind,
|
||||
QaBusCreateThreadInput,
|
||||
QaBusDeleteMessageInput,
|
||||
QaBusEditMessageInput,
|
||||
|
||||
@@ -704,6 +704,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
|
||||
const ownsLab = !params?.lab;
|
||||
const startLab = params?.startLab;
|
||||
writeQaSuiteProgress(progressEnabled, "lab start");
|
||||
const lab =
|
||||
params?.lab ??
|
||||
(await requireQaSuiteStartLab(startLab)({
|
||||
@@ -712,11 +713,18 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
port: 0,
|
||||
embeddedGateway: "disabled",
|
||||
}));
|
||||
writeQaSuiteProgress(progressEnabled, `lab ready: ${sanitizeQaSuiteProgressValue(lab.baseUrl)}`);
|
||||
const transport = createQaTransportAdapter({
|
||||
id: transportId,
|
||||
state: lab.state,
|
||||
});
|
||||
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
|
||||
const mock = await startQaProviderServer(providerMode);
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`provider ready: ${sanitizeQaSuiteProgressValue(mock?.baseUrl ?? "live")}`,
|
||||
);
|
||||
writeQaSuiteProgress(progressEnabled, "gateway start");
|
||||
const gateway = await startQaGatewayChild({
|
||||
repoRoot,
|
||||
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
|
||||
@@ -736,6 +744,10 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
? (cfg) => applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig
|
||||
: undefined,
|
||||
});
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`gateway ready: ${sanitizeQaSuiteProgressValue(gateway.baseUrl)}`,
|
||||
);
|
||||
lab.setControlUi({
|
||||
controlUiProxyTarget: gateway.baseUrl,
|
||||
controlUiToken: gateway.token,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Group fallback when message tool is unavailable
|
||||
|
||||
```yaml qa-scenario
|
||||
id: group-message-tool-unavailable-fallback
|
||||
title: Group fallback when message tool is unavailable
|
||||
surface: channel
|
||||
coverage:
|
||||
primary:
|
||||
- channels.group-visible-replies
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
- tools.message
|
||||
objective: Reproduce the group-visible-reply bug class where message_tool mode selected tool-only delivery even though group tool policy removed the message tool.
|
||||
gatewayConfigPatch:
|
||||
messages:
|
||||
groupChat:
|
||||
visibleReplies: message_tool
|
||||
channels:
|
||||
qa-channel:
|
||||
groups:
|
||||
qa-fallback-room:
|
||||
tools:
|
||||
allow:
|
||||
- read
|
||||
successCriteria:
|
||||
- The group policy removes the message tool for this room.
|
||||
- The mock provider returns a normal final answer with the marker.
|
||||
- OpenClaw falls back to automatic delivery and posts the marker to the same group.
|
||||
docsRefs:
|
||||
- docs/channels/groups.md
|
||||
- docs/channels/qa-channel.md
|
||||
codeRefs:
|
||||
- src/auto-reply/reply/dispatch-from-config.ts
|
||||
- extensions/qa-channel/src/inbound.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify message_tool visible replies degrade to automatic delivery when the active group policy removes message.
|
||||
config:
|
||||
conversationId: qa-fallback-room
|
||||
promptSnippet: qa group message unavailable fallback check
|
||||
prompt: "@openclaw qa group message unavailable fallback check. exact marker: `QA-GROUP-FALLBACK-OK`"
|
||||
expectedMarker: QA-GROUP-FALLBACK-OK
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: falls back to final-answer delivery when message is not available
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- call: state.addInboundMessage
|
||||
args:
|
||||
- conversation:
|
||||
id:
|
||||
expr: config.conversationId
|
||||
kind: group
|
||||
title: QA Fallback Room
|
||||
senderId: alice
|
||||
senderName: Alice
|
||||
text:
|
||||
expr: config.prompt
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: outbound
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && !candidate.threadId && candidate.text.includes(config.expectedMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 180000)
|
||||
- set: matchingOutbound
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && message.conversation.kind === 'group' && String(message.text ?? '').includes(config.expectedMarker))"
|
||||
- assert:
|
||||
expr: matchingOutbound.length === 1
|
||||
message:
|
||||
expr: "`expected exactly one fallback group reply, saw ${matchingOutbound.length}`"
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : []"
|
||||
- assert:
|
||||
expr: "!env.mock || scenarioRequests.length > 0"
|
||||
message: expected mock request evidence for fallback scenario
|
||||
- assert:
|
||||
expr: "!env.mock || scenarioRequests.every((request) => request.plannedToolName !== 'message')"
|
||||
message:
|
||||
expr: "`message tool should not be planned when group policy removes it, saw ${JSON.stringify(scenarioRequests.map((request) => request.plannedToolName ?? null))}`"
|
||||
detailsExpr: "`${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}`"
|
||||
```
|
||||
96
qa/scenarios/channels/group-visible-reply-tool.md
Normal file
96
qa/scenarios/channels/group-visible-reply-tool.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Group visible reply via message tool
|
||||
|
||||
```yaml qa-scenario
|
||||
id: group-visible-reply-tool
|
||||
title: Group visible reply via message tool
|
||||
surface: channel
|
||||
coverage:
|
||||
primary:
|
||||
- channels.group-visible-replies
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
- tools.message
|
||||
objective: Verify a group-sourced QA channel turn replies visibly through message(action=send) in the same room.
|
||||
gatewayConfigPatch:
|
||||
messages:
|
||||
groupChat:
|
||||
visibleReplies: message_tool
|
||||
successCriteria:
|
||||
- Agent receives a synthetic shared-room turn.
|
||||
- Mock provider calls the shared message tool instead of relying on final-answer delivery.
|
||||
- The visible reply lands once in the same group transcript.
|
||||
docsRefs:
|
||||
- docs/channels/groups.md
|
||||
- docs/channels/qa-channel.md
|
||||
codeRefs:
|
||||
- extensions/qa-channel/src/inbound.ts
|
||||
- extensions/qa-channel/src/outbound.ts
|
||||
- src/auto-reply/reply/dispatch-from-config.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Send a mentioned group message and verify visible output uses the message tool in the source group.
|
||||
config:
|
||||
conversationId: qa-visible-tool-room
|
||||
promptSnippet: qa group visible reply tool check
|
||||
prompt: "@openclaw qa group visible reply tool check. Use the visible room reply path. exact marker: `QA-GROUP-TOOL-OK`"
|
||||
expectedMarker: QA-GROUP-TOOL-OK
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: posts visible room output through message tool
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- call: state.addInboundMessage
|
||||
args:
|
||||
- conversation:
|
||||
id:
|
||||
expr: config.conversationId
|
||||
kind: group
|
||||
title: QA Visible Tool Room
|
||||
senderId: alice
|
||||
senderName: Alice
|
||||
text:
|
||||
expr: config.prompt
|
||||
- call: waitForCondition
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
params: []
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).find((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : true"
|
||||
- expr: liveTurnTimeoutMs(env, 180000)
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : []"
|
||||
- assert:
|
||||
expr: "!env.mock || scenarioRequests.some((request) => request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === config.expectedMarker)"
|
||||
message:
|
||||
expr: "`expected message(action=send) with marker, saw ${JSON.stringify(scenarioRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, toolOutput: request.toolOutput ?? '', tools: Array.isArray(request.body?.tools) ? request.body.tools.map((tool) => tool?.name ?? tool?.function?.name ?? tool?.type ?? null).filter(Boolean).slice(0, 25) : [] })))} `"
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: outbound
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && !candidate.threadId && candidate.text.includes(config.expectedMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 180000)
|
||||
- set: matchingOutbound
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && message.conversation.kind === 'group' && String(message.text ?? '').includes(config.expectedMarker))"
|
||||
- assert:
|
||||
expr: matchingOutbound.length === 1
|
||||
message:
|
||||
expr: "`expected exactly one visible group reply, saw ${matchingOutbound.length}`"
|
||||
detailsExpr: "`${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}`"
|
||||
```
|
||||
@@ -244,13 +244,11 @@ export function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
|
||||
const conversationKey = threadId ? baseSessionKey : raw;
|
||||
const conversation = parseRawSessionConversationRef(conversationKey);
|
||||
if (conversation) {
|
||||
const resolvedConversation = /:(?:sender|thread|topic):/iu.test(conversation.rawId)
|
||||
? resolveSessionConversation({
|
||||
channel: conversation.channel,
|
||||
kind: conversation.kind,
|
||||
rawId: conversation.rawId,
|
||||
})
|
||||
: null;
|
||||
const resolvedConversation = resolveSessionConversation({
|
||||
channel: conversation.channel,
|
||||
kind: conversation.kind,
|
||||
rawId: conversation.rawId,
|
||||
});
|
||||
return {
|
||||
channel: conversation.channel,
|
||||
groupIds: collectUniqueStrings([
|
||||
|
||||
@@ -1421,6 +1421,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
transcriptPrompt: params.transcriptCommandBody,
|
||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||
sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode,
|
||||
forceMessageTool:
|
||||
params.followupRun.run.sourceReplyDeliveryMode === "message_tool_only",
|
||||
silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode,
|
||||
toolResultFormat: (() => {
|
||||
const channel = resolveMessageChannel(
|
||||
|
||||
@@ -4427,6 +4427,42 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to automatic group/channel delivery when group tools remove the message tool", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "group policy fallback" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "channel",
|
||||
From: "discord:channel:C1",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:main:discord:channel:C1",
|
||||
}),
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
groups: {
|
||||
C1: { tools: { allow: ["read"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "group policy fallback" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
import {
|
||||
isToolAllowedByPolicies,
|
||||
resolveEffectiveToolPolicy,
|
||||
resolveGroupToolPolicy,
|
||||
resolveSubagentToolPolicyForSession,
|
||||
} from "../../agents/pi-tools.policy.js";
|
||||
import {
|
||||
isSubagentEnvelopeSession,
|
||||
resolveSubagentCapabilityStore,
|
||||
} from "../../agents/subagent-capabilities.js";
|
||||
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js";
|
||||
import {
|
||||
resolveConversationBindingRecord,
|
||||
@@ -17,6 +23,7 @@ import {
|
||||
import { normalizeChatType } from "../../channels/chat-type.js";
|
||||
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
|
||||
import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -82,6 +89,7 @@ import type {
|
||||
import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
|
||||
import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
|
||||
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
|
||||
import { resolveOriginMessageProvider } from "./origin-routing.js";
|
||||
import { resolveReplyRoutingDecision } from "./routing-policy.js";
|
||||
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
|
||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||
@@ -612,11 +620,57 @@ export async function dispatchReplyFromConfig(
|
||||
sessionKey: acpDispatchSessionKey,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
|
||||
const providerProfilePolicy = mergeAlsoAllowPolicy(
|
||||
resolveToolProfilePolicy(providerProfile),
|
||||
providerProfileAlsoAllow,
|
||||
);
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
const configuredVisibleReplies =
|
||||
chatType === "group" || chatType === "channel"
|
||||
? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies)
|
||||
: cfg.messages?.visibleReplies;
|
||||
const prefersMessageToolDelivery =
|
||||
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
|
||||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
|
||||
ctx.CommandSource !== "native" &&
|
||||
(chatType === "group" || chatType === "channel"
|
||||
? configuredVisibleReplies !== "automatic"
|
||||
: configuredVisibleReplies === "message_tool"));
|
||||
const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : [];
|
||||
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [
|
||||
...(profileAlsoAllow ?? []),
|
||||
...runtimeProfileAlsoAllow,
|
||||
]);
|
||||
const providerProfilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(providerProfile), [
|
||||
...(providerProfileAlsoAllow ?? []),
|
||||
...runtimeProfileAlsoAllow,
|
||||
]);
|
||||
const groupResolution = resolveGroupSessionKey(ctx);
|
||||
const messageProvider = resolveOriginMessageProvider({
|
||||
originatingChannel: ctx.OriginatingChannel,
|
||||
provider: ctx.Provider ?? ctx.Surface,
|
||||
});
|
||||
const groupPolicy = resolveGroupToolPolicy({
|
||||
config: cfg,
|
||||
sessionKey: acpDispatchSessionKey,
|
||||
messageProvider,
|
||||
groupId: groupResolution?.id,
|
||||
groupChannel:
|
||||
normalizeOptionalString(ctx.GroupChannel) ?? normalizeOptionalString(ctx.GroupSubject),
|
||||
groupSpace: normalizeOptionalString(ctx.GroupSpace),
|
||||
accountId: ctx.AccountId,
|
||||
senderId: normalizeOptionalString(ctx.SenderId),
|
||||
senderName: normalizeOptionalString(ctx.SenderName),
|
||||
senderUsername: normalizeOptionalString(ctx.SenderUsername),
|
||||
senderE164: normalizeOptionalString(ctx.SenderE164),
|
||||
});
|
||||
const subagentStore = resolveSubagentCapabilityStore(acpDispatchSessionKey, { cfg });
|
||||
const subagentPolicy =
|
||||
acpDispatchSessionKey &&
|
||||
isSubagentEnvelopeSession(acpDispatchSessionKey, {
|
||||
cfg,
|
||||
store: subagentStore,
|
||||
})
|
||||
? resolveSubagentToolPolicyForSession(cfg, acpDispatchSessionKey, {
|
||||
store: subagentStore,
|
||||
})
|
||||
: undefined;
|
||||
const messageToolAvailable = isToolAllowedByPolicies("message", [
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
@@ -624,6 +678,8 @@ export async function dispatchReplyFromConfig(
|
||||
agentProviderPolicy,
|
||||
globalPolicy,
|
||||
agentPolicy,
|
||||
groupPolicy,
|
||||
subagentPolicy,
|
||||
]);
|
||||
const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({
|
||||
cfg,
|
||||
|
||||
@@ -1380,6 +1380,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
forceMessageTool: true,
|
||||
}),
|
||||
);
|
||||
expect(routeReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -306,6 +306,7 @@ export function createFollowupRunner(params: {
|
||||
extraSystemPrompt: run.extraSystemPrompt,
|
||||
silentReplyPromptMode: run.silentReplyPromptMode,
|
||||
sourceReplyDeliveryMode: run.sourceReplyDeliveryMode,
|
||||
forceMessageTool: run.sourceReplyDeliveryMode === "message_tool_only",
|
||||
ownerNumbers: run.ownerNumbers,
|
||||
enforceFinalTag: run.enforceFinalTag,
|
||||
allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent,
|
||||
|
||||
@@ -9786,6 +9786,92 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
],
|
||||
},
|
||||
},
|
||||
groupPolicy: {
|
||||
type: "string",
|
||||
enum: ["open", "allowlist", "disabled"],
|
||||
},
|
||||
groupAllowFrom: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requireMention: {
|
||||
type: "boolean",
|
||||
},
|
||||
tools: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
alsoAllow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
deny: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
toolsBySender: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
alsoAllow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
deny: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
defaultTo: {
|
||||
type: "string",
|
||||
},
|
||||
@@ -9849,6 +9935,92 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
],
|
||||
},
|
||||
},
|
||||
groupPolicy: {
|
||||
type: "string",
|
||||
enum: ["open", "allowlist", "disabled"],
|
||||
},
|
||||
groupAllowFrom: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requireMention: {
|
||||
type: "boolean",
|
||||
},
|
||||
tools: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
alsoAllow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
deny: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
toolsBySender: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
alsoAllow: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
deny: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
defaultTo: {
|
||||
type: "string",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type QaBusConversationKind = "direct" | "channel";
|
||||
export type QaBusConversationKind = "direct" | "channel" | "group";
|
||||
|
||||
export type QaBusConversation = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user