Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
// Qa Channel plugin module implements accounts behavior.
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig, QaChannelAccountConfig, ResolvedQaChannelAccount } from "./types.js";
const DEFAULT_POLL_TIMEOUT_MS = 1_000;
const {
listAccountIds: listQaChannelAccountIds,
resolveDefaultAccountId: resolveDefaultQaChannelAccountId,
} = createAccountListHelpers("qa-channel", {
normalizeAccountId,
implicitDefaultAccount: {
channelKeys: ["baseUrl"],
},
});
export { listQaChannelAccountIds, resolveDefaultQaChannelAccountId };
function resolveMergedQaAccountConfig(cfg: CoreConfig, accountId: string): QaChannelAccountConfig {
return resolveMergedAccountConfig<QaChannelAccountConfig>({
channelConfig: cfg.channels?.["qa-channel"] as QaChannelAccountConfig | undefined,
accounts: cfg.channels?.["qa-channel"]?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
export function resolveQaChannelAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedQaChannelAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = resolveMergedQaAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.["qa-channel"]?.enabled !== false;
const enabled = baseEnabled && merged.enabled !== false;
const baseUrl = merged.baseUrl?.trim() ?? "";
const botUserId = merged.botUserId?.trim() || "openclaw";
const botDisplayName = merged.botDisplayName?.trim() || "OpenClaw QA";
return {
accountId,
enabled,
configured: Boolean(baseUrl),
name: normalizeOptionalString(merged.name),
baseUrl,
botUserId,
botDisplayName,
pollTimeoutMs: merged.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
config: {
...merged,
allowFrom: merged.allowFrom ?? ["*"],
},
};
}
export function listEnabledQaChannelAccounts(cfg: CoreConfig): ResolvedQaChannelAccount[] {
return listQaChannelAccountIds(cfg)
.map((accountId) => resolveQaChannelAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export { DEFAULT_ACCOUNT_ID };
export type { ResolvedQaChannelAccount } from "./types.js";

View File

@@ -0,0 +1,154 @@
// Qa Channel tests cover bus client plugin behavior.
import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js";
async function startJsonServer(
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
) {
const server = createServer((req, res) => {
const response = handler({ url: req.url });
res.writeHead(response.statusCode ?? 200, {
"content-type": "application/json; charset=utf-8",
});
res.end(response.body);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server failed to bind");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
async stop() {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
describe("qa-bus client", () => {
const stops: Array<() => Promise<void>> = [];
afterEach(async () => {
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":[',
}));
stops.push(server["stop"]);
await expect(
pollQaBus({
baseUrl: server.baseUrl,
accountId: "acct-a",
cursor: 0,
timeoutMs: 0,
}),
).rejects.toThrow(SyntaxError);
});
it("rejects immediately when a poll request is aborted", async () => {
const server = createServer((_req, _res) => {
// Keep the request open so the client abort path owns the outcome.
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server failed to bind");
}
stops.push(async () => {
server.closeAllConnections?.();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
const abort = new AbortController();
const request = pollQaBus({
baseUrl: `http://127.0.0.1:${address.port}`,
accountId: "acct-a",
cursor: 0,
timeoutMs: 30_000,
signal: abort.signal,
});
abort.abort();
try {
await withTimeout(request, 500, "poll abort did not settle");
throw new Error("expected poll abort to reject");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).name).toBe("AbortError");
}
});
it("preserves baseUrl path prefixes when composing bus URLs", async () => {
const server = await startJsonServer((req) => ({
statusCode: req.url === "/qa-bus/v1/state" ? 200 : 404,
body:
req.url === "/qa-bus/v1/state"
? JSON.stringify({
cursor: 1,
conversations: [],
threads: [],
messages: [],
events: [],
})
: JSON.stringify({ error: `unexpected path: ${req.url}` }),
}));
stops.push(server["stop"]);
await expect(getQaBusState(`${server.baseUrl}/qa-bus`)).resolves.toEqual({
cursor: 1,
conversations: [],
threads: [],
messages: [],
events: [],
});
});
});

View File

@@ -0,0 +1,312 @@
// Qa Channel plugin module implements bus client behavior.
import http from "node:http";
import https from "node:https";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type {
QaBusInboundMessageInput,
QaBusMessage,
QaBusPollResult,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
QaBusToolCall,
} from "./protocol.js";
export type {
QaBusAttachment,
QaBusConversation,
QaBusConversationKind,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusPollResult,
QaBusReactToMessageInput,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
QaBusToolCall,
QaBusWaitForInput,
} from "./protocol.js";
type JsonResult<T> = Promise<T>;
function buildQaBusUrl(baseUrl: string, path: string): URL {
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return new URL(path.replace(/^\/+/, ""), normalizedBaseUrl);
}
async function postJson<T>(
baseUrl: string,
path: string,
body: unknown,
signal?: AbortSignal,
): JsonResult<T> {
const url = buildQaBusUrl(baseUrl, path);
const payload = JSON.stringify(body);
const client = url.protocol === "https:" ? https : http;
return await new Promise<T>((resolve, reject) => {
const abortError = () =>
Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
if (signal?.aborted) {
reject(abortError());
return;
}
const request = client.request(
url,
{
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
connection: "close",
},
},
(response) => {
const chunks: Buffer[] = [];
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let parsed: T | { error?: string };
try {
parsed = text ? (JSON.parse(text) as T | { error?: string }) : ({} as T);
} catch (error) {
reject(toLintErrorObject(error, "Non-Error rejection"));
return;
}
if ((response.statusCode ?? 500) < 200 || (response.statusCode ?? 500) >= 300) {
const error =
typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : undefined;
reject(new Error(error || `qa-bus request failed: ${response.statusCode ?? 500}`));
return;
}
resolve(parsed as T);
});
response.on("error", reject);
},
);
const onAbort = () => {
const error = abortError();
request.destroy(error);
reject(error);
};
signal?.addEventListener("abort", onAbort, { once: true });
request.on("error", (error) => {
signal?.removeEventListener("abort", onAbort);
reject(error);
});
request.on("close", () => {
signal?.removeEventListener("abort", onAbort);
});
request.end(payload);
});
}
export function normalizeQaTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
return trimmed;
}
export function parseQaTarget(raw: string): {
chatType: "direct" | "channel" | "group";
conversationId: string;
threadId?: string;
} {
const normalized = normalizeQaTarget(raw);
if (!normalized) {
throw new Error("qa-channel target is required");
}
if (normalized.startsWith("thread:")) {
const rest = normalized.slice("thread:".length);
const slashIndex = rest.indexOf("/");
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
throw new Error(`invalid qa-channel thread target: ${normalized}`);
}
return {
chatType: "channel",
conversationId: rest.slice(0, slashIndex),
threadId: rest.slice(slashIndex + 1),
};
}
if (normalized.startsWith("channel:")) {
return {
chatType: "channel",
conversationId: normalized.slice("channel:".length),
};
}
if (normalized.startsWith("group:")) {
return {
chatType: "group",
conversationId: normalized.slice("group:".length),
};
}
if (normalized.startsWith("dm:")) {
return {
chatType: "direct",
conversationId: normalized.slice("dm:".length),
};
}
return {
chatType: "direct",
conversationId: normalized,
};
}
export function buildQaTarget(params: {
chatType: "direct" | "channel" | "group";
conversationId: string;
threadId?: string | null;
}) {
if (params.threadId) {
return `thread:${params.conversationId}/${params.threadId}`;
}
return `${params.chatType === "direct" ? "dm" : params.chatType}:${params.conversationId}`;
}
export async function pollQaBus(params: {
baseUrl: string;
accountId: string;
cursor: number;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<QaBusPollResult> {
return await postJson<QaBusPollResult>(
params.baseUrl,
"/v1/poll",
{
accountId: params.accountId,
cursor: params.cursor,
timeoutMs: params.timeoutMs,
},
params.signal,
);
}
export async function sendQaBusMessage(params: {
baseUrl: string;
accountId: string;
to: string;
text: string;
senderId?: string;
senderName?: string;
threadId?: string;
replyToId?: string;
attachments?: import("./protocol.js").QaBusAttachment[];
toolCalls?: QaBusToolCall[];
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/outbound/message", params);
}
export async function createQaBusThread(params: {
baseUrl: string;
accountId: string;
conversationId: string;
title: string;
createdBy?: string;
}) {
return await postJson<{ thread: QaBusThread }>(
params.baseUrl,
"/v1/actions/thread-create",
params,
);
}
export async function reactToQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
emoji: string;
senderId?: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/react", params);
}
export async function editQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
text: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/edit", params);
}
export async function deleteQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/delete", params);
}
export async function readQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/read", params);
}
export async function searchQaBusMessages(params: {
baseUrl: string;
input: QaBusSearchMessagesInput;
}) {
return await postJson<{ messages: QaBusMessage[] }>(
params.baseUrl,
"/v1/actions/search",
params.input,
);
}
export async function injectQaBusInboundMessage(params: {
baseUrl: string;
input: QaBusInboundMessageInput;
}) {
return await postJson<{ message: QaBusMessage }>(
params.baseUrl,
"/v1/inbound/message",
params.input,
);
}
export async function getQaBusState(baseUrl: string): Promise<QaBusStateSnapshot> {
const { response, release } = await fetchWithSsrFGuard({
url: buildQaBusUrl(baseUrl, "/v1/state").toString(),
policy: { allowPrivateNetwork: true },
auditContext: "qa-channel.bus-state",
});
try {
if (!response.ok) {
throw new Error(`qa-bus request failed: ${response.status}`);
}
return (await response.json()) as QaBusStateSnapshot;
} finally {
await release();
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,251 @@
// Qa Channel plugin module implements channel actions behavior.
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { Type } from "typebox";
import { resolveQaChannelAccount } from "./accounts.js";
import {
buildQaTarget,
createQaBusThread,
deleteQaBusMessage,
editQaBusMessage,
parseQaTarget,
reactToQaBusMessage,
readQaBusMessage,
searchQaBusMessages,
sendQaBusMessage,
} from "./bus-client.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
function listQaChannelActions(
cfg: CoreConfig,
accountId?: string | null,
): ChannelMessageActionName[] {
const account = resolveQaChannelAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
return [];
}
const actions = new Set<ChannelMessageActionName>(["send"]);
if (account.config.actions?.messages !== false) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (account.config.actions?.reactions !== false) {
actions.add("react");
actions.add("reactions");
}
if (account.config.actions?.threads !== false) {
actions.add("thread-create");
actions.add("thread-reply");
}
if (account.config.actions?.search !== false) {
actions.add("search");
}
return Array.from(actions);
}
function readQaSendText(params: Record<string, unknown>) {
return (
readStringParam(params, "message", { allowEmpty: true }) ??
readStringParam(params, "text", { allowEmpty: true }) ??
readStringParam(params, "content", { allowEmpty: true })
);
}
function readQaSendTarget(params: Record<string, unknown>) {
const explicitTo = readStringParam(params, "to");
if (explicitTo) {
return explicitTo;
}
const channelId = readStringParam(params, "channelId");
if (channelId) {
return buildQaTarget({ chatType: "channel", conversationId: channelId });
}
const target = readStringParam(params, "target");
if (!target) {
return undefined;
}
if (/^(dm|channel|group):|^thread:[^/]+\/.+/i.test(target)) {
return target;
}
return buildQaTarget({ chatType: "channel", conversationId: target });
}
export const qaChannelMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (context) => ({
actions: listQaChannelActions(context.cfg as CoreConfig, context.accountId),
capabilities: [],
schema: {
properties: {
channelId: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
title: Type.Optional(Type.String()),
query: Type.Optional(Type.String()),
},
},
}),
extractToolSend: ({ args }: { args: Record<string, unknown> }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "send") {
const to = readQaSendTarget(args);
const threadId = readStringParam(args, "threadId");
return to ? { to, threadId } : null;
}
if (action === "sendMessage") {
return extractToolSend(args, "sendMessage") ?? null;
}
if (action === "threadReply") {
const channelId = typeof args.channelId === "string" ? args.channelId.trim() : "";
const threadId = typeof args.threadId === "string" ? args.threadId.trim() : "";
return channelId && threadId ? { to: `thread:${channelId}/${threadId}` } : null;
}
return null;
},
handleAction: async (context) => {
const { action, cfg, accountId, params } = context;
const account = resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId });
const baseUrl = account.baseUrl;
switch (action) {
case "send": {
const to = readQaSendTarget(params);
const text = readQaSendText(params);
if (!to || text === undefined) {
throw new Error("qa-channel send requires to/target and message/text");
}
const parsed = parseQaTarget(to);
const threadId = readStringParam(params, "threadId") ?? parsed.threadId;
const { message } = await sendQaBusMessage({
baseUrl,
accountId: account.accountId,
to: buildQaTarget({
chatType: parsed.chatType,
conversationId: parsed.conversationId,
threadId,
}),
text,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId,
replyToId: readStringParam(params, "replyTo") ?? readStringParam(params, "replyToId"),
});
return jsonResult({ message });
}
case "thread-create": {
const channelId =
readStringParam(params, "channelId") ??
(() => {
const to = readStringParam(params, "to");
return to ? parseQaTarget(to).conversationId : undefined;
})();
const title = readStringParam(params, "title") ?? "QA thread";
if (!channelId) {
throw new Error("qa-channel thread-create requires channelId");
}
const { thread } = await createQaBusThread({
baseUrl,
accountId: account.accountId,
conversationId: channelId,
title,
createdBy: account.botUserId,
});
return jsonResult({
thread,
target: `thread:${channelId}/${thread.id}`,
});
}
case "thread-reply": {
const channelId = readStringParam(params, "channelId");
const threadId = readStringParam(params, "threadId");
const text = readStringParam(params, "text");
if (!channelId || !threadId || !text) {
throw new Error("qa-channel thread-reply requires channelId, threadId, and text");
}
const { message } = await sendQaBusMessage({
baseUrl,
accountId: account.accountId,
to: `thread:${channelId}/${threadId}`,
text,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId,
});
return jsonResult({ message });
}
case "react": {
const messageId = readStringParam(params, "messageId");
const emoji = readStringParam(params, "emoji");
if (!messageId || !emoji) {
throw new Error("qa-channel react requires messageId and emoji");
}
const { message } = await reactToQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
emoji,
senderId: account.botUserId,
});
return jsonResult({ message });
}
case "reactions":
case "read": {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error(`qa-channel ${action} requires messageId`);
}
const { message } = await readQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
});
return jsonResult({ message });
}
case "edit": {
const messageId = readStringParam(params, "messageId");
const text = readStringParam(params, "text");
if (!messageId || !text) {
throw new Error("qa-channel edit requires messageId and text");
}
const { message } = await editQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
text,
});
return jsonResult({ message });
}
case "delete": {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error("qa-channel delete requires messageId");
}
const { message } = await deleteQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
});
return jsonResult({ message });
}
case "search": {
const query = readStringParam(params, "query");
const channelId = readStringParam(params, "channelId");
const threadId = readStringParam(params, "threadId");
const { messages } = await searchQaBusMessages({
baseUrl,
input: {
accountId: account.accountId,
query,
conversationId: channelId,
threadId,
},
});
return jsonResult({ messages });
}
default:
throw new Error(`qa-channel action not implemented: ${action}`);
}
},
};

View File

@@ -0,0 +1,62 @@
// Qa Channel plugin module implements channel base behavior.
import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
import {
listQaChannelAccountIds,
resolveDefaultQaChannelAccountId,
resolveQaChannelAccount,
type ResolvedQaChannelAccount,
} from "./accounts.js";
import { qaChannelPluginConfigSchema } from "./config-schema.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { applyQaSetup } from "./setup.js";
import type { CoreConfig } from "./types.js";
export const QA_CHANNEL_ID = "qa-channel" as const;
export const qaChannelSetupMeta = { ...getChatChannelMeta(QA_CHANNEL_ID) };
export const qaChannelRuntimeMeta = {
...qaChannelSetupMeta,
id: QA_CHANNEL_ID,
label: "QA Channel",
selectionLabel: "QA Channel",
docsPath: "/channels/qa-channel",
blurb: "Synthetic QA channel for OpenClaw QA runs.",
};
type QaChannelPluginBase = Pick<
ChannelPlugin<ResolvedQaChannelAccount>,
"id" | "meta" | "capabilities" | "reload" | "configSchema" | "setup" | "config"
>;
export function createQaChannelPluginBase(
meta: ChannelPlugin<ResolvedQaChannelAccount>["meta"] = qaChannelSetupMeta,
): QaChannelPluginBase {
return {
id: QA_CHANNEL_ID,
meta,
capabilities: {
chatTypes: ["direct", "group"],
},
reload: { configPrefixes: ["channels.qa-channel"] },
configSchema: qaChannelPluginConfigSchema,
setup: {
applyAccountConfig: ({ cfg, accountId, input }) =>
applyQaSetup({
cfg,
accountId,
input: input as Record<string, unknown>,
}),
},
config: {
listAccountIds: (cfg) => listQaChannelAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultQaChannelAccountId(cfg as CoreConfig),
isConfigured: (account) => account.configured,
resolveAllowFrom: ({ cfg, accountId }) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
resolveDefaultTo: ({ cfg, accountId }) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo,
},
};
}

View File

@@ -0,0 +1,7 @@
// Qa Channel plugin module implements channel.setup behavior.
import type { ResolvedQaChannelAccount } from "./accounts.js";
import { createQaChannelPluginBase } from "./channel-base.js";
import type { ChannelPlugin } from "./runtime-api.js";
export const qaChannelSetupPlugin: ChannelPlugin<ResolvedQaChannelAccount> =
createQaChannelPluginBase();

View File

@@ -0,0 +1,712 @@
// Qa Channel tests cover channel plugin behavior.
import path from "node:path";
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
import {
createPluginRuntimeMock,
createStartAccountContext,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import {
createTestRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { extractToolPayload } from "openclaw/plugin-sdk/tool-payload";
import { afterEach, describe, expect, it } from "vitest";
import { createQaBusState, startQaBusServer } from "../../qa-lab/bus-api.js";
import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
afterEach(() => {
resetPluginRuntimeStateForTest();
});
describe("QA channel account resolution", () => {
it("preserves top-level default account when named accounts are configured", () => {
const cfg = {
channels: {
"qa-channel": {
baseUrl: "http://127.0.0.1:8787",
accounts: {
work: { enabled: false },
},
},
},
};
expect(listQaChannelAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultQaChannelAccountId(cfg)).toBe("default");
});
});
function installQaChannelTestRegistry() {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "qa-channel", plugin: qaChannelPlugin, source: "test" }]),
);
}
function expectDispatchedContext(ctx: Record<string, unknown> | null): Record<string, unknown> {
if (ctx === null) {
throw new Error("Expected dispatched context");
}
return ctx;
}
function createMockQaRuntime(params?: {
onDispatch?: (ctx: Record<string, unknown>) => void;
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
}): PluginRuntime {
const sessionUpdatedAt = new Map<string, number>();
return createPluginRuntimeMock({
channel: {
mentions: {
buildMentionRegexes() {
return [/^@openclaw\b/i];
},
matchesMentionPatterns(text: string, patterns: RegExp[]) {
return patterns.some((pattern) => pattern.test(text));
},
},
routing: {
resolveAgentRoute({
accountId,
peer,
}: {
accountId?: string | null;
peer?: { kind?: string; id?: string } | null;
}) {
return {
agentId: "qa-agent",
channel: "qa-channel",
accountId: accountId ?? "default",
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
mainSessionKey: "qa-agent:main",
lastRoutePolicy: "session",
matchedBy: "default",
};
},
},
session: {
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
return agentId;
},
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
return sessionUpdatedAt.get(sessionKey);
},
recordInboundSession({ sessionKey }: { sessionKey: string }) {
sessionUpdatedAt.set(sessionKey, Date.now());
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};
},
formatAgentEnvelope({ body }: { body: string }) {
return body;
},
finalizeInboundContext(ctx: Record<string, unknown>) {
return ctx as typeof ctx & { CommandAuthorized: boolean };
},
async dispatchReplyWithBufferedBlockDispatcher({
ctx,
dispatcherOptions,
replyOptions,
}: {
ctx: { BodyForAgent?: string; Body?: string };
dispatcherOptions: {
deliver: (payload: { text: string }, info: { kind: string }) => Promise<void>;
};
replyOptions?: {
onToolStart?: (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => Promise<void> | void;
};
}) {
for (const toolStart of params?.toolStarts ?? []) {
await replyOptions?.onToolStart?.(toolStart);
}
params?.onDispatch?.(ctx as Record<string, unknown>);
await dispatcherOptions.deliver(
{
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
},
{ kind: "final" },
);
},
},
inbound: {
async dispatchReply(turn: QaDispatchTurn) {
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey:
typeof turn.ctxPayload.SessionKey === "string"
? turn.ctxPayload.SessionKey
: turn.routeSessionKey,
ctx: turn.ctxPayload,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
admission: turn.admission ?? { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload,
cfg: turn.cfg,
dispatcherOptions: {
...turn.dispatcherOptions,
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
await turn.delivery.deliver(...args);
},
onError: turn.delivery.onError,
},
replyOptions: turn.replyOptions,
replyResolver: turn.replyResolver,
}),
};
},
},
},
} as unknown as PluginRuntime);
}
function createQaChannelConfig(params: { baseUrl: string; allowFrom?: string[] }) {
return {
channels: {
"qa-channel": {
baseUrl: params.baseUrl,
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
allowFrom: params.allowFrom,
},
},
};
}
function requireQaStartAccount() {
const startAccount = qaChannelPlugin.gateway?.startAccount;
if (!startAccount) {
throw new Error("expected qa-channel gateway startAccount");
}
return startAccount;
}
function requireQaMessageAdapter() {
const adapter = qaChannelPlugin.message;
if (!adapter) {
throw new Error("expected qa-channel message adapter");
}
return adapter;
}
function requireQaActionHandler() {
const handleAction = qaChannelPlugin.actions?.handleAction;
if (!handleAction) {
throw new Error("expected qa-channel action handler");
}
return handleAction;
}
async function startQaChannelTestHarness(params?: {
runtime?: PluginRuntime;
allowFrom?: string[];
}) {
installQaChannelTestRegistry();
const state = createQaBusState();
const bus = await startQaBusServer({ state });
setQaChannelRuntime(params?.runtime ?? createMockQaRuntime());
const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl, allowFrom: params?.allowFrom });
const account = qaChannelPlugin.config.resolveAccount(cfg, "default");
const abort = new AbortController();
const startAccount = requireQaStartAccount();
const task = startAccount(
createStartAccountContext({
account,
cfg,
abortSignal: abort.signal,
}),
);
return {
state,
baseUrl: bus.baseUrl,
async stop() {
abort.abort();
await task;
await bus.stop();
},
};
}
describe("qa-channel plugin", () => {
it("derives thread-aware outbound session routes from explicit thread targets", async () => {
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: {},
agentId: "main",
accountId: "default",
target: "thread:qa-room/thread-1",
});
expect(route?.sessionKey).toBe("agent:main:qa-channel:channel:thread:qa-room/thread-1");
expect(route?.baseSessionKey).toBe("agent:main:qa-channel:channel:thread:qa-room/thread-1");
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?.sessionKey).toBe("agent:main:qa-channel:group:group:qa-room");
expect(route?.baseSessionKey).toBe("agent:main:qa-channel:group:group:qa-room");
expect(route?.chatType).toBe("group");
expect(route?.to).toBe("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?.id).toBe("qa-room");
expect(resolved?.baseConversationId).toBe("qa-room");
expect(resolved?.parentConversationCandidates).toEqual(["qa-room"]);
});
it("recovers thread-aware outbound session routes from currentSessionKey", async () => {
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: {},
agentId: "main",
accountId: "default",
target: "channel:qa-room",
currentSessionKey: "agent:main:qa-channel:channel:channel:qa-room:thread:thread-1",
});
expect(route?.sessionKey).toBe("agent:main:qa-channel:channel:channel:qa-room:thread:thread-1");
expect(route?.baseSessionKey).toBe("agent:main:qa-channel:channel:channel:qa-room");
expect(route?.threadId).toBe("thread-1");
});
it('does not recover currentSessionKey threads for shared dmScope "main" DMs', async () => {
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: {},
agentId: "main",
accountId: "default",
target: "dm:alice",
currentSessionKey: "agent:main:main:thread:thread-1",
});
expect(route?.sessionKey).toBe("agent:main:main");
expect(route?.baseSessionKey).toBe("agent:main:main");
expect(route?.threadId).toBeUndefined();
});
it("backs declared message adapter capabilities with qa bus sends", async () => {
const harness = await startQaChannelTestHarness({ allowFrom: ["*"] });
try {
const adapter = requireQaMessageAdapter();
const proveText = async () => {
const result = await adapter.send!.text!({
cfg: createQaChannelConfig({ baseUrl: harness.baseUrl, allowFrom: ["*"] }),
to: "thread:qa-room/thread-1",
text: "hello",
accountId: "default",
replyToId: "parent-1",
threadId: "thread-1",
});
const receiptPart = result.receipt.parts[0];
expect(receiptPart?.kind).toBe("text");
expect(receiptPart?.replyToId).toBe("parent-1");
expect(receiptPart?.threadId).toBe("thread-1");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "qaChannelMessageAdapter",
adapter,
proofs: {
text: proveText,
replyTo: proveText,
thread: proveText,
messageSendingHooks: () => {
expect(adapter.send!.text).toBeTypeOf("function");
},
},
});
} finally {
await harness.stop();
}
});
it("roundtrips inbound DM traffic through the qa bus", { timeout: 20_000 }, async () => {
const harness = await startQaChannelTestHarness({ allowFrom: ["*"] });
try {
harness.state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "hello",
});
const outbound = await harness.state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: hello",
direction: "outbound",
timeoutMs: 15_000,
});
expect("text" in outbound && outbound.text).toContain("qa-echo: hello");
} finally {
await harness.stop();
}
});
it(
"attaches sanitized agent tool starts to outbound qa bus messages",
{ timeout: 20_000 },
async () => {
const harness = await startQaChannelTestHarness({
allowFrom: ["*"],
runtime: createMockQaRuntime({
toolStarts: [
{
name: "exec",
phase: "start",
args: {
command: "pwd",
apiToken: "secret-token",
},
},
{
name: "exec",
phase: "update",
args: {
command: "ignored update",
},
},
],
}),
});
try {
harness.state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "hello",
});
const outbound = await harness.state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: hello",
direction: "outbound",
timeoutMs: 15_000,
});
expect("toolCalls" in outbound ? outbound.toolCalls : undefined).toEqual([
{
name: "exec",
arguments: {
command: "[redacted]",
apiToken: "[redacted]",
},
},
]);
} finally {
await harness.stop();
}
},
);
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,
});
const ctx = expectDispatchedContext(dispatchedCtx);
expect(ctx.ChatType).toBe("group");
expect(ctx.From).toBe("group:qa-room");
expect(ctx.To).toBe("group:qa-room");
expect(ctx.SessionKey).toBe("qa-agent:group:group:qa-room");
expect(ctx.SenderId).toBe("alice");
expect(ctx.GroupSubject).toBe("QA Room");
expect("conversation" in outbound).toBe(true);
if (!("conversation" in outbound)) {
throw new Error("expected outbound message conversation");
}
expect(outbound.conversation.id).toBe("qa-room");
expect(outbound.conversation.kind).toBe("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({
allowFrom: ["*"],
runtime: createMockQaRuntime({
onDispatch: (ctx) => {
dispatchedCtx = ctx;
},
}),
});
try {
harness.state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "describe this image",
attachments: [
{
id: "image-1",
kind: "image",
mimeType: "image/png",
fileName: "red-top-blue-bottom.png",
contentBase64:
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=",
},
],
});
await harness.state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: describe this image",
direction: "outbound",
timeoutMs: 15_000,
});
const mediaCtx = expectDispatchedContext(dispatchedCtx) as {
MediaPath?: string;
MediaPaths?: string[];
MediaType?: string;
MediaTypes?: string[];
};
expect(typeof mediaCtx.MediaPath).toBe("string");
expect(path.basename(mediaCtx.MediaPath ?? "")).toMatch(
/^red-top-blue-bottom---[a-f0-9-]{36}\.png$/,
);
expect(mediaCtx.MediaType).toBe("image/png");
expect(mediaCtx.MediaPaths).toEqual([mediaCtx.MediaPath]);
expect(mediaCtx.MediaTypes).toEqual(["image/png"]);
} finally {
await harness.stop();
}
});
it("exposes thread and message actions against the qa bus", async () => {
installQaChannelTestRegistry();
const state = createQaBusState();
const bus = await startQaBusServer({ state });
try {
const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl });
const handleAction = requireQaActionHandler();
const threadResult = await handleAction({
channel: "qa-channel",
action: "thread-create",
cfg,
accountId: "default",
params: {
channelId: "qa-room",
title: "QA thread",
},
});
const threadPayload = extractToolPayload(threadResult) as {
thread: { id: string };
target: string;
};
expect(threadPayload.thread.id).toMatch(/^thread-/);
expect(threadPayload.target).toContain(threadPayload.thread.id);
const outbound = state.addOutboundMessage({
to: threadPayload.target,
text: "message",
threadId: threadPayload.thread.id,
});
await handleAction({
channel: "qa-channel",
action: "react",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
emoji: "white_check_mark",
},
});
await handleAction({
channel: "qa-channel",
action: "edit",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
text: "message (edited)",
},
});
const readResult = await handleAction({
channel: "qa-channel",
action: "read",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
},
});
const readPayload = extractToolPayload(readResult) as { message: { text: string } };
expect(readPayload.message.text).toContain("(edited)");
const searchResult = await handleAction({
channel: "qa-channel",
action: "search",
cfg,
accountId: "default",
params: {
query: "edited",
channelId: "qa-room",
threadId: threadPayload.thread.id,
},
});
const searchPayload = extractToolPayload(searchResult) as {
messages: Array<{ id: string }>;
};
expect(searchPayload.messages.map((message) => message.id)).toContain(outbound.id);
await handleAction({
channel: "qa-channel",
action: "delete",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
},
});
expect(state.readMessage({ messageId: outbound.id }).deleted).toBe(true);
} finally {
await bus.stop();
}
});
it("routes the advertised send action to the qa bus", async () => {
installQaChannelTestRegistry();
const state = createQaBusState();
const bus = await startQaBusServer({ state });
try {
const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl });
const sendTarget = qaChannelPlugin.actions?.extractToolSend?.({
args: {
action: "send",
target: "qa-room",
message: "hello",
},
});
expect(sendTarget).toEqual({ to: "channel:qa-room", threadId: undefined });
const result = await qaChannelPlugin.actions?.handleAction?.({
channel: "qa-channel",
action: "send",
cfg,
accountId: "default",
params: {
target: "qa-room",
message: "hello from action",
},
});
const payload = extractToolPayload(result) as { message: { text: string } };
expect(payload.message.text).toBe("hello from action");
const outbound = await state.waitFor({
kind: "message-text",
direction: "outbound",
textIncludes: "hello from action",
timeoutMs: 5_000,
});
expect("conversation" in outbound).toBe(true);
if (!("conversation" in outbound)) {
throw new Error("expected outbound message match");
}
expect(outbound.conversation.id).toBe("qa-room");
expect(outbound.conversation.kind).toBe("channel");
} finally {
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) as { message: { text: string } };
expect(payload.message.text).toBe("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.id).toBe("qa-room");
expect(outbound.conversation.kind).toBe("group");
} finally {
await bus.stop();
}
});
});

View File

@@ -0,0 +1,143 @@
// Qa Channel plugin module implements channel behavior.
import {
buildChannelOutboundSessionRoute,
buildThreadAwareOutboundSessionRoute,
createChatChannelPlugin,
} from "openclaw/plugin-sdk/channel-core";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
} from "openclaw/plugin-sdk/channel-outbound";
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
import { buildQaTarget, normalizeQaTarget, parseQaTarget } from "./bus-client.js";
import { qaChannelMessageActions } from "./channel-actions.js";
import { createQaChannelPluginBase, QA_CHANNEL_ID, qaChannelRuntimeMeta } from "./channel-base.js";
import { startQaGatewayAccount } from "./gateway.js";
import { sendQaChannelText } from "./outbound.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { qaChannelStatus } from "./status.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
const qaChannelMessageAdapter = defineChannelMessageAdapter({
id: QA_CHANNEL_ID,
durableFinal: {
capabilities: {
text: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => {
const result = await sendQaChannelText({
cfg: ctx.cfg as CoreConfig,
accountId: ctx.accountId,
to: ctx.to,
text: ctx.text,
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
const threadId = ctx.threadId == null ? undefined : String(ctx.threadId);
const replyToId = ctx.replyToId ?? undefined;
return {
messageId: result.messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: QA_CHANNEL_ID, messageId: result.messageId }],
threadId,
replyToId,
kind: "text",
}),
};
},
},
});
export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createChatChannelPlugin({
base: {
...createQaChannelPluginBase(qaChannelRuntimeMeta),
messaging: {
normalizeTarget: normalizeQaTarget,
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
targetResolver: {
looksLikeId: (raw) =>
/^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
hint: "<dm:user|channel:room|group:room|thread:room/thread>",
},
resolveOutboundSessionRoute: ({
cfg,
agentId,
accountId,
target,
replyToId,
threadId,
currentSessionKey,
}) => {
const parsed = parseQaTarget(target);
const baseRoute = buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: QA_CHANNEL_ID,
accountId,
peer: {
kind:
parsed.chatType === "direct"
? "direct"
: parsed.chatType === "group"
? "group"
: "channel",
id: buildQaTarget(parsed),
},
chatType: parsed.chatType,
from: `${QA_CHANNEL_ID}:${accountId ?? DEFAULT_ACCOUNT_ID}`,
to: buildQaTarget(parsed),
});
return buildThreadAwareOutboundSessionRoute({
route: baseRoute,
replyToId,
threadId: threadId ?? (target.trim().startsWith("thread:") ? undefined : parsed.threadId),
currentSessionKey,
canRecoverCurrentThread: ({ route }) =>
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: {
startAccount: async (ctx) => {
await startQaGatewayAccount(QA_CHANNEL_ID, qaChannelRuntimeMeta.label, ctx);
},
},
actions: qaChannelMessageActions,
message: qaChannelMessageAdapter,
},
outbound: {
base: {
deliveryMode: "direct",
},
attachedResults: {
channel: QA_CHANNEL_ID,
sendText: async ({ cfg, to, text, accountId, threadId, replyToId }) =>
await sendQaChannelText({
cfg: cfg as CoreConfig,
accountId,
to,
text,
threadId,
replyToId,
}),
},
},
});

View File

@@ -0,0 +1,47 @@
// Qa Channel helper module supports config schema behavior.
import {
ToolPolicySchema,
buildChannelConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
const QaChannelActionConfigSchema = z
.object({
messages: z.boolean().optional(),
reactions: z.boolean().optional(),
search: z.boolean().optional(),
threads: z.boolean().optional(),
})
.strict();
const QaChannelGroupConfigSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema.optional(),
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
})
.strict();
const QaChannelAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().url().optional(),
botUserId: z.string().optional(),
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(),
})
.strict();
const QaChannelConfigSchema = QaChannelAccountConfigSchema.extend({
accounts: z.record(z.string(), QaChannelAccountConfigSchema.partial()).optional(),
defaultAccount: z.string().optional(),
}).strict();
export const qaChannelPluginConfigSchema = buildChannelConfigSchema(QaChannelConfigSchema);

View File

@@ -0,0 +1,228 @@
// Qa Channel tests cover gateway lifecycle behavior.
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startQaGatewayAccount } from "./gateway.js";
import { handleQaInbound } from "./inbound.js";
import type { ChannelGatewayContext } from "./runtime-api.js";
import type { ResolvedQaChannelAccount } from "./types.js";
vi.mock("./inbound.js", () => ({
handleQaInbound: vi.fn(async () => undefined),
}));
async function startJsonServer(
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
) {
const server = createServer((req, res) => {
const response = handler({ url: req.url });
res.writeHead(response.statusCode ?? 200, {
"content-type": "application/json; charset=utf-8",
});
res.end(response.body);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server failed to bind");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
async stop() {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe("qa-channel gateway", () => {
const stops: Array<() => Promise<void>> = [];
afterEach(async () => {
vi.mocked(handleQaInbound).mockReset().mockResolvedValue(undefined);
await Promise.all(stops.splice(0).map((stop) => stop()));
});
it("lets native commands bypass the ordered inbound queue", async () => {
const controller = new AbortController();
const message = {
id: "msg-1",
accountId: "default",
direction: "inbound" as const,
conversation: { id: "alice", kind: "direct" as const },
senderId: "alice",
text: "hello",
timestamp: Date.now(),
reactions: [],
};
const server = await startJsonServer(() => ({
body: JSON.stringify({
cursor: 2,
events: [
{ cursor: 1, kind: "inbound-message", accountId: "default", message },
{
cursor: 2,
kind: "inbound-message",
accountId: "default",
message: { ...message, id: "msg-2", text: "follow-up" },
},
{
cursor: 3,
kind: "inbound-message",
accountId: "default",
message: {
...message,
id: "msg-3",
text: "/stop",
nativeCommand: { name: "stop" },
},
},
],
}),
}));
stops.push(() => server.stop());
let releaseFirst: (() => void) | undefined;
const firstPending = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
vi.mocked(handleQaInbound).mockImplementation(async ({ message: inbound }) => {
if (inbound.text === "hello") {
await firstPending;
}
if (inbound.text === "/stop") {
controller.abort();
}
});
const account: ResolvedQaChannelAccount = {
accountId: "default",
baseUrl: server.baseUrl,
botDisplayName: "QA Bot",
botUserId: "qa-bot",
config: {},
configured: true,
enabled: true,
pollTimeoutMs: 1,
};
const gateway = startQaGatewayAccount("qa-channel", "QA Channel", {
abortSignal: controller.signal,
account,
cfg: {},
setStatus: vi.fn(),
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>);
await vi.waitFor(() => {
const handled = vi.mocked(handleQaInbound).mock.calls.map(([params]) => params.message.text);
expect(handled).toContain("hello");
expect(handled).toContain("/stop");
expect(handled).not.toContain("follow-up");
});
releaseFirst?.();
await gateway;
const handled = vi.mocked(handleQaInbound).mock.calls.map(([params]) => params.message.text);
expect(handled).toHaveLength(3);
expect(handled).toContain("/stop");
expect(handled.indexOf("hello")).toBeLessThan(handled.indexOf("follow-up"));
});
it("clears running status when polling fails", async () => {
const server = await startJsonServer(() => ({
statusCode: 500,
body: JSON.stringify({ error: "qa bus unavailable" }),
}));
stops.push(() => server.stop());
const account: ResolvedQaChannelAccount = {
accountId: "default",
baseUrl: server.baseUrl,
botDisplayName: "QA Bot",
botUserId: "qa-bot",
config: {},
configured: true,
enabled: true,
pollTimeoutMs: 1,
};
const setStatus = vi.fn();
await expect(
startQaGatewayAccount("qa-channel", "QA Channel", {
abortSignal: new AbortController().signal,
account,
cfg: {},
setStatus,
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>),
).rejects.toThrow("qa bus unavailable");
expect(setStatus.mock.calls.map(([status]) => status)).toEqual([
{
accountId: "default",
baseUrl: server.baseUrl,
configured: true,
enabled: true,
running: true,
},
{
accountId: "default",
running: false,
},
]);
});
it("stops the ordered inbound queue after the first dispatch failure", async () => {
const controller = new AbortController();
const message = {
id: "msg-1",
accountId: "default",
direction: "inbound" as const,
conversation: { id: "alice", kind: "direct" as const },
senderId: "alice",
text: "first",
timestamp: Date.now(),
reactions: [],
};
const server = await startJsonServer(() => ({
body: JSON.stringify({
cursor: 2,
events: [
{ cursor: 1, kind: "inbound-message", accountId: "default", message },
{
cursor: 2,
kind: "inbound-message",
accountId: "default",
message: { ...message, id: "msg-2", text: "second" },
},
],
}),
}));
stops.push(() => server.stop());
vi.mocked(handleQaInbound).mockImplementationOnce(async () => {
controller.abort();
throw new Error("inbound failed");
});
const account: ResolvedQaChannelAccount = {
accountId: "default",
baseUrl: server.baseUrl,
botDisplayName: "QA Bot",
botUserId: "qa-bot",
config: {},
configured: true,
enabled: true,
pollTimeoutMs: 1,
};
await expect(
startQaGatewayAccount("qa-channel", "QA Channel", {
abortSignal: controller.signal,
account,
cfg: {},
setStatus: vi.fn(),
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>),
).rejects.toThrow("inbound failed");
expect(handleQaInbound).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,90 @@
// Qa Channel plugin module implements gateway behavior.
import { pollQaBus } from "./bus-client.js";
import { handleQaInbound } from "./inbound.js";
import type { ChannelGatewayContext } from "./runtime-api.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
export async function startQaGatewayAccount(
channelId: string,
channelLabel: string,
ctx: ChannelGatewayContext<ResolvedQaChannelAccount>,
) {
const account = ctx.account;
if (!account.configured) {
throw new Error(`QA channel is not configured for account "${account.accountId}"`);
}
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
let cursor = 0;
let inboundError: Error | undefined;
let queuedInbound = Promise.resolve();
const controlTasks = new Set<Promise<void>>();
const handleMessage = (message: Parameters<typeof handleQaInbound>[0]["message"]) =>
handleQaInbound({
channelId,
channelLabel,
account,
config: ctx.cfg as CoreConfig,
message,
});
const captureInboundError = (error: unknown) => {
inboundError ??= error instanceof Error ? error : new Error(String(error));
};
const dispatchControl = (message: Parameters<typeof handleQaInbound>[0]["message"]) => {
const task = handleMessage(message)
.catch(captureInboundError)
.finally(() => controlTasks.delete(task));
controlTasks.add(task);
};
const enqueueInbound = (message: Parameters<typeof handleQaInbound>[0]["message"]) => {
queuedInbound = queuedInbound
.then(() => (inboundError ? undefined : handleMessage(message)))
.catch(captureInboundError);
};
try {
while (!ctx.abortSignal.aborted) {
if (inboundError) {
throw inboundError;
}
const result = await pollQaBus({
baseUrl: account.baseUrl,
accountId: account.accountId,
cursor,
timeoutMs: account.pollTimeoutMs,
signal: ctx.abortSignal,
});
cursor = result.cursor;
for (const event of result.events) {
if (event.kind !== "inbound-message") {
continue;
}
if (event.message.nativeCommand) {
dispatchControl(event.message);
} else {
enqueueInbound(event.message);
}
}
}
if (inboundError) {
throw inboundError;
}
} catch (error) {
if (!(error instanceof Error) || error.name !== "AbortError") {
throw error;
}
} finally {
await Promise.all([queuedInbound, ...controlTasks]);
ctx.setStatus({
accountId: account.accountId,
running: false,
});
}
if (inboundError) {
throw inboundError;
}
}

View File

@@ -0,0 +1,416 @@
// Qa Channel tests cover inbound plugin behavior.
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setQaChannelRuntime } from "../api.js";
import { deleteQaBusMessage, editQaBusMessage, sendQaBusMessage } from "./bus-client.js";
import { handleQaInbound, isHttpMediaUrl } from "./inbound.js";
vi.mock("./bus-client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./bus-client.js")>();
return {
...actual,
deleteQaBusMessage: vi.fn(async () => ({ message: {} })),
editQaBusMessage: vi.fn(async () => ({ message: {} })),
sendQaBusMessage: vi.fn(async () => ({ message: { id: "preview-1" } })),
};
});
type HandleQaInboundParams = Parameters<typeof handleQaInbound>[0];
function createQaInboundParams(
overrides: {
accountConfig?: HandleQaInboundParams["account"]["config"];
message?: Partial<HandleQaInboundParams["message"]>;
} = {},
): HandleQaInboundParams {
return {
channelId: "qa-channel",
channelLabel: "QA Channel",
account: {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:43123",
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
pollTimeoutMs: 250,
config: {
allowFrom: ["*"],
...overrides.accountConfig,
},
},
config: {},
message: {
id: "msg-1",
accountId: "default",
direction: "inbound",
conversation: {
kind: "direct",
id: "alice",
},
senderId: "alice",
senderName: "Alice",
text: "ping",
timestamp: 1_777_000_000_000,
reactions: [],
...overrides.message,
},
};
}
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) {
const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0];
if (!call) {
throw new Error("expected assembled turn call");
}
return call[0];
}
describe("isHttpMediaUrl", () => {
it("accepts only http and https urls", () => {
expect(isHttpMediaUrl("https://example.com/image.png")).toBe(true);
expect(isHttpMediaUrl("http://example.com/image.png")).toBe(true);
expect(isHttpMediaUrl("file:///etc/passwd")).toBe(false);
expect(isHttpMediaUrl("/etc/passwd")).toBe(false);
expect(isHttpMediaUrl("data:text/plain;base64,SGVsbG8=")).toBe(false);
});
});
describe("handleQaInbound", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("publishes partial replies as one edited preview before final delivery", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
message: {
conversation: { id: "qa-room", kind: "group" },
threadId: "42",
},
}),
);
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "preview" });
await assembled.replyOptions?.onPartialReply?.({ text: "preview expanded" });
await assembled.delivery.deliver({ text: "final answer" }, { kind: "final" });
expect(sendQaBusMessage).toHaveBeenCalledOnce();
expect(sendQaBusMessage).toHaveBeenCalledWith(
expect.objectContaining({
replyToId: "msg-1",
text: "preview",
threadId: "42",
to: "thread:qa-room/42",
}),
);
expect(editQaBusMessage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ messageId: "preview-1", text: "preview expanded" }),
);
expect(editQaBusMessage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ messageId: "preview-1", text: "final answer" }),
);
});
it("treats deliveries without dispatcher metadata as final replies", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(createQaInboundParams());
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "preview" });
const missingDeliveryInfo = undefined as unknown as Parameters<
typeof assembled.delivery.deliver
>[1];
await assembled.delivery.deliver({ text: "final answer" }, missingDeliveryInfo);
expect(sendQaBusMessage).toHaveBeenCalledOnce();
expect(editQaBusMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: "preview-1", text: "final answer" }),
);
expect(deleteQaBusMessage).not.toHaveBeenCalled();
});
it("keeps block deliveries separate and retains tool calls discovered after a preview", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(createQaInboundParams());
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "preview" });
await assembled.replyOptions?.onToolStart?.({
phase: "start",
name: "search",
args: { query: "qa" },
});
await assembled.delivery.deliver({ text: "tool result" }, { kind: "block" });
await assembled.delivery.deliver({ text: "final answer" }, { kind: "final" });
expect(deleteQaBusMessage).toHaveBeenCalledOnce();
expect(sendQaBusMessage).toHaveBeenCalledTimes(3);
expect(sendQaBusMessage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
text: "tool result",
toolCalls: [{ name: "search", arguments: { query: "[redacted]" } }],
}),
);
expect(sendQaBusMessage).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
text: "final answer",
toolCalls: [{ name: "search", arguments: { query: "[redacted]" } }],
}),
);
});
it("deletes an active preview when reply dispatch fails", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(createQaInboundParams());
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" });
assembled.delivery.onError?.(new Error("model failed"), { kind: "final" });
await vi.waitFor(() => {
expect(deleteQaBusMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: "preview-1" }),
);
});
});
it("deletes a preview after a queued edit fails", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
vi.mocked(editQaBusMessage).mockRejectedValueOnce(new Error("edit failed"));
await handleQaInbound(createQaInboundParams());
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "first preview" });
await expect(
assembled.replyOptions?.onPartialReply?.({ text: "broken preview" }),
).rejects.toThrow("edit failed");
assembled.delivery.onError?.(new Error("dispatch failed"), { kind: "final" });
await vi.waitFor(() => {
expect(deleteQaBusMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: "preview-1" }),
);
});
});
it("escapes control characters in dispatch error logs", async () => {
const runtime = createPluginRuntimeMock();
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const c1Control = String.fromCharCode(0x9b);
const lineSeparator = String.fromCodePoint(0x2028);
const paragraphSeparator = String.fromCodePoint(0x2029);
vi.mocked(deleteQaBusMessage).mockRejectedValueOnce(
new Error(`cleanup\nforged\u001b[31m${c1Control}32m${lineSeparator}next`),
);
setQaChannelRuntime(runtime);
try {
await handleQaInbound(createQaInboundParams());
const assembled = firstRunAssembledParams(runtime);
await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" });
assembled.delivery.onError?.(new Error(`dispatch\r\nforged${paragraphSeparator}next`), {
kind: "final",
});
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledTimes(2);
});
assembled.delivery.onError?.(undefined, { kind: "final" });
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledTimes(3);
});
const output = warn.mock.calls.flat().join(" ");
expect(output).not.toContain("\r");
expect(output).not.toContain("\n");
expect(output).not.toContain(String.fromCharCode(0x1b));
expect(output).not.toContain(c1Control);
expect(output).not.toContain(lineSeparator);
expect(output).not.toContain(paragraphSeparator);
expect(output).toContain("dispatch\\u000d\\u000aforged\\u2029next");
expect(output).toContain("cleanup\\u000aforged\\u001b[31m\\u009b32m\\u2028next");
expect(output).toContain("[object Undefined]");
} finally {
warn.mockRestore();
}
});
it("marks group messages that match configured mention patterns", async () => {
const runtime = createPluginRuntimeMock();
vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]);
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
message: {
conversation: {
kind: "channel",
id: "qa-room",
title: "QA Room",
},
senderId: "alice",
senderName: "Alice",
text: "@openclaw ping",
},
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
const assembled = firstRunAssembledParams(runtime);
expect(assembled.replyPipeline).toEqual({});
expect(assembled.ctxPayload.WasMentioned).toBe(true);
});
it("drops direct messages outside the configured sender allowlist", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
accountConfig: {
allowFrom: ["bob"],
},
}),
);
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
});
it("allows direct messages from configured senders", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
accountConfig: {
allowFrom: ["alice"],
},
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload?.CommandAuthorized).toBe(true);
expect(ctxPayload?.SenderId).toBe("alice");
});
it("routes native commands through a separate slash session to the conversation session", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
message: {
text: "/stop",
nativeCommand: { name: "stop" },
},
}),
);
const assembled = firstRunAssembledParams(runtime);
expect(assembled.ctxPayload).toMatchObject({
CommandAuthorized: true,
CommandSource: "native",
CommandTargetSessionKey: assembled.routeSessionKey,
CommandTurn: {
body: "/stop",
source: "native",
},
});
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice");
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey);
});
it("skips malformed inline attachment base64 without dropping the message", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
message: {
attachments: [
{
id: "attachment-1",
kind: "image",
mimeType: "image/png",
contentBase64: "AAA@@@",
},
],
},
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload.MediaPath).toBeUndefined();
expect(ctxPayload.MediaPaths).toBeUndefined();
});
it("uses allowFrom as the group sender fallback for allowlist policy", async () => {
const runtime = createPluginRuntimeMock();
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
accountConfig: {
allowFrom: ["alice"],
groupPolicy: "allowlist",
},
message: {
conversation: {
kind: "group",
id: "qa-room",
title: "QA Room",
},
},
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
});
it("skips configured group messages that miss mention activation", async () => {
const runtime = createPluginRuntimeMock();
vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]);
setQaChannelRuntime(runtime);
await handleQaInbound(
createQaInboundParams({
accountConfig: {
groups: {
"qa-room": {
requireMention: true,
},
},
},
message: {
conversation: {
kind: "group",
id: "qa-room",
title: "QA Room",
},
text: "plain group message",
},
}),
);
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,414 @@
// Qa Channel plugin module implements inbound behavior.
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
buildAgentMediaPayload,
saveMediaBuffer,
saveMediaSource,
} from "openclaw/plugin-sdk/media-runtime";
import {
sanitizeQaBusToolCallArguments,
type QaBusToolCall,
} from "openclaw/plugin-sdk/qa-channel-protocol";
import {
buildQaTarget,
deleteQaBusMessage,
editQaBusMessage,
sendQaBusMessage,
type QaBusMessage,
} from "./bus-client.js";
import { getQaChannelRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
export function isHttpMediaUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function normalizeBase64ForCompare(value: string): string {
return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
}
function decodeAttachmentBase64(value: string): Buffer | null {
const buffer = Buffer.from(value, "base64");
if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) {
return null;
}
return buffer;
}
async function resolveQaInboundMediaPayload(attachments: QaBusMessage["attachments"]) {
if (!Array.isArray(attachments) || attachments.length === 0) {
return {};
}
const mediaList: Array<{ path: string; contentType?: string | null }> = [];
for (const attachment of attachments) {
if (!attachment?.mimeType) {
continue;
}
if (typeof attachment.contentBase64 === "string" && attachment.contentBase64.trim()) {
const buffer = decodeAttachmentBase64(attachment.contentBase64);
if (!buffer) {
console.warn("[qa-channel] inbound attachment contentBase64 rejected (invalid base64)");
continue;
}
const saved = await saveMediaBuffer(
buffer,
attachment.mimeType,
"inbound",
undefined,
attachment.fileName,
);
mediaList.push({
path: saved.path,
contentType: saved.contentType,
});
continue;
}
if (typeof attachment.url === "string" && attachment.url.trim()) {
if (!isHttpMediaUrl(attachment.url)) {
console.warn(
`[qa-channel] inbound attachment URL rejected (non-http scheme): ${attachment.url}`,
);
continue;
}
const saved = await saveMediaSource(attachment.url, undefined, "inbound");
mediaList.push({
path: saved.path,
contentType: saved.contentType,
});
}
}
return mediaList.length > 0 ? buildAgentMediaPayload(mediaList) : {};
}
function resolveQaGroupConfig(params: {
account: ResolvedQaChannelAccount;
conversationId: string;
target: string;
}) {
const groups = params.account.config.groups;
return groups?.[params.conversationId] ?? groups?.[params.target] ?? groups?.["*"];
}
function formatQaErrorForLog(error: unknown): string {
let escaped = "";
const message = formatErrorMessage(error) || Object.prototype.toString.call(error);
for (const character of message) {
const codePoint = character.codePointAt(0) ?? 0;
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
const isLineSeparator = codePoint === 0x2028 || codePoint === 0x2029;
escaped +=
isControl || isLineSeparator ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
}
return escaped;
}
function createQaReplyPreview(params: {
account: ResolvedQaChannelAccount;
inbound: QaBusMessage;
target: string;
toolCalls: QaBusToolCall[];
}) {
let messageId: string | null = null;
let currentText = "";
let pending = Promise.resolve();
const write = (text: string) => {
if (!text.trim() || text === currentText) {
return pending;
}
pending = pending.then(async () => {
if (messageId) {
await editQaBusMessage({
baseUrl: params.account.baseUrl,
accountId: params.account.accountId,
messageId,
text,
});
} else {
const response = await sendQaBusMessage({
baseUrl: params.account.baseUrl,
accountId: params.account.accountId,
to: params.target,
text,
senderId: params.account.botUserId,
senderName: params.account.botDisplayName,
threadId: params.inbound.threadId,
replyToId: params.inbound.id,
toolCalls: params.toolCalls,
});
messageId = response.message.id;
}
currentText = text;
});
return pending;
};
const clear = async () => {
await pending.catch(() => undefined);
if (!messageId) {
return;
}
await deleteQaBusMessage({
baseUrl: params.account.baseUrl,
accountId: params.account.accountId,
messageId,
});
messageId = null;
currentText = "";
};
const sendDurable = async (text: string) => {
if (!text.trim()) {
return;
}
await sendQaBusMessage({
baseUrl: params.account.baseUrl,
accountId: params.account.accountId,
to: params.target,
text,
senderId: params.account.botUserId,
senderName: params.account.botDisplayName,
threadId: params.inbound.threadId,
replyToId: params.inbound.id,
toolCalls: params.toolCalls,
});
};
return {
clear,
async deliver(text: string, kind: string) {
await pending;
if (kind === "final" && messageId && params.toolCalls.length === 0) {
await write(text);
return;
}
await clear();
await sendDurable(text);
},
update: write,
};
}
export async function handleQaInbound(params: {
channelId: string;
channelLabel: string;
account: ResolvedQaChannelAccount;
config: CoreConfig;
message: QaBusMessage;
}) {
const runtime = getQaChannelRuntime();
const inbound = params.message;
const target = buildQaTarget({
chatType: inbound.conversation.kind,
conversationId: inbound.conversation.id,
threadId: inbound.threadId,
});
const toolCalls: QaBusToolCall[] = [];
const preview = createQaReplyPreview({
account: params.account,
inbound,
target,
toolCalls,
});
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
peer: {
kind:
inbound.conversation.kind === "direct"
? "direct"
: inbound.conversation.kind === "group"
? "group"
: "channel",
id: target,
},
runtime: runtime.channel,
sessionStore: params.config.session?.store,
});
const isGroup = inbound.conversation.kind !== "direct";
const wasMentioned = isGroup
? runtime.channel.mentions.matchesMentionPatterns(
inbound.text,
runtime.channel.mentions.buildMentionRegexes(
params.config as OpenClawConfig,
route.agentId,
),
)
: undefined;
const groupConfig = isGroup
? resolveQaGroupConfig({
account: params.account,
conversationId: inbound.conversation.id,
target,
})
: undefined;
const access = await resolveStableChannelMessageIngress({
channelId: params.channelId,
accountId: params.account.accountId,
identity: { key: "sender", entryIdPrefix: "qa-entry" },
groupAllowFromFallbackToAllowFrom: true,
subject: { stableId: inbound.senderId },
conversation: {
kind: inbound.conversation.kind,
id: inbound.conversation.id,
threadId: inbound.threadId,
title: inbound.conversation.title,
},
mentionFacts: isGroup
? {
canDetectMention: true,
wasMentioned: wasMentioned ?? false,
}
: undefined,
dmPolicy: "open",
groupPolicy: params.account.config.groupPolicy ?? "open",
policy: {
activation: isGroup
? {
requireMention: groupConfig?.requireMention ?? false,
allowTextCommands: true,
}
: undefined,
},
allowFrom: params.account.config.allowFrom,
groupAllowFrom: params.account.config.groupAllowFrom,
});
if (access.ingress.admission !== "dispatch") {
return;
}
const { storePath, body } = buildEnvelope({
channel: params.channelLabel,
from: inbound.senderName || inbound.senderId,
timestamp: inbound.timestamp,
body: inbound.text,
});
const mediaPayload = await resolveQaInboundMediaPayload(inbound.attachments);
const nativeCommand = inbound.nativeCommand;
const commandTargets = nativeCommand
? resolveNativeCommandSessionTargets({
agentId: route.agentId,
sessionPrefix: "qa-channel:slash",
userId: inbound.senderId,
targetSessionKey: route.sessionKey,
})
: undefined;
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: inbound.text,
RawBody: inbound.text,
CommandBody: commandBody,
From: target,
To: target,
SessionKey: commandTargets?.sessionKey ?? route.sessionKey,
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
WasMentioned: wasMentioned,
ConversationLabel:
inbound.threadTitle ||
inbound.conversation.title ||
inbound.senderName ||
inbound.conversation.id,
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,
ThreadLabel: inbound.threadTitle,
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined,
SenderName: inbound.senderName,
SenderId: inbound.senderId,
Provider: params.channelId,
Surface: params.channelId,
MessageSid: inbound.id,
MessageSidFull: inbound.id,
ReplyToId: inbound.replyToId,
Timestamp: inbound.timestamp,
OriginatingChannel: params.channelId,
OriginatingTo: target,
CommandAuthorized: true,
CommandSource: nativeCommand ? "native" : undefined,
CommandTurn: nativeCommand
? {
kind: "native",
source: "native",
authorized: true,
body: commandBody,
}
: undefined,
...mediaPayload,
});
await runtime.channel.inbound.dispatchReply({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload, info) => {
const text =
payload && typeof payload === "object" && "text" in payload
? ((payload as { text?: string }).text ?? "")
: "";
if (!text.trim()) {
return;
}
await preview.deliver(text, info?.kind ?? "final");
},
onError: (error) => {
void preview.clear().catch((clearError: unknown) => {
console.warn(
`[qa-channel] failed to clear reply preview after dispatch error: ${formatQaErrorForLog(clearError)}`,
);
});
console.warn(`[qa-channel] reply dispatch failed: ${formatQaErrorForLog(error)}`);
},
},
replyOptions: {
onPartialReply: async (payload) => {
await preview.update(payload.text ?? "");
},
onToolStart: (payload) => {
if (payload.phase && payload.phase !== "start") {
return;
}
const name = payload.name?.trim();
if (!name) {
return;
}
const args = sanitizeQaBusToolCallArguments(payload.args);
toolCalls.push({
name,
...(args && Object.keys(args).length > 0 ? { arguments: args } : {}),
});
},
},
replyPipeline: {},
record: {
onRecordError: (error) => {
throw error instanceof Error
? error
: new Error(`qa-channel session record failed: ${String(error)}`);
},
},
});
}

View File

@@ -0,0 +1,35 @@
// Qa Channel plugin module implements outbound behavior.
import { resolveQaChannelAccount } from "./accounts.js";
import { buildQaTarget, parseQaTarget, sendQaBusMessage } from "./bus-client.js";
import type { CoreConfig } from "./types.js";
export async function sendQaChannelText(params: {
cfg: CoreConfig;
accountId?: string | null;
to: string;
text: string;
threadId?: string | number | null;
replyToId?: string | number | null;
}) {
const account = resolveQaChannelAccount({ cfg: params.cfg, accountId: params.accountId });
const parsed = parseQaTarget(params.to);
const resolvedThreadId = params.threadId == null ? parsed.threadId : String(params.threadId);
const { message } = await sendQaBusMessage({
baseUrl: account.baseUrl,
accountId: account.accountId,
to: buildQaTarget({
chatType: parsed.chatType,
conversationId: parsed.conversationId,
threadId: resolvedThreadId,
}),
text: params.text,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId: resolvedThreadId,
replyToId: params.replyToId == null ? undefined : String(params.replyToId),
});
return {
to: params.to,
messageId: message.id,
};
}

View File

@@ -0,0 +1,2 @@
// Qa Channel plugin module implements protocol behavior.
export type * from "openclaw/plugin-sdk/qa-channel-protocol";

View File

@@ -0,0 +1,24 @@
// Qa Channel API module exposes the plugin public contract.
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelGatewayContext,
} from "openclaw/plugin-sdk/channel-contract";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export {
buildChannelConfigSchema,
buildChannelOutboundSessionRoute,
createChatChannelPlugin,
defineChannelPluginEntry,
} from "openclaw/plugin-sdk/channel-core";
export { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
export { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
export {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";

View File

@@ -0,0 +1,11 @@
// Qa Channel plugin module implements runtime behavior.
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const { setRuntime: setQaChannelRuntime, getRuntime: getQaChannelRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "qa-channel",
errorMessage: "QA channel runtime not initialized",
});
export { getQaChannelRuntime, setQaChannelRuntime };

View File

@@ -0,0 +1,39 @@
// Qa Channel setup module handles plugin onboarding behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
import type { CoreConfig } from "./types.js";
export function applyQaSetup(params: {
cfg: OpenClawConfig;
accountId: string;
input: Record<string, unknown>;
}): OpenClawConfig {
const nextCfg = structuredClone(params.cfg) as CoreConfig;
const section = nextCfg.channels?.["qa-channel"] ?? {};
const accounts = { ...section.accounts };
const target =
params.accountId === DEFAULT_ACCOUNT_ID ? { ...section } : { ...accounts[params.accountId] };
if (typeof params.input.baseUrl === "string") {
target.baseUrl = params.input.baseUrl;
}
if (typeof params.input.botUserId === "string") {
target.botUserId = params.input.botUserId;
}
if (typeof params.input.botDisplayName === "string") {
target.botDisplayName = params.input.botDisplayName;
}
nextCfg.channels ??= {};
if (params.accountId === DEFAULT_ACCOUNT_ID) {
nextCfg.channels["qa-channel"] = {
...section,
...target,
};
} else {
accounts[params.accountId] = target;
nextCfg.channels["qa-channel"] = {
...section,
accounts,
};
}
return nextCfg as OpenClawConfig;
}

View File

@@ -0,0 +1,24 @@
// Qa Channel plugin module implements status behavior.
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "./runtime-api.js";
import type { ResolvedQaChannelAccount } from "./types.js";
export const qaChannelStatus = createComputedAccountStatusAdapter<ResolvedQaChannelAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) => ({
baseUrl: snapshot.baseUrl ?? "[missing]",
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.baseUrl || "[missing]",
botUserId: account.botUserId,
},
}),
});

View File

@@ -0,0 +1,55 @@
// Qa Channel type declarations define plugin contracts.
type QaChannelActionConfig = {
messages?: boolean;
reactions?: boolean;
search?: boolean;
threads?: boolean;
};
export type QaChannelAccountConfig = {
name?: string;
enabled?: boolean;
baseUrl?: string;
botUserId?: string;
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;
};
type QaChannelConfig = QaChannelAccountConfig & {
accounts?: Record<string, Partial<QaChannelAccountConfig>>;
defaultAccount?: string;
};
export type CoreConfig = {
channels?: {
"qa-channel"?: QaChannelConfig;
};
session?: {
store?: string;
};
};
export type ResolvedQaChannelAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
name?: string;
baseUrl: string;
botUserId: string;
botDisplayName: string;
pollTimeoutMs: number;
config: QaChannelAccountConfig;
};