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
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
// 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)}`);
|
|
},
|
|
},
|
|
});
|
|
}
|