mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc * docs: expand ACP implementation specification * feat(acp): route ACP sessions through core dispatch and lifecycle cleanup * feat(acp): add /acp commands and Discord spawn gate * ACP: add acpx runtime plugin backend * fix(subagents): defer transient lifecycle errors before announce * Agents: harden ACP sessions_spawn and tighten spawn guidance * Agents: require explicit ACP target for runtime spawns * docs: expand ACP control-plane implementation plan * ACP: harden metadata seeding and spawn guidance * ACP: centralize runtime control-plane manager and fail-closed dispatch * ACP: harden runtime manager and unify spawn helpers * Commands: route ACP sessions through ACP runtime in agent command * ACP: require persisted metadata for runtime spawns * Sessions: preserve ACP metadata when updating entries * Plugins: harden ACP backend registry across loaders * ACPX: make availability probe compatible with adapters * E2E: add manual Discord ACP plain-language smoke script * ACPX: preserve streamed spacing across Discord delivery * Docs: add ACP Discord streaming strategy * ACP: harden Discord stream buffering for thread replies * ACP: reuse shared block reply pipeline for projector * ACP: unify streaming config and adopt coalesceIdleMs * Docs: add temporary ACP production hardening plan * Docs: trim temporary ACP hardening plan goals * Docs: gate ACP thread controls by backend capabilities * ACP: add capability-gated runtime controls and /acp operator commands * Docs: remove temporary ACP hardening plan * ACP: fix spawn target validation and close cache cleanup * ACP: harden runtime dispatch and recovery paths * ACP: split ACP command/runtime internals and centralize policy * ACP: harden runtime lifecycle, validation, and observability * ACP: surface runtime and backend session IDs in thread bindings * docs: add temp plan for binding-service migration * ACP: migrate thread binding flows to SessionBindingService * ACP: address review feedback and preserve prompt wording * ACPX plugin: pin runtime dependency and prefer bundled CLI * Discord: complete binding-service migration cleanup and restore ACP plan * Docs: add standalone ACP agents guide * ACP: route harness intents to thread-bound ACP sessions * ACP: fix spawn thread routing and queue-owner stall * ACP: harden startup reconciliation and command bypass handling * ACP: fix dispatch bypass type narrowing * ACP: align runtime metadata to agentSessionId * ACP: normalize session identifier handling and labels * ACP: mark thread banner session ids provisional until first reply * ACP: stabilize session identity mapping and startup reconciliation * ACP: add resolved session-id notices and cwd in thread intros * Discord: prefix thread meta notices consistently * Discord: unify ACP/thread meta notices with gear prefix * Discord: split thread persona naming from meta formatting * Extensions: bump acpx plugin dependency to 0.1.9 * Agents: gate ACP prompt guidance behind acp.enabled * Docs: remove temp experiment plan docs * Docs: scope streaming plan to holy grail refactor * Docs: refactor ACP agents guide for human-first flow * Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow * Docs/Skill: add OpenCode and Pi to ACP harness lists * Docs/Skill: align ACP harness list with current acpx registry * Dev/Test: move ACP plain-language smoke script and mark as keep * Docs/Skill: reorder ACP harness lists with Pi first * ACP: split control-plane manager into core/types/utils modules * Docs: refresh ACP thread-bound agents plan * ACP: extract dispatch lane and split manager domains * ACP: centralize binding context and remove reverse deps * Infra: unify system message formatting * ACP: centralize error boundaries and session id rendering * ACP: enforce init concurrency cap and strict meta clear * Tests: fix ACP dispatch binding mock typing * Tests: fix Discord thread-binding mock drift and ACP request id * ACP: gate slash bypass and persist cleared overrides * ACPX: await pre-abort cancel before runTurn return * Extension: pin acpx runtime dependency to 0.1.11 * Docs: add pinned acpx install strategy for ACP extension * Extensions/acpx: enforce strict local pinned startup * Extensions/acpx: tighten acp-router install guidance * ACPX: retry runtime test temp-dir cleanup * Extensions/acpx: require proactive ACPX repair for thread spawns * Extensions/acpx: require restart offer after acpx reinstall * extensions/acpx: remove workspace protocol devDependency * extensions/acpx: bump pinned acpx to 0.1.13 * extensions/acpx: sync lockfile after dependency bump * ACPX: make runtime spawn Windows-safe * fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
405
scripts/check-channel-agnostic-boundaries.mjs
Normal file
405
scripts/check-channel-agnostic-boundaries.mjs
Normal file
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const acpCoreProtectedSources = [
|
||||
path.join(repoRoot, "src", "acp"),
|
||||
path.join(repoRoot, "src", "agents", "acp-spawn.ts"),
|
||||
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
||||
path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"),
|
||||
];
|
||||
|
||||
const channelCoreProtectedSources = [
|
||||
path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"),
|
||||
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
|
||||
];
|
||||
const acpUserFacingTextSources = [
|
||||
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
||||
];
|
||||
const systemMarkLiteralGuardSources = [
|
||||
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
||||
path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"),
|
||||
path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"),
|
||||
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
|
||||
];
|
||||
|
||||
const channelIds = [
|
||||
"bluebubbles",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"imessage",
|
||||
"irc",
|
||||
"line",
|
||||
"matrix",
|
||||
"msteams",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"web",
|
||||
"whatsapp",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
];
|
||||
|
||||
const channelIdSet = new Set(channelIds);
|
||||
const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`);
|
||||
const comparisonOperators = new Set([
|
||||
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsToken,
|
||||
]);
|
||||
|
||||
const allowedViolations = new Set([]);
|
||||
|
||||
function isTestLikeFile(filePath) {
|
||||
return (
|
||||
filePath.endsWith(".test.ts") ||
|
||||
filePath.endsWith(".test-utils.ts") ||
|
||||
filePath.endsWith(".test-harness.ts") ||
|
||||
filePath.endsWith(".e2e-harness.ts")
|
||||
);
|
||||
}
|
||||
|
||||
async function collectTypeScriptFiles(targetPath) {
|
||||
const stat = await fs.stat(targetPath);
|
||||
if (stat.isFile()) {
|
||||
if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) {
|
||||
return [];
|
||||
}
|
||||
return [targetPath];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(targetPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectTypeScriptFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!entryPath.endsWith(".ts")) {
|
||||
continue;
|
||||
}
|
||||
if (isTestLikeFile(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
files.push(entryPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function toLine(sourceFile, node) {
|
||||
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
}
|
||||
|
||||
function isChannelsPropertyAccess(node) {
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
return node.name.text === "channels";
|
||||
}
|
||||
if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) {
|
||||
return node.argumentExpression.text === "channels";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readStringLiteral(node) {
|
||||
if (ts.isStringLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isChannelLiteralNode(node) {
|
||||
const text = readStringLiteral(node);
|
||||
return text ? channelIdSet.has(text) : false;
|
||||
}
|
||||
|
||||
function matchesChannelModuleSpecifier(specifier) {
|
||||
return channelSegmentRe.test(specifier.replaceAll("\\", "/"));
|
||||
}
|
||||
|
||||
function getPropertyNameText(name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const userFacingChannelNameRe =
|
||||
/\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i;
|
||||
const systemMarkLiteral = "⚙️";
|
||||
|
||||
function isModuleSpecifierStringNode(node) {
|
||||
const parent = node.parent;
|
||||
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
ts.isCallExpression(parent) &&
|
||||
parent.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
parent.arguments[0] === node
|
||||
);
|
||||
}
|
||||
|
||||
export function findChannelAgnosticBoundaryViolations(
|
||||
content,
|
||||
fileName = "source.ts",
|
||||
options = {},
|
||||
) {
|
||||
const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true;
|
||||
const checkConfigPaths = options.checkConfigPaths ?? true;
|
||||
const checkChannelComparisons = options.checkChannelComparisons ?? true;
|
||||
const checkChannelAssignments = options.checkChannelAssignments ?? true;
|
||||
const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier;
|
||||
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const violations = [];
|
||||
|
||||
const visit = (node) => {
|
||||
if (
|
||||
checkModuleSpecifiers &&
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
if (moduleSpecifierMatcher(specifier)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
reason: `imports channel module "${specifier}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
checkModuleSpecifiers &&
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
if (moduleSpecifierMatcher(specifier)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
reason: `re-exports channel module "${specifier}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
checkModuleSpecifiers &&
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
const specifier = node.arguments[0].text;
|
||||
if (moduleSpecifierMatcher(specifier)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.arguments[0]),
|
||||
reason: `dynamically imports channel module "${specifier}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
checkConfigPaths &&
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
channelIdSet.has(node.name.text)
|
||||
) {
|
||||
if (isChannelsPropertyAccess(node.expression)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.name),
|
||||
reason: `references config path "channels.${node.name.text}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
checkConfigPaths &&
|
||||
ts.isElementAccessExpression(node) &&
|
||||
ts.isStringLiteral(node.argumentExpression) &&
|
||||
channelIdSet.has(node.argumentExpression.text)
|
||||
) {
|
||||
if (isChannelsPropertyAccess(node.expression)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.argumentExpression),
|
||||
reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
checkChannelComparisons &&
|
||||
ts.isBinaryExpression(node) &&
|
||||
comparisonOperators.has(node.operatorToken.kind)
|
||||
) {
|
||||
if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) {
|
||||
const leftText = node.left.getText(sourceFile);
|
||||
const rightText = node.right.getText(sourceFile);
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.operatorToken),
|
||||
reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (checkChannelAssignments && ts.isPropertyAssignment(node)) {
|
||||
const propName = getPropertyNameText(node.name);
|
||||
if (propName === "channel" && isChannelLiteralNode(node.initializer)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.initializer),
|
||||
reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function findChannelCoreReverseDependencyViolations(content, fileName = "source.ts") {
|
||||
return findChannelAgnosticBoundaryViolations(content, fileName, {
|
||||
checkModuleSpecifiers: true,
|
||||
checkConfigPaths: false,
|
||||
checkChannelComparisons: false,
|
||||
checkChannelAssignments: false,
|
||||
moduleSpecifierMatcher: matchesChannelModuleSpecifier,
|
||||
});
|
||||
}
|
||||
|
||||
export function findAcpUserFacingChannelNameViolations(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const violations = [];
|
||||
|
||||
const visit = (node) => {
|
||||
const text = readStringLiteral(node);
|
||||
if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `user-facing text references channel name (${JSON.stringify(text)})`,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function findSystemMarkLiteralViolations(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const violations = [];
|
||||
|
||||
const visit = (node) => {
|
||||
const text = readStringLiteral(node);
|
||||
if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `hardcoded system mark literal (${JSON.stringify(text)})`,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
const boundaryRuleSets = [
|
||||
{
|
||||
id: "acp-core",
|
||||
sources: acpCoreProtectedSources,
|
||||
scan: (content, fileName) => findChannelAgnosticBoundaryViolations(content, fileName),
|
||||
},
|
||||
{
|
||||
id: "channel-core-reverse-deps",
|
||||
sources: channelCoreProtectedSources,
|
||||
scan: (content, fileName) => findChannelCoreReverseDependencyViolations(content, fileName),
|
||||
},
|
||||
{
|
||||
id: "acp-user-facing-text",
|
||||
sources: acpUserFacingTextSources,
|
||||
scan: (content, fileName) => findAcpUserFacingChannelNameViolations(content, fileName),
|
||||
},
|
||||
{
|
||||
id: "system-mark-literal-usage",
|
||||
sources: systemMarkLiteralGuardSources,
|
||||
scan: (content, fileName) => findSystemMarkLiteralViolations(content, fileName),
|
||||
},
|
||||
];
|
||||
|
||||
export async function main() {
|
||||
const violations = [];
|
||||
for (const ruleSet of boundaryRuleSets) {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
ruleSet.sources.map(async (sourcePath) => {
|
||||
try {
|
||||
return await collectTypeScriptFiles(sourcePath);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
for (const filePath of files) {
|
||||
const relativeFile = path.relative(repoRoot, filePath);
|
||||
if (
|
||||
allowedViolations.has(`${ruleSet.id}:${relativeFile}`) ||
|
||||
allowedViolations.has(relativeFile)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
for (const violation of ruleSet.scan(content, relativeFile)) {
|
||||
violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Found channel-specific references in channel-agnostic sources:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation}`);
|
||||
}
|
||||
console.error(
|
||||
"Move channel-specific logic to channel adapters or add a justified allowlist entry.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isDirectExecution = (() => {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
||||
})();
|
||||
|
||||
if (isDirectExecution) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
779
scripts/dev/discord-acp-plain-language-smoke.ts
Normal file
779
scripts/dev/discord-acp-plain-language-smoke.ts
Normal file
@@ -0,0 +1,779 @@
|
||||
#!/usr/bin/env bun
|
||||
// Manual ACP thread smoke for plain-language routing.
|
||||
// Keep this script available for regression/debug validation. Do not delete.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type ThreadBindingRecord = {
|
||||
accountId?: string;
|
||||
channelId?: string;
|
||||
threadId?: string;
|
||||
targetKind?: string;
|
||||
targetSessionKey?: string;
|
||||
agentId?: string;
|
||||
boundBy?: string;
|
||||
boundAt?: number;
|
||||
};
|
||||
|
||||
type ThreadBindingsPayload = {
|
||||
version?: number;
|
||||
bindings?: Record<string, ThreadBindingRecord>;
|
||||
};
|
||||
|
||||
type DiscordMessage = {
|
||||
id: string;
|
||||
content?: string;
|
||||
timestamp?: string;
|
||||
author?: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type DiscordUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
|
||||
type DriverMode = "token" | "webhook";
|
||||
|
||||
type Args = {
|
||||
channelId: string;
|
||||
driverMode: DriverMode;
|
||||
driverToken: string;
|
||||
driverTokenPrefix: string;
|
||||
botToken: string;
|
||||
botTokenPrefix: string;
|
||||
targetAgent: string;
|
||||
timeoutMs: number;
|
||||
pollMs: number;
|
||||
mentionUserId?: string;
|
||||
instruction?: string;
|
||||
threadBindingsPath: string;
|
||||
json: boolean;
|
||||
};
|
||||
|
||||
type SuccessResult = {
|
||||
ok: true;
|
||||
smokeId: string;
|
||||
ackToken: string;
|
||||
sentMessageId: string;
|
||||
binding: {
|
||||
threadId: string;
|
||||
targetSessionKey: string;
|
||||
targetKind: string;
|
||||
agentId: string;
|
||||
boundAt: number;
|
||||
accountId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
ackMessage: {
|
||||
id: string;
|
||||
authorId?: string;
|
||||
authorUsername?: string;
|
||||
timestamp?: string;
|
||||
content?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type FailureResult = {
|
||||
ok: false;
|
||||
smokeId: string;
|
||||
stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected";
|
||||
error: string;
|
||||
diagnostics?: {
|
||||
parentChannelRecent?: Array<{
|
||||
id: string;
|
||||
author?: string;
|
||||
bot?: boolean;
|
||||
content?: string;
|
||||
}>;
|
||||
bindingCandidates?: Array<{
|
||||
threadId: string;
|
||||
targetSessionKey: string;
|
||||
targetKind?: string;
|
||||
agentId?: string;
|
||||
boundAt?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveStateDir(): string {
|
||||
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override.startsWith("~")
|
||||
? path.resolve(process.env.HOME || "", override.slice(1))
|
||||
: path.resolve(override);
|
||||
}
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || "";
|
||||
return path.join(home, ".openclaw");
|
||||
}
|
||||
|
||||
function resolveArg(flag: string): string | undefined {
|
||||
const argv = process.argv.slice(2);
|
||||
const eq = argv.find((entry) => entry.startsWith(`${flag}=`));
|
||||
if (eq) {
|
||||
return eq.slice(flag.length + 1);
|
||||
}
|
||||
const idx = argv.indexOf(flag);
|
||||
if (idx >= 0 && idx + 1 < argv.length) {
|
||||
return argv[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasFlag(flag: string): boolean {
|
||||
return process.argv.slice(2).includes(flag);
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return (
|
||||
"Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " +
|
||||
"--channel <discord-channel-id> [--token <driver-token> | --driver webhook --bot-token <bot-token>] [options]\n\n" +
|
||||
"Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" +
|
||||
"1) OpenClaw spawned an ACP thread binding\n" +
|
||||
"2) agent replied in that bound thread with the expected ACK token\n\n" +
|
||||
"Options:\n" +
|
||||
" --channel <id> Parent Discord channel id (required)\n" +
|
||||
" --driver <token|webhook> Driver transport mode (default: token)\n" +
|
||||
" --token <token> Driver Discord token (required for driver=token)\n" +
|
||||
" --token-prefix <prefix> Auth prefix for --token (default: Bot)\n" +
|
||||
" --bot-token <token> Bot token for webhook driver mode\n" +
|
||||
" --bot-token-prefix <prefix> Auth prefix for --bot-token (default: Bot)\n" +
|
||||
" --agent <id> Expected ACP agent id (default: codex)\n" +
|
||||
" --mention <user-id> Mention this user in the instruction (optional)\n" +
|
||||
" --instruction <text> Custom instruction template (optional)\n" +
|
||||
" --timeout-ms <n> Total timeout in ms (default: 240000)\n" +
|
||||
" --poll-ms <n> Poll interval in ms (default: 1500)\n" +
|
||||
" --thread-bindings-path <p> Override thread-bindings json path\n" +
|
||||
" --json Emit JSON output\n" +
|
||||
"\n" +
|
||||
"Environment fallbacks:\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_DRIVER\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_AGENT\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_POLL_MS\n" +
|
||||
" OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH"
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const channelId =
|
||||
resolveArg("--channel") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_CHANNEL_ID ||
|
||||
"";
|
||||
const driverModeRaw =
|
||||
resolveArg("--driver") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER ||
|
||||
"token";
|
||||
const normalizedDriverMode = driverModeRaw.trim().toLowerCase();
|
||||
const driverMode: DriverMode =
|
||||
normalizedDriverMode === "webhook"
|
||||
? "webhook"
|
||||
: normalizedDriverMode === "token"
|
||||
? "token"
|
||||
: "token";
|
||||
const driverToken =
|
||||
resolveArg("--token") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER_TOKEN ||
|
||||
"";
|
||||
const driverTokenPrefix =
|
||||
resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot";
|
||||
const botToken =
|
||||
resolveArg("--bot-token") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_BOT_TOKEN ||
|
||||
process.env.DISCORD_BOT_TOKEN ||
|
||||
"";
|
||||
const botTokenPrefix =
|
||||
resolveArg("--bot-token-prefix") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX ||
|
||||
"Bot";
|
||||
const targetAgent =
|
||||
resolveArg("--agent") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_AGENT ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_AGENT ||
|
||||
"codex";
|
||||
const mentionUserId =
|
||||
resolveArg("--mention") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_MENTION_USER_ID ||
|
||||
undefined;
|
||||
const instruction =
|
||||
resolveArg("--instruction") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION ||
|
||||
process.env.CLAWDBOT_DISCORD_SMOKE_INSTRUCTION ||
|
||||
undefined;
|
||||
const timeoutMs = parseNumber(
|
||||
resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS,
|
||||
240_000,
|
||||
);
|
||||
const pollMs = parseNumber(
|
||||
resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS,
|
||||
1_500,
|
||||
);
|
||||
const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json");
|
||||
const threadBindingsPath =
|
||||
resolveArg("--thread-bindings-path") ||
|
||||
process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH ||
|
||||
defaultBindingsPath;
|
||||
const json = hasFlag("--json");
|
||||
|
||||
if (!channelId) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (driverMode === "token" && !driverToken) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (driverMode === "webhook" && !botToken) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
driverMode,
|
||||
driverToken,
|
||||
driverTokenPrefix,
|
||||
botToken,
|
||||
botTokenPrefix,
|
||||
targetAgent,
|
||||
timeoutMs,
|
||||
pollMs,
|
||||
mentionUserId,
|
||||
instruction,
|
||||
threadBindingsPath,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string {
|
||||
const token = params.token.trim();
|
||||
if (!token) {
|
||||
throw new Error("Missing Discord driver token.");
|
||||
}
|
||||
if (token.includes(" ")) {
|
||||
return token;
|
||||
}
|
||||
return `${params.tokenPrefix.trim() || "Bot"} ${token}`;
|
||||
}
|
||||
|
||||
async function discordApi<T>(params: {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
authHeader: string;
|
||||
body?: unknown;
|
||||
retries?: number;
|
||||
}): Promise<T> {
|
||||
const retries = params.retries ?? 6;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const response = await fetch(`${DISCORD_API_BASE}${params.path}`, {
|
||||
method: params.method,
|
||||
headers: {
|
||||
Authorization: params.authHeader,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body === undefined ? undefined : JSON.stringify(params.body),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
|
||||
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
|
||||
await sleep(Math.ceil(waitSeconds * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`);
|
||||
}
|
||||
|
||||
async function discordWebhookApi<T>(params: {
|
||||
method: "POST" | "DELETE";
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
body?: unknown;
|
||||
query?: string;
|
||||
retries?: number;
|
||||
}): Promise<T> {
|
||||
const retries = params.retries ?? 6;
|
||||
const suffix = params.query ? `?${params.query}` : "";
|
||||
const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
|
||||
method: params.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body === undefined ? undefined : JSON.stringify(params.body),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
|
||||
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
|
||||
await sleep(Math.ceil(waitSeconds * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`);
|
||||
}
|
||||
|
||||
async function readThreadBindings(filePath: string): Promise<ThreadBindingRecord[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const payload = JSON.parse(raw) as ThreadBindingsPayload;
|
||||
const entries = Object.values(payload.bindings ?? {});
|
||||
return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey));
|
||||
}
|
||||
|
||||
function normalizeBoundAt(record: ThreadBindingRecord): number {
|
||||
if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) {
|
||||
return record.boundAt;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveCandidateBindings(params: {
|
||||
entries: ThreadBindingRecord[];
|
||||
minBoundAt: number;
|
||||
targetAgent: string;
|
||||
}): ThreadBindingRecord[] {
|
||||
const normalizedTargetAgent = params.targetAgent.trim().toLowerCase();
|
||||
return params.entries
|
||||
.filter((entry) => {
|
||||
const targetKind = String(entry.targetKind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (targetKind !== "acp") {
|
||||
return false;
|
||||
}
|
||||
if (normalizeBoundAt(entry) < params.minBoundAt) {
|
||||
return false;
|
||||
}
|
||||
const agentId = String(entry.agentId || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a));
|
||||
}
|
||||
|
||||
function buildInstruction(params: {
|
||||
smokeId: string;
|
||||
ackToken: string;
|
||||
targetAgent: string;
|
||||
mentionUserId?: string;
|
||||
template?: string;
|
||||
}): string {
|
||||
const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : "";
|
||||
if (params.template?.trim()) {
|
||||
return mentionPrefix + params.template.trim();
|
||||
}
|
||||
return (
|
||||
mentionPrefix +
|
||||
`Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.`
|
||||
);
|
||||
}
|
||||
|
||||
function toRecentMessageRow(message: DiscordMessage) {
|
||||
return {
|
||||
id: message.id,
|
||||
author: message.author?.username || message.author?.id || "unknown",
|
||||
bot: Boolean(message.author?.bot),
|
||||
content: (message.content || "").slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) {
|
||||
if (params.json) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(params.payload, null, 2));
|
||||
return;
|
||||
}
|
||||
if (params.payload.ok) {
|
||||
const success = params.payload;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("PASS");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`smokeId: ${success.smokeId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`sentMessageId: ${success.sentMessageId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`threadId: ${success.binding.threadId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`sessionKey: ${success.binding.targetSessionKey}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`ackMessageId: ${success.ackMessage.id}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const failure = params.payload;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("FAIL");
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`stage: ${failure.stage}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`smokeId: ${failure.smokeId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`error: ${failure.error}`);
|
||||
if (failure.diagnostics?.bindingCandidates?.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("binding candidates:");
|
||||
for (const candidate of failure.diagnostics.bindingCandidates) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failure.diagnostics?.parentChannelRecent?.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("recent parent channel messages:");
|
||||
for (const row of failure.diagnostics.parentChannelRecent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<SuccessResult | FailureResult> {
|
||||
let args: Args;
|
||||
try {
|
||||
args = parseArgs();
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "validation",
|
||||
smokeId: "n/a",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const ackToken = `ACP_SMOKE_ACK_${smokeId}`;
|
||||
const instruction = buildInstruction({
|
||||
smokeId,
|
||||
ackToken,
|
||||
targetAgent: args.targetAgent,
|
||||
mentionUserId: args.mentionUserId,
|
||||
template: args.instruction,
|
||||
});
|
||||
|
||||
let readAuthHeader = "";
|
||||
let sentMessageId = "";
|
||||
let setupStage: "discord-api" | "send-message" = "discord-api";
|
||||
let senderAuthorId: string | undefined;
|
||||
let webhookForCleanup:
|
||||
| {
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
if (args.driverMode === "token") {
|
||||
const authHeader = resolveAuthorizationHeader({
|
||||
token: args.driverToken,
|
||||
tokenPrefix: args.driverTokenPrefix,
|
||||
});
|
||||
readAuthHeader = authHeader;
|
||||
|
||||
const driverUser = await discordApi<DiscordUser>({
|
||||
method: "GET",
|
||||
path: "/users/@me",
|
||||
authHeader,
|
||||
});
|
||||
senderAuthorId = driverUser.id;
|
||||
|
||||
setupStage = "send-message";
|
||||
const sent = await discordApi<DiscordMessage>({
|
||||
method: "POST",
|
||||
path: `/channels/${encodeURIComponent(args.channelId)}/messages`,
|
||||
authHeader,
|
||||
body: {
|
||||
content: instruction,
|
||||
allowed_mentions: args.mentionUserId
|
||||
? { parse: [], users: [args.mentionUserId] }
|
||||
: { parse: [] },
|
||||
},
|
||||
});
|
||||
sentMessageId = sent.id;
|
||||
} else {
|
||||
const botAuthHeader = resolveAuthorizationHeader({
|
||||
token: args.botToken,
|
||||
tokenPrefix: args.botTokenPrefix,
|
||||
});
|
||||
readAuthHeader = botAuthHeader;
|
||||
|
||||
await discordApi<DiscordUser>({
|
||||
method: "GET",
|
||||
path: "/users/@me",
|
||||
authHeader: botAuthHeader,
|
||||
});
|
||||
|
||||
setupStage = "send-message";
|
||||
const webhook = await discordApi<{ id: string; token?: string | null }>({
|
||||
method: "POST",
|
||||
path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`,
|
||||
authHeader: botAuthHeader,
|
||||
body: {
|
||||
name: `openclaw-acp-smoke-${smokeId.slice(-8)}`,
|
||||
},
|
||||
});
|
||||
if (!webhook.id || !webhook.token) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "send-message",
|
||||
smokeId,
|
||||
error:
|
||||
"Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.",
|
||||
};
|
||||
}
|
||||
webhookForCleanup = { id: webhook.id, token: webhook.token };
|
||||
|
||||
const sent = await discordWebhookApi<DiscordMessage>({
|
||||
method: "POST",
|
||||
webhookId: webhook.id,
|
||||
webhookToken: webhook.token,
|
||||
query: "wait=true",
|
||||
body: {
|
||||
content: instruction,
|
||||
allowed_mentions: args.mentionUserId
|
||||
? { parse: [], users: [args.mentionUserId] }
|
||||
: { parse: [] },
|
||||
},
|
||||
});
|
||||
sentMessageId = sent.id;
|
||||
senderAuthorId = sent.author?.id;
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: setupStage,
|
||||
smokeId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
const deadline = startedAt + args.timeoutMs;
|
||||
let winningBinding: ThreadBindingRecord | undefined;
|
||||
let latestCandidates: ThreadBindingRecord[] = [];
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline && !winningBinding) {
|
||||
try {
|
||||
const entries = await readThreadBindings(args.threadBindingsPath);
|
||||
latestCandidates = resolveCandidateBindings({
|
||||
entries,
|
||||
minBoundAt: startedAt - 3_000,
|
||||
targetAgent: args.targetAgent,
|
||||
});
|
||||
winningBinding = latestCandidates[0];
|
||||
} catch {
|
||||
// Keep polling; file may not exist yet or may be mid-write.
|
||||
}
|
||||
if (!winningBinding) {
|
||||
await sleep(args.pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) {
|
||||
let parentRecent: DiscordMessage[] = [];
|
||||
try {
|
||||
parentRecent = await discordApi<DiscordMessage[]>({
|
||||
method: "GET",
|
||||
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
|
||||
authHeader: readAuthHeader,
|
||||
});
|
||||
} catch {
|
||||
// Best effort diagnostics only.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
stage: "wait-binding",
|
||||
smokeId,
|
||||
error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`,
|
||||
diagnostics: {
|
||||
bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({
|
||||
threadId: entry.threadId || "",
|
||||
targetSessionKey: entry.targetSessionKey || "",
|
||||
targetKind: entry.targetKind,
|
||||
agentId: entry.agentId,
|
||||
boundAt: entry.boundAt,
|
||||
})),
|
||||
parentChannelRecent: parentRecent.map(toRecentMessageRow),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const threadId = winningBinding.threadId;
|
||||
let ackMessage: DiscordMessage | undefined;
|
||||
while (Date.now() < deadline && !ackMessage) {
|
||||
try {
|
||||
const threadMessages = await discordApi<DiscordMessage[]>({
|
||||
method: "GET",
|
||||
path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`,
|
||||
authHeader: readAuthHeader,
|
||||
});
|
||||
ackMessage = threadMessages.find((message) => {
|
||||
const content = message.content || "";
|
||||
if (!content.includes(ackToken)) {
|
||||
return false;
|
||||
}
|
||||
const authorId = message.author?.id || "";
|
||||
return !senderAuthorId || authorId !== senderAuthorId;
|
||||
});
|
||||
} catch {
|
||||
// Keep polling; thread can appear before read permissions settle.
|
||||
}
|
||||
if (!ackMessage) {
|
||||
await sleep(args.pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ackMessage) {
|
||||
let parentRecent: DiscordMessage[] = [];
|
||||
try {
|
||||
parentRecent = await discordApi<DiscordMessage[]>({
|
||||
method: "GET",
|
||||
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
|
||||
authHeader: readAuthHeader,
|
||||
});
|
||||
} catch {
|
||||
// Best effort diagnostics only.
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage: "wait-ack",
|
||||
smokeId,
|
||||
error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`,
|
||||
diagnostics: {
|
||||
bindingCandidates: [
|
||||
{
|
||||
threadId: winningBinding.threadId || "",
|
||||
targetSessionKey: winningBinding.targetSessionKey || "",
|
||||
targetKind: winningBinding.targetKind,
|
||||
agentId: winningBinding.agentId,
|
||||
boundAt: winningBinding.boundAt,
|
||||
},
|
||||
],
|
||||
parentChannelRecent: parentRecent.map(toRecentMessageRow),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
smokeId,
|
||||
ackToken,
|
||||
sentMessageId,
|
||||
binding: {
|
||||
threadId,
|
||||
targetSessionKey: winningBinding.targetSessionKey,
|
||||
targetKind: String(winningBinding.targetKind || "acp"),
|
||||
agentId: String(winningBinding.agentId || args.targetAgent),
|
||||
boundAt: normalizeBoundAt(winningBinding),
|
||||
accountId: winningBinding.accountId,
|
||||
channelId: winningBinding.channelId,
|
||||
},
|
||||
ackMessage: {
|
||||
id: ackMessage.id,
|
||||
authorId: ackMessage.author?.id,
|
||||
authorUsername: ackMessage.author?.username,
|
||||
timestamp: ackMessage.timestamp,
|
||||
content: ackMessage.content,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (webhookForCleanup) {
|
||||
await discordWebhookApi<void>({
|
||||
method: "DELETE",
|
||||
webhookId: webhookForCleanup.id,
|
||||
webhookToken: webhookForCleanup.token,
|
||||
}).catch(() => {
|
||||
// Best-effort cleanup only.
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFlag("--help") || hasFlag("-h")) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = await run().catch(
|
||||
(err): FailureResult => ({
|
||||
ok: false,
|
||||
stage: "unexpected",
|
||||
smokeId: "n/a",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
|
||||
printOutput({
|
||||
json: hasFlag("--json"),
|
||||
payload: result,
|
||||
});
|
||||
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
Reference in New Issue
Block a user