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
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:
66
extensions/qa-channel/src/accounts.ts
Normal file
66
extensions/qa-channel/src/accounts.ts
Normal 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";
|
||||
154
extensions/qa-channel/src/bus-client.test.ts
Normal file
154
extensions/qa-channel/src/bus-client.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
312
extensions/qa-channel/src/bus-client.ts
Normal file
312
extensions/qa-channel/src/bus-client.ts
Normal 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;
|
||||
}
|
||||
251
extensions/qa-channel/src/channel-actions.ts
Normal file
251
extensions/qa-channel/src/channel-actions.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
62
extensions/qa-channel/src/channel-base.ts
Normal file
62
extensions/qa-channel/src/channel-base.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
7
extensions/qa-channel/src/channel.setup.ts
Normal file
7
extensions/qa-channel/src/channel.setup.ts
Normal 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();
|
||||
712
extensions/qa-channel/src/channel.test.ts
Normal file
712
extensions/qa-channel/src/channel.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
143
extensions/qa-channel/src/channel.ts
Normal file
143
extensions/qa-channel/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
47
extensions/qa-channel/src/config-schema.ts
Normal file
47
extensions/qa-channel/src/config-schema.ts
Normal 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);
|
||||
228
extensions/qa-channel/src/gateway.test.ts
Normal file
228
extensions/qa-channel/src/gateway.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
extensions/qa-channel/src/gateway.ts
Normal file
90
extensions/qa-channel/src/gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
416
extensions/qa-channel/src/inbound.test.ts
Normal file
416
extensions/qa-channel/src/inbound.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
414
extensions/qa-channel/src/inbound.ts
Normal file
414
extensions/qa-channel/src/inbound.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
35
extensions/qa-channel/src/outbound.ts
Normal file
35
extensions/qa-channel/src/outbound.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
extensions/qa-channel/src/protocol.ts
Normal file
2
extensions/qa-channel/src/protocol.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Qa Channel plugin module implements protocol behavior.
|
||||
export type * from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
24
extensions/qa-channel/src/runtime-api.ts
Normal file
24
extensions/qa-channel/src/runtime-api.ts
Normal 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";
|
||||
11
extensions/qa-channel/src/runtime.ts
Normal file
11
extensions/qa-channel/src/runtime.ts
Normal 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 };
|
||||
39
extensions/qa-channel/src/setup.ts
Normal file
39
extensions/qa-channel/src/setup.ts
Normal 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;
|
||||
}
|
||||
24
extensions/qa-channel/src/status.ts
Normal file
24
extensions/qa-channel/src/status.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
55
extensions/qa-channel/src/types.ts
Normal file
55
extensions/qa-channel/src/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user