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,46 @@
// Qa Channel API module exposes the plugin public contract.
export {
DEFAULT_ACCOUNT_ID,
listEnabledQaChannelAccounts,
listQaChannelAccountIds,
resolveDefaultQaChannelAccountId,
type ResolvedQaChannelAccount,
resolveQaChannelAccount,
} from "./src/accounts.js";
export { qaChannelPlugin } from "./src/channel.js";
export { qaChannelMessageActions } from "./src/channel-actions.js";
export { getQaChannelRuntime, setQaChannelRuntime } from "./src/runtime.js";
export {
buildQaTarget,
createQaBusThread,
deleteQaBusMessage,
editQaBusMessage,
getQaBusState,
injectQaBusInboundMessage,
normalizeQaTarget,
parseQaTarget,
pollQaBus,
type QaBusAttachment,
type QaBusConversation,
type QaBusConversationKind,
type QaBusCreateThreadInput,
type QaBusDeleteMessageInput,
type QaBusEditMessageInput,
type QaBusEvent,
type QaBusInboundMessageInput,
type QaBusMessage,
type QaBusOutboundMessageInput,
type QaBusPollInput,
type QaBusPollResult,
type QaBusReactToMessageInput,
type QaBusReadMessageInput,
type QaBusSearchMessagesInput,
type QaBusStateSnapshot,
type QaBusThread,
type QaBusToolCall,
type QaBusWaitForInput,
reactToQaBusMessage,
readQaBusMessage,
searchQaBusMessages,
sendQaBusMessage,
} from "./test-api.js";

View File

@@ -0,0 +1,2 @@
// Qa Channel API module exposes the plugin public contract.
export { qaChannelPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,17 @@
// Qa Channel plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "qa-channel",
name: "QA Channel",
description: "Synthetic QA channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "qaChannelPlugin",
},
runtime: {
specifier: "./api.js",
exportName: "setQaChannelRuntime",
},
});

View File

@@ -0,0 +1,12 @@
{
"id": "qa-channel",
"activation": {
"onStartup": false
},
"channels": ["qa-channel"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,51 @@
{
"name": "@openclaw/qa-channel",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw QA synthetic channel plugin",
"type": "module",
"exports": {
".": "./index.ts",
"./api.js": "./api.ts",
"./runtime-api.js": "./runtime-api.ts",
"./test-api.js": "./test-api.ts"
},
"dependencies": {
"typebox": "1.3.3",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "qa-channel",
"label": "QA Channel",
"selectionLabel": "QA Channel (Synthetic)",
"detailLabel": "QA Channel",
"docsPath": "/channels/qa-channel",
"docsLabel": "qa-channel",
"blurb": "Synthetic Slack-class transport for automated OpenClaw QA scenarios.",
"systemImage": "checklist",
"order": 999,
"exposure": {
"configured": false,
"setup": false,
"docs": false
}
}
}
}

View File

@@ -0,0 +1,21 @@
// Qa Channel API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
buildChannelOutboundSessionRoute,
type ChannelGatewayContext,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
createChatChannelPlugin,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
createPluginRuntimeStore,
defineChannelPluginEntry,
getChatChannelMeta,
jsonResult,
type OpenClawConfig,
type PluginRuntime,
readStringParam,
type RuntimeEnv,
} from "./src/runtime-api.js";
export { getQaChannelRuntime, setQaChannelRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,18 @@
// Qa Channel tests cover setup entry plugin behavior.
import { beforeAll, describe, expect, it } from "vitest";
import setupEntry from "./setup-entry.js";
describe("qa-channel setup entry", () => {
let setupPlugin: ReturnType<typeof setupEntry.loadSetupPlugin>;
beforeAll(() => {
setupPlugin = setupEntry.loadSetupPlugin();
});
it("loads the bundled setup plugin through the setup-entry contract", () => {
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupPlugin.id).toBe("qa-channel");
expect(setupPlugin.capabilities.chatTypes).toEqual(["direct", "group"]);
});
});

View File

@@ -0,0 +1,14 @@
// Qa Channel plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "qaChannelSetupPlugin",
},
runtime: {
specifier: "./api.js",
exportName: "setQaChannelRuntime",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader QA channel runtime and gateway surface.
export { qaChannelSetupPlugin } from "./src/channel.setup.js";

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;
};

View File

@@ -0,0 +1,3 @@
// Qa Channel API module exposes the plugin public contract.
export * from "./src/protocol.js";
export * from "./src/bus-client.js";

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}