mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 10:11:36 +00:00
478 lines
13 KiB
TypeScript
478 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import type { GatewayHandle, LaneState } from "./config.ts";
|
|
import {
|
|
CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS,
|
|
CROSS_OS_DISCORD_FETCH_TIMEOUT_MS,
|
|
CROSS_OS_FETCH_BODY_MAX_CHARS,
|
|
shouldUseManagedGatewayService,
|
|
} from "./config.ts";
|
|
import {
|
|
ensureManagedGatewayReady,
|
|
runInstalledCli,
|
|
startManualGatewayFromInstalledCli,
|
|
waitForInstalledGateway,
|
|
} from "./installed.ts";
|
|
import { stopGateway } from "./process.ts";
|
|
import { formatError, sleep } from "./shared.ts";
|
|
|
|
export function buildCrossOsDiscordRoundtripNonces() {
|
|
return {
|
|
outboundNonce: `native-cross-os-outbound-${randomUUID()}`,
|
|
inboundNonce: `native-cross-os-inbound-${randomUUID()}`,
|
|
};
|
|
}
|
|
|
|
export function buildDiscordSmokeGuildsConfig(guildId: string, channelId: string) {
|
|
return {
|
|
[guildId]: {
|
|
channels: {
|
|
[channelId]: {
|
|
enabled: true,
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function configureDiscordSmoke(params: {
|
|
lane: LaneState;
|
|
cliPath: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
gatewayHolder?: { current: GatewayHandle | null };
|
|
logPath: string;
|
|
token: string;
|
|
guildId: string;
|
|
channelId: string;
|
|
}) {
|
|
const guildsJson = JSON.stringify(
|
|
buildDiscordSmokeGuildsConfig(params.guildId, params.channelId),
|
|
);
|
|
await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: [
|
|
"config",
|
|
"set",
|
|
"channels.discord.token",
|
|
"--ref-provider",
|
|
"default",
|
|
"--ref-source",
|
|
"env",
|
|
"--ref-id",
|
|
"DISCORD_BOT_TOKEN",
|
|
],
|
|
cwd: params.cwd,
|
|
env: { ...params.env, DISCORD_BOT_TOKEN: params.token },
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
});
|
|
await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: ["config", "set", "channels.discord.enabled", "true"],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
});
|
|
await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: ["config", "set", "channels.discord.groupPolicy", "allowlist"],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
});
|
|
await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: ["config", "set", "channels.discord.guilds", guildsJson, "--strict-json"],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
});
|
|
if (!shouldUseManagedGatewayService()) {
|
|
const gatewayEnv = { ...params.env, DISCORD_BOT_TOKEN: params.token };
|
|
if (params.gatewayHolder?.current) {
|
|
await stopGateway(params.gatewayHolder.current);
|
|
params.gatewayHolder.current = null;
|
|
}
|
|
const gateway = await startManualGatewayFromInstalledCli({
|
|
lane: params.lane,
|
|
cliPath: params.cliPath,
|
|
env: gatewayEnv,
|
|
logPath: join(params.cwd, `.openclaw/logs/${params.lane.name}-discord-gateway.log`),
|
|
});
|
|
if (params.gatewayHolder) {
|
|
params.gatewayHolder.current = gateway;
|
|
}
|
|
await waitForInstalledGateway({
|
|
lane: params.lane,
|
|
cliPath: params.cliPath,
|
|
env: gatewayEnv,
|
|
logPath: params.logPath,
|
|
});
|
|
return;
|
|
}
|
|
await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: ["gateway", "restart"],
|
|
cwd: params.cwd,
|
|
env: { ...params.env, DISCORD_BOT_TOKEN: params.token },
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
check: false,
|
|
});
|
|
await ensureManagedGatewayReady({
|
|
lane: params.lane,
|
|
cliPath: params.cliPath,
|
|
env: { ...params.env, DISCORD_BOT_TOKEN: params.token },
|
|
logPath: params.logPath,
|
|
});
|
|
}
|
|
|
|
export async function readBoundedCrossOsResponseText(
|
|
response: Response,
|
|
maxChars = CROSS_OS_FETCH_BODY_MAX_CHARS,
|
|
options: { signal?: AbortSignal | null } = {},
|
|
): Promise<string> {
|
|
if (!response.body) {
|
|
return "";
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let text = "";
|
|
let truncated = false;
|
|
let aborted = false;
|
|
|
|
try {
|
|
while (text.length <= maxChars) {
|
|
const { done, value } = await readCrossOsResponseChunk(reader, options.signal);
|
|
if (done) {
|
|
text += decoder.decode();
|
|
break;
|
|
}
|
|
|
|
text += decoder.decode(value, { stream: true });
|
|
if (text.length > maxChars) {
|
|
text = text.slice(0, maxChars);
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
aborted = options.signal?.aborted === true;
|
|
throw error;
|
|
} finally {
|
|
if (truncated || aborted) {
|
|
await reader.cancel().catch(() => undefined);
|
|
} else {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
return truncated ? `${text}\n[truncated]` : text;
|
|
}
|
|
|
|
function readCrossOsResponseChunk(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
signal?: AbortSignal | null,
|
|
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
if (!signal) {
|
|
return reader.read();
|
|
}
|
|
if (signal.aborted) {
|
|
throw crossOsAbortReason(signal);
|
|
}
|
|
return new Promise((resolveRead, rejectRead) => {
|
|
const onAbort = () => rejectRead(crossOsAbortReason(signal));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
reader
|
|
.read()
|
|
.then(resolveRead, rejectRead)
|
|
.finally(() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
});
|
|
});
|
|
}
|
|
|
|
function crossOsAbortReason(signal: AbortSignal): Error {
|
|
const reason = signal.reason;
|
|
if (reason instanceof Error) {
|
|
return reason;
|
|
}
|
|
return new Error(typeof reason === "string" ? reason : "The operation was aborted.");
|
|
}
|
|
|
|
export function dashboardHtmlMarkerStatus(html: string): {
|
|
app: boolean;
|
|
ready: boolean;
|
|
title: boolean;
|
|
} {
|
|
const title = html.includes("<title>OpenClaw Control</title>");
|
|
const app = html.includes("<openclaw-app></openclaw-app>");
|
|
return { app, ready: title && app, title };
|
|
}
|
|
|
|
export function resolveDashboardAssetUrls(dashboardUrl: string, html: string): string[] {
|
|
const baseUrl = new URL(dashboardUrl);
|
|
const assetUrls = new Set<string>();
|
|
const assetAttributePattern = /<(?:script|link)\b[^>]*(?:src|href)\s*=\s*(["'])([^"']+)\1/giu;
|
|
for (const match of html.matchAll(assetAttributePattern)) {
|
|
const rawUrl = match[2]?.trim();
|
|
if (!rawUrl) {
|
|
continue;
|
|
}
|
|
const assetUrl = new URL(rawUrl, baseUrl);
|
|
if (assetUrl.origin === baseUrl.origin && assetUrl.pathname.includes("/assets/")) {
|
|
assetUrls.add(assetUrl.href);
|
|
}
|
|
}
|
|
return [...assetUrls].toSorted();
|
|
}
|
|
|
|
export async function verifyDashboardAssetUrls(
|
|
assetUrls: string[],
|
|
fetchAsset: typeof fetch = fetch,
|
|
): Promise<{ failures: string[]; ok: boolean }> {
|
|
if (assetUrls.length === 0) {
|
|
return { failures: ["no dashboard asset URLs found"], ok: false };
|
|
}
|
|
const failures: string[] = [];
|
|
for (const assetUrl of assetUrls) {
|
|
try {
|
|
const response = await fetchAsset(assetUrl, {
|
|
signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS),
|
|
});
|
|
await response.body?.cancel().catch(() => undefined);
|
|
if (!response.ok) {
|
|
failures.push(`${assetUrl} status=${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${assetUrl} ${formatError(error)}`);
|
|
}
|
|
}
|
|
return { failures, ok: failures.length === 0 };
|
|
}
|
|
|
|
async function waitForDiscordMessage(params: { token: string; channelId: string; needle: string }) {
|
|
const deadline = Date.now() + 3 * 60 * 1000;
|
|
while (Date.now() < deadline) {
|
|
let response;
|
|
let text;
|
|
try {
|
|
const init = buildDiscordFetchInit(params.token);
|
|
response = await fetch(
|
|
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
|
|
init,
|
|
);
|
|
text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal });
|
|
} catch {
|
|
await sleep(2_000);
|
|
continue;
|
|
}
|
|
if (!response.ok) {
|
|
await sleep(2_000);
|
|
continue;
|
|
}
|
|
if (text.includes(params.needle)) {
|
|
return;
|
|
}
|
|
await sleep(2_000);
|
|
}
|
|
throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`);
|
|
}
|
|
|
|
export function buildDiscordFetchInit(
|
|
token: string,
|
|
init: RequestInit = {},
|
|
): RequestInit & { signal: AbortSignal } {
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Authorization", `Bot ${token}`);
|
|
return {
|
|
...init,
|
|
signal: init.signal ?? AbortSignal.timeout(CROSS_OS_DISCORD_FETCH_TIMEOUT_MS),
|
|
headers,
|
|
};
|
|
}
|
|
|
|
async function postDiscordMessage(params: {
|
|
token: string;
|
|
channelId: string;
|
|
content: string;
|
|
}): Promise<string | null> {
|
|
const init = buildDiscordFetchInit(params.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content: params.content,
|
|
flags: 4096,
|
|
}),
|
|
});
|
|
const response = await fetch(
|
|
`https://discord.com/api/v10/channels/${params.channelId}/messages`,
|
|
init,
|
|
);
|
|
const text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to post Discord smoke message: ${text}`);
|
|
}
|
|
try {
|
|
const payload = JSON.parse(text) as { id?: string };
|
|
return payload.id ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function deleteDiscordMessage(params: {
|
|
token: string;
|
|
channelId: string;
|
|
messageId: string | null;
|
|
}) {
|
|
if (!params.messageId) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(
|
|
`https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`,
|
|
buildDiscordFetchInit(params.token, {
|
|
method: "DELETE",
|
|
}),
|
|
);
|
|
await response.body?.cancel?.().catch(() => undefined);
|
|
} catch {
|
|
// Cleanup is best-effort; the smoke result should not fail after readback succeeds.
|
|
}
|
|
}
|
|
|
|
async function waitForInstalledDiscordReadback(params: {
|
|
cliPath: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
logPath: string;
|
|
channelId: string;
|
|
needle: string;
|
|
}) {
|
|
const deadline = Date.now() + 3 * 60 * 1000;
|
|
while (Date.now() < deadline) {
|
|
const response = await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: [
|
|
"message",
|
|
"read",
|
|
"--channel",
|
|
"discord",
|
|
"--target",
|
|
`channel:${params.channelId}`,
|
|
"--limit",
|
|
"20",
|
|
"--json",
|
|
],
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
logPath: params.logPath,
|
|
timeoutMs: 60_000,
|
|
check: false,
|
|
});
|
|
if (response.exitCode === 0 && response.stdout.includes(params.needle)) {
|
|
return;
|
|
}
|
|
await sleep(3_000);
|
|
}
|
|
throw new Error(`Discord guest readback timed out for ${params.needle}.`);
|
|
}
|
|
|
|
export async function maybeRunDiscordRoundtrip(params: {
|
|
lane: LaneState;
|
|
cliPath: string;
|
|
env: NodeJS.ProcessEnv;
|
|
gatewayHolder: { current: GatewayHandle | null };
|
|
logPath: string;
|
|
}) {
|
|
const token =
|
|
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN?.trim() ||
|
|
process.env.DISCORD_BOT_TOKEN?.trim() ||
|
|
"";
|
|
const guildId = process.env.OPENCLAW_DISCORD_SMOKE_GUILD_ID?.trim() || "";
|
|
const channelId = process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID?.trim() || "";
|
|
if (!token || !guildId || !channelId) {
|
|
return "skipped-missing-config";
|
|
}
|
|
|
|
const { outboundNonce, inboundNonce } = buildCrossOsDiscordRoundtripNonces();
|
|
let sentMessageId: string | null = null;
|
|
let hostMessageId: string | null = null;
|
|
try {
|
|
await configureDiscordSmoke({
|
|
lane: params.lane,
|
|
cliPath: params.cliPath,
|
|
cwd: params.lane.homeDir,
|
|
env: params.env,
|
|
gatewayHolder: params.gatewayHolder,
|
|
logPath: params.logPath,
|
|
token,
|
|
guildId,
|
|
channelId,
|
|
});
|
|
|
|
const sendResult = await runInstalledCli({
|
|
cliPath: params.cliPath,
|
|
args: [
|
|
"message",
|
|
"send",
|
|
"--channel",
|
|
"discord",
|
|
"--target",
|
|
`channel:${channelId}`,
|
|
"--message",
|
|
outboundNonce,
|
|
"--silent",
|
|
"--json",
|
|
],
|
|
cwd: params.lane.homeDir,
|
|
env: { ...params.env, DISCORD_BOT_TOKEN: token },
|
|
logPath: params.logPath,
|
|
timeoutMs: 2 * 60 * 1000,
|
|
});
|
|
let parsedSendResult: {
|
|
payload?: { messageId?: string; result?: { messageId?: string } };
|
|
} | null;
|
|
try {
|
|
parsedSendResult = JSON.parse(sendResult.stdout) as {
|
|
payload?: { messageId?: string; result?: { messageId?: string } };
|
|
};
|
|
} catch {
|
|
parsedSendResult = null;
|
|
}
|
|
sentMessageId =
|
|
parsedSendResult?.payload?.messageId ?? parsedSendResult?.payload?.result?.messageId ?? null;
|
|
await waitForDiscordMessage({
|
|
token,
|
|
channelId,
|
|
needle: outboundNonce,
|
|
});
|
|
hostMessageId = await postDiscordMessage({
|
|
token,
|
|
channelId,
|
|
content: inboundNonce,
|
|
});
|
|
await waitForInstalledDiscordReadback({
|
|
cliPath: params.cliPath,
|
|
cwd: params.lane.homeDir,
|
|
env: { ...params.env, DISCORD_BOT_TOKEN: token },
|
|
logPath: params.logPath,
|
|
channelId,
|
|
needle: inboundNonce,
|
|
});
|
|
return "pass";
|
|
} finally {
|
|
await deleteDiscordMessage({ token, channelId, messageId: sentMessageId });
|
|
await deleteDiscordMessage({ token, channelId, messageId: hostMessageId });
|
|
}
|
|
}
|