2026-02-26 11:00:09 +01:00
# ! / u s r / b i n / e n v b u n
2026-03-01 12:35:38 +01:00
import { execFile } from "node:child_process" ;
2026-02-26 11:00:09 +01:00
// 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" ;
2026-03-01 12:35:38 +01:00
import { promisify } from "node:util" ;
2026-02-26 11:00:09 +01:00
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 ;
} ;
2026-03-01 12:35:38 +01:00
const execFileAsync = promisify ( execFile ) ;
type DriverMode = "token" | "webhook" | "openclaw" ;
2026-02-26 11:00:09 +01:00
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 ;
2026-03-01 12:35:38 +01:00
openclawBin : string ;
2026-02-26 11:00:09 +01:00
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 " +
2026-03-01 12:35:38 +01:00
"--channel <discord-channel-id> [--token <driver-token> | --driver webhook --bot-token <bot-token> | --driver openclaw] [options]\n\n" +
2026-02-26 11:00:09 +01:00
"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" +
2026-03-01 12:35:38 +01:00
" --driver <token|webhook|openclaw> Driver transport mode (default: token)\n" +
2026-02-26 11:00:09 +01:00
" --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" +
2026-03-01 12:35:38 +01:00
" --openclaw-bin <path> OpenClaw CLI binary for driver=openclaw (default: openclaw)\n" +
2026-02-26 11:00:09 +01:00
" --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" +
2026-03-01 12:35:38 +01:00
" OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH\n" +
" OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN"
2026-02-26 11:00:09 +01:00
) ;
}
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"
2026-03-01 12:35:38 +01:00
: normalizedDriverMode === "openclaw"
? "openclaw"
: normalizedDriverMode === "token"
? "token"
: "token" ;
2026-02-26 11:00:09 +01:00
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 ;
2026-03-01 12:35:38 +01:00
const openclawBin =
resolveArg ( "--openclaw-bin" ) || process . env . OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN || "openclaw" ;
2026-02-26 11:00:09 +01:00
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 ,
2026-03-01 12:35:38 +01:00
openclawBin ,
2026-02-26 11:00:09 +01:00
json ,
} ;
}
2026-03-01 12:35:38 +01:00
async function openclawCliJson < T > ( params : { openclawBin : string ; args : string [ ] } ) : Promise < T > {
const result = await execFileAsync ( params . openclawBin , params . args , {
maxBuffer : 8 * 1024 * 1024 ,
env : process.env ,
} ) ;
const stdout = ( result . stdout || "" ) . trim ( ) ;
if ( ! stdout ) {
throw new Error ( ` openclaw ${ params . args . join ( " " ) } returned empty stdout ` ) ;
}
return JSON . parse ( stdout ) as T ;
}
async function readMessagesWithOpenclaw ( params : {
openclawBin : string ;
target : string ;
limit : number ;
} ) : Promise < DiscordMessage [ ] > {
const response = await openclawCliJson < {
payload ? : {
messages? : DiscordMessage [ ] ;
} ;
} > ( {
openclawBin : params.openclawBin ,
args : [
"message" ,
"read" ,
"--channel" ,
"discord" ,
"--target" ,
params . target ,
"--limit" ,
String ( params . limit ) ,
"--json" ,
] ,
} ) ;
return Array . isArray ( response . payload ? . messages ) ? response . payload . messages : [ ] ;
}
2026-02-26 11:00:09 +01:00
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 > {
2026-03-02 08:51:27 +00:00
return requestDiscordJson < T > ( {
method : params.method ,
path : params.path ,
headers : {
Authorization : params.authHeader ,
"Content-Type" : "application/json" ,
} ,
body : params.body ,
retries : params.retries ,
errorPrefix : "Discord API" ,
} ) ;
2026-02-26 11:00:09 +01:00
}
async function discordWebhookApi < T > ( params : {
method : "POST" | "DELETE" ;
webhookId : string ;
webhookToken : string ;
body? : unknown ;
query? : string ;
retries? : number ;
} ) : Promise < T > {
const suffix = params . query ? ` ? ${ params . query } ` : "" ;
const path = ` /webhooks/ ${ encodeURIComponent ( params . webhookId ) } / ${ encodeURIComponent ( params . webhookToken ) } ${ suffix } ` ;
2026-03-02 08:51:27 +00:00
return requestDiscordJson < T > ( {
method : params.method ,
path ,
headers : {
"Content-Type" : "application/json" ,
} ,
body : params.body ,
retries : params.retries ,
errorPrefix : "Discord webhook API" ,
} ) ;
}
async function requestDiscordJson < T > ( params : {
method : string ;
path : string ;
headers : Record < string , string > ;
body? : unknown ;
retries? : number ;
errorPrefix : string ;
} ) : Promise < T > {
const retries = params . retries ? ? 6 ;
2026-02-26 11:00:09 +01:00
for ( let attempt = 0 ; attempt <= retries ; attempt += 1 ) {
2026-03-02 08:51:27 +00:00
const response = await fetch ( ` ${ DISCORD_API_BASE } ${ params . path } ` , {
2026-02-26 11:00:09 +01:00
method : params.method ,
2026-03-02 08:51:27 +00:00
headers : params.headers ,
2026-02-26 11:00:09 +01:00
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 (
2026-03-02 08:51:27 +00:00
` ${ params . errorPrefix } ${ params . method } ${ params . path } failed: ${ response . status } ${ response . statusText } ${ text ? ` :: ${ text } ` : "" } ` ,
2026-02-26 11:00:09 +01:00
) ;
}
if ( response . status === 204 ) {
return undefined as T ;
}
return ( await response . json ( ) ) as T ;
}
2026-03-02 08:51:27 +00:00
throw new Error ( ` ${ params . errorPrefix } ${ params . method } ${ params . path } exceeded retry budget. ` ) ;
2026-02-26 11:00:09 +01:00
}
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 ) ,
} ;
}
2026-03-02 08:51:27 +00:00
async function loadParentRecentMessages ( params : {
args : Args ;
readAuthHeader : string ;
} ) : Promise < DiscordMessage [ ] > {
if ( params . args . driverMode === "openclaw" ) {
return await readMessagesWithOpenclaw ( {
openclawBin : params.args.openclawBin ,
target : params.args.channelId ,
limit : 20 ,
} ) ;
}
return await discordApi < DiscordMessage [ ] > ( {
method : "GET" ,
path : ` /channels/ ${ encodeURIComponent ( params . args . channelId ) } /messages?limit=20 ` ,
authHeader : params.readAuthHeader ,
} ) ;
}
2026-02-26 11:00:09 +01:00
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 ;
2026-03-01 12:35:38 +01:00
} else if ( args . driverMode === "webhook" ) {
2026-02-26 11:00:09 +01:00
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 ;
2026-03-01 12:35:38 +01:00
} else {
setupStage = "send-message" ;
const sent = await openclawCliJson < {
payload ? : {
result ? : {
messageId? : string ;
} ;
} ;
} > ( {
openclawBin : args.openclawBin ,
args : [
"message" ,
"send" ,
"--channel" ,
"discord" ,
"--target" ,
args . channelId ,
"--message" ,
instruction ,
"--json" ,
] ,
} ) ;
sentMessageId = String ( sent . payload ? . result ? . messageId || "" ) ;
if ( ! sentMessageId ) {
throw new Error ( "openclaw message send did not return payload.result.messageId" ) ;
}
2026-02-26 11:00:09 +01:00
}
} 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 {
2026-03-02 08:51:27 +00:00
parentRecent = await loadParentRecentMessages ( { args , readAuthHeader } ) ;
2026-02-26 11:00:09 +01:00
} 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 {
2026-03-01 12:35:38 +01:00
const threadMessages =
args . driverMode === "openclaw"
? await readMessagesWithOpenclaw ( {
openclawBin : args.openclawBin ,
target : threadId ,
limit : 50 ,
} )
: await discordApi < DiscordMessage [ ] > ( {
method : "GET" ,
path : ` /channels/ ${ encodeURIComponent ( threadId ) } /messages?limit=50 ` ,
authHeader : readAuthHeader ,
} ) ;
2026-02-26 11:00:09 +01:00
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 {
2026-03-02 08:51:27 +00:00
parentRecent = await loadParentRecentMessages ( { args , readAuthHeader } ) ;
2026-02-26 11:00:09 +01:00
} 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 ) ;