#!/usr/bin/env -S node --import tsx

import { type ChildProcess, spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createPnpmRunnerSpawnSpec } from "../pnpm-runner.mjs";
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
import { telegramBotApi } from "./telegram-bot-api.ts";

type CommandResult = {
  stderr: string;
  stdout: string;
};

type GatewaySpawnSpec = {
  args: string[];
  command: string;
  options: SpawnOptionsWithoutStdio;
};

type JsonObject = Record<string, unknown>;

type PreviewCrop = "telegram-window";

type CrabboxInspect = {
  host?: string;
  id?: string;
  slug?: string;
  sshKey?: string;
  sshPort?: string;
  sshUser?: string;
  state?: string;
};

type Options = {
  crabboxClass: string;
  command:
    | "finish"
    | "probe"
    | "publish"
    | "run"
    | "screenshot"
    | "send"
    | "start"
    | "status"
    | "view";
  crabboxBin: string;
  desktopChatTitle: string;
  dryRun: boolean;
  envFile?: string;
  expect: string[];
  gatewayPort: number;
  idleTimeout: string;
  keepBox: boolean;
  leaseId?: string;
  mockResponseText: string;
  mockPort: number;
  outputDir: string;
  messageId?: string;
  previewCrop?: PreviewCrop;
  previewFps: number;
  previewCropWidth: number;
  previewWidth: number;
  provider: string;
  publishFullArtifacts: boolean;
  publishPr?: number;
  publishRepo: string;
  publishSummary?: string;
  recordFps: number;
  recordSeconds: number;
  remoteCommand: string[];
  sessionFile?: string;
  sutUsername?: string;
  target: string;
  tdlibSha256?: string;
  tdlibUrl?: string;
  text: string;
  timeoutMs: number;
  ttl: string;
  userDriverScript: string;
};

type LocalSut = {
  configPath: string;
  drained: {
    drained: number;
    pendingAfter?: number;
    pendingBefore?: number;
    webhookUrlSet: boolean;
  };
  mock: ChildProcess;
  mockLog: string;
  requestLog: string;
  stateDir: string;
  tempRoot: string;
  workspace: string;
  gateway: ChildProcess;
  gatewayLog: string;
};

type SessionFile = {
  command: "telegram-user-crabbox-session";
  createdAt: string;
  crabbox: {
    class: string;
    createdLease: boolean;
    id: string;
    inspect: CrabboxInspect;
    provider: string;
    target: string;
  };
  credential: {
    groupId: string;
    leaseFile: string;
    sutUsername: string;
    testerUserId: string;
    testerUsername: string;
  };
  localRoot: string;
  localSut: {
    gatewayLog: string;
    gatewayPid: number;
    mockLog: string;
    mockPid: number;
    requestLog: string;
    stateDir: string;
    tempRoot: string;
    workspace: string;
  };
  outputDir: string;
  recorder: {
    log: string;
    pidFile: string;
    remoteVideo: string;
  };
  remoteRoot: string;
};

const DEFAULT_SKILL_DIR = "~/.codex/skills/custom/telegram-e2e-bot-to-bot";
const DEFAULT_CONVEX_ENV_FILE = `${DEFAULT_SKILL_DIR}/convex.local.env`;
const DEFAULT_USER_DRIVER = "scripts/e2e/telegram-user-driver.py";
const DEFAULT_OUTPUT_ROOT = ".artifacts/qa-e2e/telegram-user-crabbox";
export const COMMAND_STDOUT_MAX_CHARS = 1024 * 1024;
export const COMMAND_STDERR_TAIL_CHARS = 256 * 1024;
export const COMMAND_FAILURE_STDOUT_TAIL_CHARS = 64 * 1024;
const REMOTE_ROOT = "/tmp/openclaw-telegram-user-crabbox";
const CREDENTIAL_SCRIPT = fileURLToPath(new URL("./telegram-user-credential.ts", import.meta.url));
export function readTelegramUserProofLogTailBytes(env: NodeJS.ProcessEnv = process.env): number {
  return readPositiveIntEnv("OPENCLAW_TELEGRAM_USER_PROOF_LOG_TAIL_BYTES", 256 * 1024, env);
}

const LOG_READY_TAIL_BYTES = readTelegramUserProofLogTailBytes();
const TELEGRAM_PROOF_WINDOW = {
  height: 1000,
  width: 650,
  x: 635,
  y: 40,
};
const TELEGRAM_PROOF_CROP = {
  cropWidth: 430,
  height: TELEGRAM_PROOF_WINDOW.height,
  width: 430,
  x: TELEGRAM_PROOF_WINDOW.x + 220,
  y: TELEGRAM_PROOF_WINDOW.y,
};

function usageText() {
  return [
    "Usage:",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts [probe] [--text /status] [--expect OpenClaw]",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts start [--tdlib-url <url>]",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts send --session <session.json> --text <text>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts run --session <session.json> -- <remote command>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts view --session <session.json> --message-id <id>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts screenshot --session <session.json>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts status --session <session.json>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts finish --session <session.json>",
    "  node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts publish --session <session.json> --pr <number>",
    "",
    "Useful options:",
    "  --class <name>                Crabbox machine class. Default: standard.",
    "  --desktop-chat-title <name>   Telegram Desktop chat to select before recording.",
    "  --id <cbx_id>                 Reuse an existing Crabbox desktop lease.",
    "  --keep-box                    Leave the Crabbox lease running for VNC debugging.",
    "  --mock-response-file <path>    Text returned by the mock model.",
    "  --output-dir <path>           Artifact directory under the repo.",
    "  --message-id <id>             Telegram message id for proof-view deep link.",
    "  --preview-crop telegram-window Create a side-by-side friendly Telegram-window GIF.",
    "  --preview-crop-width <pixels>  Cropped preview GIF width. Default: 430.",
    "  --preview-fps <fps>            Motion GIF frames per second. Default: 24.",
    "  --preview-width <pixels>       Motion GIF width. Default: 1920.",
    "  --pr <number>                 Pull request number for publish.",
    "  --record-fps <fps>             Desktop recording frames per second. Default: 24.",
    "  --record-seconds <seconds>    Desktop video duration. Default: 35.",
    "  --repo <owner/name>           GitHub repo for publish. Default: openclaw/openclaw.",
    "  --session <path>              Session file from start. Default: <output-dir>/session.json.",
    "  --summary <text>              Artifact publish summary.",
    "  --full-artifacts              Publish all session artifacts. Default publishes only the motion GIF.",
    "  --tdlib-sha256 <hex>         Expected SHA-256 for --tdlib-url. Defaults to <url>.sha256.",
    "  --tdlib-url <url>             Linux tdlib archive containing libtdjson.so.",
    "  --dry-run                     Validate local inputs and print the plan.",
  ].join("\n");
}

function usage(): never {
  throw new Error(usageText());
}

function expandHome(value: string) {
  if (value === "~") {
    return os.homedir();
  }
  if (value.startsWith("~/")) {
    return path.join(os.homedir(), value.slice(2));
  }
  return value;
}

function trimToValue(value: string | undefined) {
  const trimmed = value?.trim();
  return trimmed && trimmed.length > 0 ? trimmed : undefined;
}

function parsePositiveInteger(value: string, label: string) {
  const parsed = Number(value);
  if (!Number.isInteger(parsed) || parsed < 1) {
    throw new Error(`${label} must be a positive integer.`);
  }
  return parsed;
}

function parseArgs(argv: string[]): Options {
  argv = argv[0] === "--" ? argv.slice(1) : argv;
  const commands = new Set([
    "finish",
    "probe",
    "publish",
    "run",
    "screenshot",
    "send",
    "start",
    "status",
    "view",
  ]);
  const command = commands.has(argv[0] ?? "") ? (argv.shift() as Options["command"]) : "probe";
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
  const opts: Options = {
    crabboxClass: "standard",
    command,
    crabboxBin: trimToValue(process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_BIN) ?? "crabbox",
    desktopChatTitle:
      trimToValue(process.env.OPENCLAW_TELEGRAM_USER_DESKTOP_CHAT_TITLE) ?? "OpenClaw Testing",
    dryRun: false,
    expect: ["OpenClaw"],
    gatewayPort: 19_879,
    idleTimeout: "60m",
    keepBox: false,
    mockResponseText: "OPENCLAW_E2E_OK",
    mockPort: 19_882,
    outputDir: path.join(DEFAULT_OUTPUT_ROOT, stamp),
    previewCropWidth: TELEGRAM_PROOF_CROP.cropWidth,
    previewFps: 24,
    previewWidth: 1920,
    provider: process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER?.trim() || "aws",
    publishFullArtifacts: false,
    publishRepo: "openclaw/openclaw",
    recordFps: 24,
    recordSeconds: 35,
    remoteCommand: [],
    target: "linux",
    text: "/status",
    timeoutMs: 90_000,
    ttl: "120m",
    userDriverScript:
      trimToValue(process.env.OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT) ?? DEFAULT_USER_DRIVER,
  };
  const commandSeparator = argv.indexOf("--");
  if (command === "run" && commandSeparator >= 0) {
    opts.remoteCommand = argv.slice(commandSeparator + 1);
    argv = argv.slice(0, commandSeparator);
  }
  let expectWasPassed = false;
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    const readValue = () => {
      const value = argv[index + 1];
      if (!value || value.startsWith("--")) {
        usage();
      }
      index += 1;
      return value;
    };
    if (arg === "--class") {
      opts.crabboxClass = readValue();
    } else if (arg === "--crabbox-bin") {
      opts.crabboxBin = readValue();
    } else if (arg === "--desktop-chat-title") {
      opts.desktopChatTitle = readValue();
    } else if (arg === "--dry-run") {
      opts.dryRun = true;
    } else if (arg === "--env-file") {
      opts.envFile = readValue();
    } else if (arg === "--expect") {
      if (!expectWasPassed) {
        opts.expect = [];
        expectWasPassed = true;
      }
      opts.expect.push(readValue());
    } else if (arg === "--gateway-port") {
      opts.gatewayPort = parsePositiveInteger(readValue(), "--gateway-port");
    } else if (arg === "--id") {
      opts.leaseId = readValue();
    } else if (arg === "--idle-timeout") {
      opts.idleTimeout = readValue();
    } else if (arg === "--keep-box") {
      opts.keepBox = true;
    } else if (arg === "--mock-port") {
      opts.mockPort = parsePositiveInteger(readValue(), "--mock-port");
    } else if (arg === "--mock-response-file") {
      opts.mockResponseText = fs.readFileSync(resolveRepoPath(process.cwd(), readValue()), "utf8");
    } else if (arg === "--message-id") {
      opts.messageId = String(parsePositiveInteger(readValue(), "--message-id"));
    } else if (arg === "--output-dir") {
      opts.outputDir = readValue();
    } else if (arg === "--preview-crop") {
      const value = readValue();
      if (value !== "telegram-window") {
        throw new Error("--preview-crop must be telegram-window.");
      }
      opts.previewCrop = value;
    } else if (arg === "--preview-crop-width") {
      opts.previewCropWidth = parsePositiveInteger(readValue(), "--preview-crop-width");
    } else if (arg === "--preview-fps") {
      opts.previewFps = parsePositiveInteger(readValue(), "--preview-fps");
    } else if (arg === "--preview-width") {
      opts.previewWidth = parsePositiveInteger(readValue(), "--preview-width");
    } else if (arg === "--provider") {
      opts.provider = readValue();
    } else if (arg === "--pr") {
      opts.publishPr = parsePositiveInteger(readValue(), "--pr");
    } else if (arg === "--repo") {
      opts.publishRepo = readValue();
    } else if (arg === "--record-seconds") {
      opts.recordSeconds = parsePositiveInteger(readValue(), "--record-seconds");
    } else if (arg === "--session") {
      opts.sessionFile = readValue();
    } else if (arg === "--summary") {
      opts.publishSummary = readValue();
    } else if (arg === "--full-artifacts") {
      opts.publishFullArtifacts = true;
    } else if (arg === "--record-fps") {
      opts.recordFps = parsePositiveInteger(readValue(), "--record-fps");
    } else if (arg === "--sut-username") {
      opts.sutUsername = readValue().replace(/^@/u, "");
    } else if (arg === "--target") {
      opts.target = readValue();
    } else if (arg === "--tdlib-sha256") {
      opts.tdlibSha256 = readValue().toLowerCase();
    } else if (arg === "--tdlib-url") {
      opts.tdlibUrl = readValue();
    } else if (arg === "--text") {
      opts.text = readValue();
    } else if (arg === "--timeout-ms") {
      opts.timeoutMs = parsePositiveInteger(readValue(), "--timeout-ms");
    } else if (arg === "--ttl") {
      opts.ttl = readValue();
    } else if (arg === "--user-driver-script") {
      opts.userDriverScript = readValue();
    } else if (arg === "--help" || arg === "-h") {
      console.log(usageText());
      process.exit(0);
    } else {
      throw new Error(`Unknown argument: ${arg}`);
    }
  }
  if (command === "run" && opts.remoteCommand.length === 0) {
    throw new Error("run requires a remote command after --.");
  }
  if (
    ["finish", "publish", "run", "screenshot", "send", "status", "view"].includes(command) &&
    !opts.sessionFile
  ) {
    throw new Error(`${command} requires --session.`);
  }
  if (command === "view" && !opts.messageId) {
    throw new Error("view requires --message-id.");
  }
  if (command === "publish" && !opts.publishPr) {
    throw new Error("publish requires --pr.");
  }
  return opts;
}

function repoRoot() {
  const cwd = process.cwd();
  if (
    !fs.existsSync(path.join(cwd, "package.json")) ||
    !fs.existsSync(path.join(cwd, "scripts/e2e/mock-openai-server.mjs"))
  ) {
    throw new Error("Run from the OpenClaw repo root.");
  }
  return cwd;
}

function resolveRepoPath(root: string, value: string) {
  const resolved = path.isAbsolute(value) ? value : path.resolve(root, value);
  const relative = path.relative(root, resolved);
  if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
    throw new Error(`Output path must stay inside the repo: ${value}`);
  }
  return resolved;
}

function readJsonFile(filePath: string): JsonObject {
  try {
    return JSON.parse(fs.readFileSync(expandHome(filePath), "utf8")) as JsonObject;
  } catch (error) {
    if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
      return {};
    }
    throw error;
  }
}

function requireString(source: JsonObject, key: string) {
  const value = source[key];
  if (typeof value === "number") {
    return String(value);
  }
  if (typeof value === "string" && value.trim()) {
    return value.trim();
  }
  throw new Error(`Missing ${key}.`);
}

function childProcessBaseEnv() {
  const keys = [
    "CI",
    "COREPACK_HOME",
    "FORCE_COLOR",
    "HOME",
    "LANG",
    "LC_ALL",
    "NODE_OPTIONS",
    "OPENCLAW_BUILD_PRIVATE_QA",
    "OPENCLAW_ENABLE_PRIVATE_QA_CLI",
    "PATH",
    "PNPM_HOME",
    "SHELL",
    "TEMP",
    "TMP",
    "TMPDIR",
    "USER",
    "XDG_CACHE_HOME",
    "XDG_CONFIG_HOME",
  ];
  const env: NodeJS.ProcessEnv = {};
  for (const key of keys) {
    const value = process.env[key];
    if (value) {
      env[key] = value;
    }
  }
  return env;
}

function mockServerEnv(params: { mockPort: number; mockResponseText: string; requestLog: string }) {
  return {
    ...childProcessBaseEnv(),
    MOCK_PORT: String(params.mockPort),
    MOCK_REQUEST_LOG: params.requestLog,
    SUCCESS_MARKER: params.mockResponseText,
  };
}

function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) {
  return {
    ...childProcessBaseEnv(),
    OPENAI_API_KEY: "sk-openclaw-e2e-mock",
    OPENCLAW_CONFIG_PATH: params.configPath,
    OPENCLAW_STATE_DIR: params.stateDir,
    TELEGRAM_BOT_TOKEN: params.sutToken,
  };
}

export function createOpenClawGatewaySpawnSpec(params: {
  env: NodeJS.ProcessEnv;
  gatewayPort: number;
  repoRoot: string;
  comSpec?: string;
  nodeExecPath?: string;
  npmExecPath?: string;
  platform?: NodeJS.Platform;
}): GatewaySpawnSpec {
  const spec = createPnpmRunnerSpawnSpec({
    comSpec: params.comSpec,
    cwd: params.repoRoot,
    env: params.env,
    nodeExecPath: params.nodeExecPath,
    npmExecPath: params.npmExecPath,
    platform: params.platform,
    pnpmArgs: ["openclaw", "gateway", "--port", String(params.gatewayPort)],
  });
  return {
    args: spec.args,
    command: spec.command,
    options: {
      cwd: spec.options.cwd,
      env: spec.options.env,
      shell: spec.options.shell,
      windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
    },
  };
}

function shellQuote(value: string) {
  return `'${value.replaceAll("'", "'\\''")}'`;
}

type AppendCommandStdoutResult = { ok: true; value: string } | { ok: false; message: string };

function appendCommandText(current: string, chunk: Buffer): string {
  return current + chunk.toString("utf8");
}

export function appendCommandTextTail(current: string, chunk: Buffer, maxChars: number): string {
  const next = appendCommandText(current, chunk);
  return next.length > maxChars ? next.slice(-maxChars) : next;
}

export function appendCommandStdout(
  current: string,
  chunk: Buffer,
  maxChars = COMMAND_STDOUT_MAX_CHARS,
): AppendCommandStdoutResult {
  const next = appendCommandText(current, chunk);
  if (next.length > maxChars) {
    return { ok: false, message: `command stdout exceeded ${maxChars} characters` };
  }
  return { ok: true, value: next };
}

export function appendCommandStderrTail(
  current: string,
  chunk: Buffer,
  maxChars = COMMAND_STDERR_TAIL_CHARS,
): string {
  return appendCommandTextTail(current, chunk, maxChars);
}

function commandFailureOutput(stdout: string, stderr: string): string {
  const stdoutTail =
    stdout.length > COMMAND_FAILURE_STDOUT_TAIL_CHARS
      ? `\n[stdout truncated to last ${COMMAND_FAILURE_STDOUT_TAIL_CHARS} characters]\n${stdout.slice(
          -COMMAND_FAILURE_STDOUT_TAIL_CHARS,
        )}`
      : stdout;
  return `${stdoutTail}${stderr}`;
}

function runCommand(params: {
  args: string[];
  command: string;
  cwd: string;
  env?: NodeJS.ProcessEnv;
  outputFile?: string;
  stdio?: "inherit" | "pipe";
  stdin?: string;
}) {
  return new Promise<CommandResult>((resolve, reject) => {
    if (params.outputFile) {
      fs.writeFileSync(params.outputFile, "");
    }
    const child = spawn(params.command, params.args, {
      cwd: params.cwd,
      env: params.env ?? process.env,
      stdio: ["pipe", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    let stdoutLimitError: string | null = null;
    child.stdout.on("data", (chunk: Buffer) => {
      const text = chunk.toString();
      if (params.outputFile) {
        fs.appendFileSync(params.outputFile, text);
        stdout = appendCommandTextTail(stdout, chunk, COMMAND_FAILURE_STDOUT_TAIL_CHARS);
      } else if (params.stdio === "inherit") {
        stdout = appendCommandTextTail(stdout, chunk, COMMAND_FAILURE_STDOUT_TAIL_CHARS);
      } else {
        const appended = appendCommandStdout(stdout, chunk);
        if (!appended.ok) {
          stdoutLimitError = appended.message;
          child.kill("SIGKILL");
        } else {
          stdout = appended.value;
        }
      }
      if (params.stdio === "inherit") {
        process.stdout.write(text);
      }
    });
    child.stderr.on("data", (chunk: Buffer) => {
      const text = chunk.toString();
      if (params.outputFile) {
        fs.appendFileSync(params.outputFile, text);
      }
      stderr = appendCommandStderrTail(stderr, chunk);
      if (params.stdio === "inherit") {
        process.stderr.write(text);
      }
    });
    child.on("error", reject);
    child.on("close", (code, signal) => {
      if (stdoutLimitError) {
        reject(new Error(`${params.command} ${params.args.join(" ")} failed: ${stdoutLimitError}`));
        return;
      }
      if (code === 0) {
        resolve({ stdout, stderr });
        return;
      }
      const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
      reject(
        new Error(
          `${params.command} ${params.args.join(" ")} failed with ${detail}\n${commandFailureOutput(
            stdout,
            stderr,
          )}`,
        ),
      );
    });
    if (params.stdin) {
      child.stdin.end(params.stdin);
    } else {
      child.stdin.end();
    }
  });
}

function spawnLogged(command: string, args: string[], options: SpawnOptionsWithoutStdio) {
  const child = spawn(command, args, {
    ...options,
    detached: true,
    stdio: ["ignore", "pipe", "pipe"],
  });
  child.stdout.setEncoding("utf8");
  child.stderr.setEncoding("utf8");
  let output = "";
  const capture = (chunk: string) => {
    output = `${output}${chunk}`.slice(-12000);
  };
  child.stdout.on("data", capture);
  child.stderr.on("data", capture);
  return {
    child,
    get output() {
      return output;
    },
  };
}

function waitForOutput(
  child: ChildProcess,
  pattern: RegExp,
  output: () => string,
  label: string,
  timeoutMs: number,
) {
  return new Promise<void>((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(
        new Error(`${label} did not become ready within ${timeoutMs}ms\n${output().slice(-4000)}`),
      );
    }, timeoutMs);
    const onData = () => {
      if (pattern.test(output())) {
        cleanup();
        resolve();
      }
    };
    const onExit = (code: number | null) => {
      cleanup();
      reject(
        new Error(
          `${label} exited before ready with code ${code ?? "unknown"}\n${output().slice(-4000)}`,
        ),
      );
    };
    const cleanup = () => {
      clearTimeout(timeout);
      child.stdout?.off("data", onData);
      child.stderr?.off("data", onData);
      child.off("exit", onExit);
    };
    child.stdout?.on("data", onData);
    child.stderr?.on("data", onData);
    child.on("exit", onExit);
    onData();
  });
}

function killTree(child: ChildProcess | undefined) {
  if (!child || child.killed || child.exitCode !== null) {
    return;
  }
  if (!child.pid) {
    return;
  }
  try {
    process.kill(-child.pid, "SIGTERM");
  } catch {
    child.kill("SIGTERM");
  }
}

function killPidTree(pid: number | undefined) {
  if (!pid) {
    return;
  }
  try {
    process.kill(-pid, "SIGTERM");
  } catch {
    try {
      process.kill(pid, "SIGTERM");
    } catch {
      return;
    }
  }
}

function spawnDaemon(params: {
  args: string[];
  command: string;
  cwd: string;
  env: NodeJS.ProcessEnv;
  logPath: string;
  shell?: boolean;
  windowsVerbatimArguments?: boolean;
}) {
  const log = fs.openSync(params.logPath, "a");
  const child = spawn(params.command, params.args, {
    cwd: params.cwd,
    detached: true,
    env: params.env,
    shell: params.shell,
    stdio: ["ignore", log, log],
    windowsVerbatimArguments: params.windowsVerbatimArguments,
  });
  child.unref();
  fs.closeSync(log);
  return child.pid;
}

export function readLogTail(logPath: string, maxBytes = LOG_READY_TAIL_BYTES): string {
  let stat: fs.Stats;
  try {
    stat = fs.statSync(logPath);
  } catch {
    return "";
  }
  if (!stat.isFile() || stat.size <= 0) {
    return "";
  }
  const bytesToRead = Math.min(Math.max(1, maxBytes), stat.size);
  const buffer = Buffer.alloc(bytesToRead);
  const fd = fs.openSync(logPath, "r");
  let bytesRead = 0;
  try {
    bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
  } finally {
    fs.closeSync(fd);
  }
  return buffer.subarray(0, bytesRead).toString("utf8");
}

export async function waitForLog(
  logPath: string,
  pattern: RegExp,
  label: string,
  timeoutMs: number,
) {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    const text = readLogTail(logPath);
    if (pattern.test(text)) {
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  const text = readLogTail(logPath);
  throw new Error(`${label} did not become ready within ${timeoutMs}ms\n${text.slice(-4000)}`);
}

async function telegram(token: string, method: string, body: JsonObject = {}) {
  return await telegramBotApi(token, method, body);
}

async function drainSutUpdates(sutToken: string) {
  const before = telegramResultObject(await telegram(sutToken, "getWebhookInfo"), "getWebhookInfo");
  const rawUpdates = await telegram(sutToken, "getUpdates", {
    allowed_updates: ["message", "edited_message"],
    timeout: 0,
  });
  if (!Array.isArray(rawUpdates)) {
    throw new Error("getUpdates returned an invalid payload.");
  }
  const updates = rawUpdates;
  if (updates.length) {
    const last = updates.at(-1);
    if (
      last &&
      typeof last === "object" &&
      "update_id" in last &&
      typeof last.update_id === "number"
    ) {
      await telegram(sutToken, "getUpdates", { offset: last.update_id + 1, timeout: 0 });
    }
  }
  const after = telegramResultObject(await telegram(sutToken, "getWebhookInfo"), "getWebhookInfo");
  return {
    drained: updates.length,
    pendingAfter:
      typeof after.pending_update_count === "number" ? after.pending_update_count : undefined,
    pendingBefore:
      typeof before.pending_update_count === "number" ? before.pending_update_count : undefined,
    webhookUrlSet: typeof before.url === "string" && before.url.length > 0,
  };
}

async function sutIdentity(sutToken: string) {
  const result = telegramResultObject(await telegram(sutToken, "getMe"), "getMe");
  const username = requireString(result, "username").replace(/^@/u, "");
  return { id: requireString(result, "id"), username };
}

function telegramResultObject(value: unknown, label: string): JsonObject {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} returned an invalid payload.`);
  }
  return value as JsonObject;
}

function writeSutConfig(params: {
  gatewayPort: number;
  groupId: string;
  mockPort: number;
  outputDir: string;
  testerId: string;
}) {
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tg-crabbox-sut-"));
  const stateDir = path.join(tempRoot, "state");
  const workspace = path.join(tempRoot, "workspace");
  fs.mkdirSync(stateDir, { recursive: true });
  fs.mkdirSync(workspace, { recursive: true });
  const configPath = path.join(tempRoot, "openclaw.json");
  const config = {
    agents: {
      defaults: {
        model: { primary: "openai/gpt-5.5" },
        models: { "openai/gpt-5.5": { params: { openaiWsWarmup: false, transport: "sse" } } },
      },
      list: [
        {
          default: true,
          id: "main",
          model: { primary: "openai/gpt-5.5" },
          name: "Main",
          workspace,
        },
      ],
    },
    channels: {
      telegram: {
        allowFrom: [params.testerId],
        botToken: { id: "TELEGRAM_BOT_TOKEN", provider: "default", source: "env" },
        commands: { native: true, nativeSkills: false },
        dmPolicy: "allowlist",
        enabled: true,
        groupAllowFrom: [params.testerId],
        groupPolicy: "allowlist",
        groups: {
          [params.groupId]: {
            allowFrom: [params.testerId],
            groupPolicy: "allowlist",
            requireMention: false,
          },
        },
        replyToMode: "first",
      },
    },
    gateway: { auth: { mode: "none" }, bind: "loopback", mode: "local", port: params.gatewayPort },
    messages: { groupChat: { visibleReplies: "automatic" } },
    models: {
      providers: {
        openai: {
          api: "openai-responses",
          apiKey: { id: "OPENAI_API_KEY", provider: "default", source: "env" },
          baseUrl: `http://127.0.0.1:${params.mockPort}/v1`,
          models: [
            { api: "openai-responses", contextWindow: 128000, id: "gpt-5.5", name: "gpt-5.5" },
          ],
          request: { allowPrivateNetwork: true },
        },
      },
    },
    plugins: {
      allow: ["telegram", "openai"],
      enabled: true,
      entries: { openai: { enabled: true }, telegram: { enabled: true } },
    },
  };
  fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
  return { configPath, stateDir, tempRoot, workspace };
}

async function startLocalSut(params: {
  gatewayPort: number;
  groupId: string;
  mockResponseText: string;
  mockPort: number;
  outputDir: string;
  sutToken: string;
  testerId: string;
  repoRoot: string;
}) {
  const drained = await drainSutUpdates(params.sutToken);
  const config = writeSutConfig(params);
  const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
  const mock = spawnLogged("node", ["scripts/e2e/mock-openai-server.mjs"], {
    cwd: params.repoRoot,
    env: mockServerEnv({ ...params, requestLog }),
  });
  await waitForOutput(
    mock.child,
    /mock-openai listening/u,
    () => mock.output,
    "mock-openai",
    10_000,
  );
  const gatewaySpec = createOpenClawGatewaySpawnSpec({
    env: gatewayEnv({ ...config, sutToken: params.sutToken }),
    gatewayPort: params.gatewayPort,
    repoRoot: params.repoRoot,
  });
  const gateway = spawnLogged(gatewaySpec.command, gatewaySpec.args, gatewaySpec.options);
  await waitForOutput(gateway.child, /\[gateway\] ready/u, () => gateway.output, "gateway", 60_000);
  return {
    ...config,
    drained,
    gateway: gateway.child,
    get gatewayLog() {
      return gateway.output;
    },
    mock: mock.child,
    get mockLog() {
      return mock.output;
    },
    requestLog,
  };
}

async function startLocalSutDaemon(params: {
  gatewayPort: number;
  groupId: string;
  mockResponseText: string;
  mockPort: number;
  outputDir: string;
  sutToken: string;
  testerId: string;
  repoRoot: string;
}) {
  const drained = await drainSutUpdates(params.sutToken);
  const config = writeSutConfig(params);
  const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
  const mockLog = path.join(params.outputDir, "mock-openai.log");
  const gatewayLog = path.join(params.outputDir, "gateway.log");
  let mockPid: number | undefined;
  let gatewayPid: number | undefined;
  try {
    mockPid = spawnDaemon({
      command: "node",
      args: ["scripts/e2e/mock-openai-server.mjs"],
      cwd: params.repoRoot,
      env: mockServerEnv({ ...params, requestLog }),
      logPath: mockLog,
    });
    if (!mockPid) {
      throw new Error("mock-openai did not start.");
    }
    await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000);

    const gatewayEnvVars = gatewayEnv({ ...config, sutToken: params.sutToken });
    const gatewaySpec = createOpenClawGatewaySpawnSpec({
      env: gatewayEnvVars,
      gatewayPort: params.gatewayPort,
      repoRoot: params.repoRoot,
    });
    gatewayPid = spawnDaemon({
      args: gatewaySpec.args,
      command: gatewaySpec.command,
      cwd: gatewaySpec.options.cwd ?? params.repoRoot,
      env: gatewaySpec.options.env ?? gatewayEnvVars,
      logPath: gatewayLog,
      shell: gatewaySpec.options.shell,
      windowsVerbatimArguments: gatewaySpec.options.windowsVerbatimArguments,
    });
    if (!gatewayPid) {
      throw new Error("gateway did not start.");
    }
    await waitForLog(gatewayLog, /\[gateway\] ready/u, "gateway", 60_000);
    return {
      ...config,
      drained,
      gatewayLog,
      gatewayPid,
      mockLog,
      mockPid,
      requestLog,
    };
  } catch (error) {
    killPidTree(gatewayPid);
    killPidTree(mockPid);
    throw error;
  }
}

function extractLeaseId(output: string) {
  return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
}

async function warmupCrabbox(opts: Options, root: string) {
  const result = await runCommand({
    command: opts.crabboxBin,
    args: [
      "warmup",
      "--provider",
      opts.provider,
      "--target",
      opts.target,
      "--desktop",
      "--browser",
      "--class",
      opts.crabboxClass,
      "--idle-timeout",
      opts.idleTimeout,
      "--ttl",
      opts.ttl,
    ],
    cwd: root,
    stdio: "inherit",
  });
  const leaseId = extractLeaseId(`${result.stdout}\n${result.stderr}`);
  if (!leaseId) {
    throw new Error("Crabbox warmup did not print a lease id.");
  }
  return leaseId;
}

async function createMotionPreview(params: {
  motionGifPath: string;
  motionVideoPath: string;
  opts: Options;
  root: string;
  videoPath: string;
}) {
  const preview = await runCommand({
    command: params.opts.crabboxBin,
    args: [
      "media",
      "preview",
      "--input",
      params.videoPath,
      "--output",
      params.motionGifPath,
      "--fps",
      String(params.opts.previewFps),
      "--width",
      String(params.opts.previewWidth),
      "--trimmed-video-output",
      params.motionVideoPath,
      "--json",
    ],
    cwd: params.root,
    stdio: "inherit",
  });
  return JSON.parse(preview.stdout) as JsonObject;
}

function previewCrop(opts: Options) {
  return opts.previewCrop === "telegram-window"
    ? { ...TELEGRAM_PROOF_CROP, cropWidth: opts.previewCropWidth }
    : undefined;
}

async function createCroppedMotionPreview(params: {
  crop: typeof TELEGRAM_PROOF_CROP;
  croppedGifPath: string;
  croppedVideoPath: string;
  opts: Options;
  root: string;
  videoPath: string;
}) {
  const crop = `crop=${params.crop.width}:${params.crop.height}:${params.crop.x}:${params.crop.y}`;
  const scale = `scale=${params.crop.cropWidth}:-2:flags=lanczos`;
  await runCommand({
    command: "ffmpeg",
    args: [
      "-y",
      "-hide_banner",
      "-loglevel",
      "warning",
      "-i",
      params.videoPath,
      "-vf",
      `${crop},${scale}`,
      "-pix_fmt",
      "yuv420p",
      params.croppedVideoPath,
    ],
    cwd: params.root,
    stdio: "inherit",
  });
  await runCommand({
    command: "ffmpeg",
    args: [
      "-y",
      "-hide_banner",
      "-loglevel",
      "warning",
      "-i",
      params.videoPath,
      "-filter_complex",
      `${crop},fps=${params.opts.previewFps},${scale},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
      params.croppedGifPath,
    ],
    cwd: params.root,
    stdio: "inherit",
  });
  return {
    crop,
    fps: params.opts.previewFps,
    outputWidth: params.crop.cropWidth,
  };
}

async function inspectCrabbox(opts: Options, root: string, leaseId: string) {
  const result = await runCommand({
    command: opts.crabboxBin,
    args: [
      "inspect",
      "--provider",
      opts.provider,
      "--target",
      opts.target,
      "--id",
      leaseId,
      "--json",
    ],
    cwd: root,
  });
  return JSON.parse(result.stdout) as CrabboxInspect;
}

function sshArgs(inspect: CrabboxInspect) {
  if (!inspect.host || !inspect.sshKey || !inspect.sshUser) {
    throw new Error("Crabbox inspect output is missing SSH details.");
  }
  return {
    base: [
      "-i",
      inspect.sshKey,
      "-p",
      inspect.sshPort ?? "22",
      "-o",
      "IdentitiesOnly=yes",
      "-o",
      "BatchMode=yes",
      "-o",
      "StrictHostKeyChecking=accept-new",
      "-o",
      "ConnectTimeout=15",
    ],
    scpBase: [
      "-i",
      inspect.sshKey,
      "-P",
      inspect.sshPort ?? "22",
      "-o",
      "IdentitiesOnly=yes",
      "-o",
      "BatchMode=yes",
      "-o",
      "StrictHostKeyChecking=accept-new",
      "-o",
      "ConnectTimeout=15",
    ],
    target: `${inspect.sshUser}@${inspect.host}`,
  };
}

function isTransientSshFailure(error: unknown) {
  const message = error instanceof Error ? error.message : String(error);
  return /Connection (?:closed|reset)|Operation timed out|Connection timed out/u.test(message);
}

async function runRemoteCommand(params: {
  args: string[];
  command: string;
  cwd: string;
  outputFile?: string;
  stdio?: "inherit" | "pipe";
}) {
  let lastError: unknown;
  for (let attempt = 1; attempt <= 4; attempt += 1) {
    try {
      return await runCommand(params);
    } catch (error) {
      lastError = error;
      if (attempt === 4 || !isTransientSshFailure(error)) {
        throw error;
      }
      await new Promise((resolve) => setTimeout(resolve, attempt * 3000));
    }
  }
  throw lastError;
}

async function scpToRemote(root: string, inspect: CrabboxInspect, local: string, remote: string) {
  const ssh = sshArgs(inspect);
  await runRemoteCommand({
    command: "scp",
    args: [...ssh.scpBase, local, `${ssh.target}:${remote}`],
    cwd: root,
    stdio: "inherit",
  });
}

async function scpFromRemote(root: string, inspect: CrabboxInspect, remote: string, local: string) {
  const ssh = sshArgs(inspect);
  await runRemoteCommand({
    command: "scp",
    args: [...ssh.scpBase, `${ssh.target}:${remote}`, local],
    cwd: root,
    stdio: "inherit",
  });
}

async function sshRun(
  root: string,
  inspect: CrabboxInspect,
  remoteCommand: string,
  options: { outputFile?: string } = {},
) {
  const ssh = sshArgs(inspect);
  return await runRemoteCommand({
    command: "ssh",
    args: [...ssh.base, ssh.target, remoteCommand],
    cwd: root,
    outputFile: options.outputFile,
    stdio: "inherit",
  });
}

function renderRemoteSetup(params: { tdlibSha256?: string; tdlibUrl?: string }) {
  const tdlibSha256 = JSON.stringify(params.tdlibSha256 ?? "");
  const tdlibUrl = JSON.stringify(params.tdlibUrl ?? "");
  return `#!/usr/bin/env bash
set -euo pipefail
root=${REMOTE_ROOT}
tdlib_sha256=${tdlibSha256}
tdlib_url=${tdlibUrl}
setup_step_timeout_kill_after="\${OPENCLAW_TELEGRAM_USER_SETUP_KILL_AFTER_SECONDS:-30}s"
apt_timeout="\${OPENCLAW_TELEGRAM_USER_APT_TIMEOUT_SECONDS:-900}s"
download_timeout="\${OPENCLAW_TELEGRAM_USER_DOWNLOAD_TIMEOUT_SECONDS:-600}"
download_connect_timeout="\${OPENCLAW_TELEGRAM_USER_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}"
download_retries="\${OPENCLAW_TELEGRAM_USER_DOWNLOAD_RETRIES:-3}"
download_retry_delay="\${OPENCLAW_TELEGRAM_USER_DOWNLOAD_RETRY_DELAY_SECONDS:-5}"
tdlib_clone_timeout="\${OPENCLAW_TELEGRAM_USER_TDLIB_CLONE_TIMEOUT_SECONDS:-600}s"
tdlib_build_timeout="\${OPENCLAW_TELEGRAM_USER_TDLIB_BUILD_TIMEOUT_SECONDS:-1800}s"
run_setup_step() {
  local label="$1"
  local timeout_value="$2"
  shift 2
  echo "==> $label" >&2
  timeout --kill-after="$setup_step_timeout_kill_after" "$timeout_value" "$@"
}
download_file() {
  local url="$1"
  local output="$2"
  curl -fL \
    --connect-timeout "$download_connect_timeout" \
    --max-time "$download_timeout" \
    --retry "$download_retries" \
    --retry-delay "$download_retry_delay" \
    --retry-all-errors \
    -o "$output" \
    "$url"
}
mkdir -p "$root"
tar -xzf "$root/state.tgz" -C "$root"
run_setup_step "apt-get update" "$apt_timeout" sudo apt-get update -y
run_setup_step "apt-get install" "$apt_timeout" sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y curl git cmake g++ make zlib1g-dev libssl-dev python3 ffmpeg scrot xz-utils tar wmctrl xdotool x11-utils zbar-tools libopengl0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxkbcommon-x11-0 >/tmp/openclaw-telegram-apt.log
if ! command -v python3 >/dev/null 2>&1; then
  echo "python3 is required" >&2
  exit 127
fi
if [ ! -x "$root/Telegram/Telegram" ]; then
  download_file https://telegram.org/dl/desktop/linux "$root/telegram.tar.xz"
  tar -xJf "$root/telegram.tar.xz" -C "$root"
fi
if ! ldconfig -p | grep -q libtdjson.so; then
  if [ -n "$tdlib_url" ]; then
    download_file "$tdlib_url" "$root/tdlib-linux.tgz"
    if [ -z "$tdlib_sha256" ]; then
      download_file "$tdlib_url.sha256" "$root/tdlib-linux.tgz.sha256"
      tdlib_sha256="$(awk '{print $1; exit}' "$root/tdlib-linux.tgz.sha256")"
    fi
    printf '%s  %s\\n' "$tdlib_sha256" "$root/tdlib-linux.tgz" | sha256sum -c -
    mkdir -p "$root/tdlib-linux"
    tar -xzf "$root/tdlib-linux.tgz" -C "$root/tdlib-linux"
    lib="$(find "$root/tdlib-linux" -name libtdjson.so -type f | head -n 1)"
    test -n "$lib"
    sudo install -m 0755 "$lib" /usr/local/lib/libtdjson.so
  else
    rm -rf "$root/td" "$root/td-build"
    run_setup_step "tdlib clone" "$tdlib_clone_timeout" git clone --depth 1 --branch v1.8.0 https://github.com/tdlib/td.git "$root/td"
    run_setup_step "tdlib configure" "$tdlib_build_timeout" cmake -S "$root/td" -B "$root/td-build" -DCMAKE_BUILD_TYPE=Release -DTD_ENABLE_JNI=OFF
    run_setup_step "tdlib build" "$tdlib_build_timeout" cmake --build "$root/td-build" --target tdjson -j "$(nproc)"
    run_setup_step "tdlib install" "$apt_timeout" sudo cmake --install "$root/td-build"
  fi
  sudo ldconfig
fi
TELEGRAM_USER_DRIVER_STATE_DIR="$root/user-driver" python3 "$root/user-driver.py" status --json --timeout-ms 60000 >"$root/status.json"
TELEGRAM_USER_DRIVER_STATE_DIR="$root/user-driver" python3 "$root/user-driver.py" terminate-desktop-sessions --json --timeout-ms 60000 --output "$root/desktop-sessions-cleanup.json"
`;
}

function renderLaunchDesktop() {
  return `#!/usr/bin/env bash
set -euo pipefail
root=${REMOTE_ROOT}
export DISPLAY="\${DISPLAY:-:99}"
pkill -f "$root/Telegram/Telegram" >/dev/null 2>&1 || true
rm -rf "$root/desktop/tdata"
nohup "$root/Telegram/Telegram" -workdir "$root/desktop" >"$root/telegram-desktop.log" 2>&1 &
pid=$!
sleep 8
if ! kill -0 "$pid" >/dev/null 2>&1; then
  cat "$root/telegram-desktop.log" >&2
  exit 1
fi
if ! wmctrl -l | grep -i telegram >/dev/null 2>&1; then
  cat "$root/telegram-desktop.log" >&2
  exit 1
fi
`;
}

function renderAuthorizeDesktop() {
  return `#!/usr/bin/env bash
set -euo pipefail
root=${REMOTE_ROOT}
export DISPLAY="\${DISPLAY:-:99}"
win="$(wmctrl -l | awk 'tolower($0) ~ /telegram/ {print $1; exit}')"
test -n "$win"
xdotool windowactivate "$win"
sleep 5
click_window_ratio() {
  eval "$(xdotool getwindowgeometry --shell "$win")"
  xdotool windowactivate "$win"
  sleep 0.2
  xdotool mousemove "$((X + WIDTH / 2))" "$((Y + HEIGHT * $1 / 100))"
  sleep 0.2
  xdotool click 1
  sleep 1
}
read_qr_link() {
  scrot "$root/telegram-login-qr.png"
  { zbarimg --raw "$root/telegram-login-qr.png" 2>/dev/null || true; } | awk 'index($0, "tg://login?token=") == 1 {print; exit}'
}
wait_for_qr_link() {
  for _ in $(seq 1 25); do
    link="$(read_qr_link)"
    if [ -n "$link" ]; then
      printf '%s\\n' "$link"
      return 0
    fi
    sleep 1
  done
  return 1
}
click_window_ratio 69
sleep 3
click_window_ratio 80
link="$(wait_for_qr_link)" || {
  echo "Telegram Desktop QR login code was not found." >&2
  exit 1
}
export TELEGRAM_USER_DRIVER_STATE_DIR="$root/user-driver"
python3 "$root/user-driver.py" confirm-qr --link "$link" --json --output "$root/desktop-session.json"
python3 - "$root/desktop-session.json" <<'PY'
import json
import sys
payload = json.loads(open(sys.argv[1]).read())
session = payload.get("session") or {}
if session.get("isPasswordPending"):
    raise SystemExit("Telegram Desktop QR login requires a 2FA password.")
PY
sleep 6
`;
}

function renderSelectDesktopChat(params: { chatTitle: string }) {
  return `#!/usr/bin/env bash
set -euo pipefail
chat_title=${JSON.stringify(params.chatTitle)}
export DISPLAY="\${DISPLAY:-:99}"
win="$(wmctrl -l | awk 'tolower($0) ~ /telegram/ {print $1; exit}')"
test -n "$win"
left=520
top=170
xdotool windowactivate --sync "$win"
xdotool windowsize "$win" 980 720
xdotool windowmove "$win" "$left" "$top"
sleep 1
xdotool mousemove "$((left + 180))" "$((top + 50))" click 1
xdotool key ctrl+a BackSpace
xdotool type --delay 5 -- "$chat_title"
sleep 2
xdotool mousemove "$((left + 150))" "$((top + 120))" click 1
sleep 1
`;
}

function renderRemoteProbe(params: {
  expect: string[];
  outputPath?: string;
  sutUsername: string;
  text: string;
  timeoutMs: number;
}) {
  const args = [
    "probe",
    "--text",
    params.text,
    "--timeout-ms",
    String(params.timeoutMs),
    "--output",
    params.outputPath ?? `${REMOTE_ROOT}/probe.json`,
    "--json",
  ];
  for (const expected of params.expect) {
    args.push("--expect", expected);
  }
  const escapedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
  return `#!/usr/bin/env bash
set -euo pipefail
root=${REMOTE_ROOT}
export TELEGRAM_USER_DRIVER_STATE_DIR="$root/user-driver"
export TELEGRAM_USER_DRIVER_SUT_USERNAME=${JSON.stringify(params.sutUsername)}
python3 "$root/user-driver.py" ${escapedArgs}
`;
}

async function writeExecutable(filePath: string, content: string) {
  fs.writeFileSync(filePath, content);
  fs.chmodSync(filePath, 0o700);
}

function requireUserDriverScript(opts: Options) {
  const userDriverScript = expandHome(opts.userDriverScript);
  if (!fs.existsSync(userDriverScript)) {
    throw new Error(`Missing user driver script: ${opts.userDriverScript}`);
  }
  return userDriverScript;
}

async function prepareRemoteState(params: { localRoot: string; opts: Options; root: string }) {
  const stateArchive = path.join(params.localRoot, "remote-state.tgz");
  const userDriverScript = requireUserDriverScript(params.opts);
  await runCommand({
    command: "cp",
    args: [userDriverScript, path.join(params.localRoot, "user-driver.py")],
    cwd: params.root,
  });
  await runCommand({
    command: "tar",
    args: [
      "-C",
      params.localRoot,
      "-czf",
      stateArchive,
      "user-driver",
      "desktop",
      "user-driver.py",
    ],
    cwd: params.root,
  });
  return stateArchive;
}

async function leaseCredential(params: { localRoot: string; opts: Options; root: string }) {
  const userDriverDir = path.join(params.localRoot, "user-driver");
  const desktopWorkdir = path.join(params.localRoot, "desktop");
  const leaseFile = path.join(params.localRoot, "lease.json");
  const payloadFile = path.join(params.localRoot, "payload.json");
  const args = [
    CREDENTIAL_SCRIPT,
    "lease-restore",
    "--user-driver-dir",
    userDriverDir,
    "--desktop-workdir",
    desktopWorkdir,
    "--lease-file",
    leaseFile,
    "--payload-output",
    payloadFile,
  ];
  if (params.opts.envFile) {
    args.push("--env-file", params.opts.envFile);
  }
  const result = await runCommand({
    command: "node",
    args: ["--import", "tsx", ...args],
    cwd: params.root,
    stdio: "inherit",
  });
  const acquired = JSON.parse(result.stdout || "{}") as JsonObject;
  const payload = readJsonFile(payloadFile);
  return {
    acquired,
    desktopWorkdir,
    groupId: requireString(payload, "groupId"),
    leaseFile,
    payloadFile,
    sutToken: requireString(payload, "sutToken"),
    testerUserId: requireString(payload, "testerUserId"),
    testerUsername: requireString(payload, "testerUsername"),
    userDriverDir,
  };
}

async function releaseCredential(root: string, opts: Options, leaseFile: string) {
  if (!fs.existsSync(leaseFile)) {
    return;
  }
  const args = [CREDENTIAL_SCRIPT, "release", "--lease-file", leaseFile];
  if (opts.envFile) {
    args.push("--env-file", opts.envFile);
  }
  await runCommand({
    command: "node",
    args: ["--import", "tsx", ...args],
    cwd: root,
    stdio: "inherit",
  });
}

async function stopCrabbox(root: string, opts: Options, leaseId: string) {
  await runCommand({
    command: opts.crabboxBin,
    args: ["stop", "--provider", opts.provider, leaseId],
    cwd: root,
    stdio: "inherit",
  });
}

function buildTargetText(text: string, sutUsername: string) {
  if (!text.startsWith("/")) {
    return text.replaceAll("{sut}", sutUsername);
  }
  if (/^\/\S+@\w+/u.test(text)) {
    return text;
  }
  const [command, ...rest] = text.split(/\s+/u);
  return [`${command}@${sutUsername}`, ...rest].join(" ").trim();
}

function summarizeProbe(probePath: string) {
  const probe = readJsonFile(probePath);
  const reply = probe.reply;
  const sent = probe.sent;
  const messageId = (value: unknown) => {
    if (!value || typeof value !== "object") {
      return undefined;
    }
    if ("messageId" in value) {
      return value.messageId;
    }
    if ("id" in value) {
      return value.id;
    }
    return undefined;
  };
  return {
    ok: probe.ok === true,
    replyMessageId: messageId(reply),
    sentMessageId: messageId(sent),
  };
}

function writeReport(params: {
  croppedMotionGifPath?: string;
  croppedMotionVideoPath?: string;
  motionGifPath?: string;
  motionVideoPath?: string;
  outputDir: string;
  screenshotPath?: string;
  status: "pass" | "fail";
  summaryPath: string;
  videoPath?: string;
}) {
  const reportPath = path.join(params.outputDir, "telegram-user-crabbox-proof.md");
  fs.writeFileSync(
    reportPath,
    [
      "# Telegram User Crabbox Proof",
      "",
      `Status: ${params.status}`,
      `Summary: ${path.basename(params.summaryPath)}`,
      params.videoPath ? `Video: ${path.basename(params.videoPath)}` : "Video: missing",
      params.motionVideoPath
        ? `Motion video: ${path.basename(params.motionVideoPath)}`
        : "Motion video: missing",
      params.motionGifPath
        ? `Motion GIF: ${path.basename(params.motionGifPath)}`
        : "Motion GIF: missing",
      params.croppedMotionVideoPath
        ? `Cropped motion video: ${path.basename(params.croppedMotionVideoPath)}`
        : undefined,
      params.croppedMotionGifPath
        ? `Cropped motion GIF: ${path.basename(params.croppedMotionGifPath)}`
        : undefined,
      params.screenshotPath
        ? `Screenshot: ${path.basename(params.screenshotPath)}`
        : "Screenshot: missing",
      "",
    ]
      .filter((line) => line !== undefined)
      .join("\n"),
  );
  return reportPath;
}

function sessionPath(root: string, opts: Options, outputDir: string) {
  return opts.sessionFile
    ? resolveRepoPath(root, opts.sessionFile)
    : path.join(outputDir, "session.json");
}

function writeSession(pathname: string, session: SessionFile) {
  fs.mkdirSync(path.dirname(pathname), { recursive: true });
  fs.writeFileSync(pathname, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
  fs.chmodSync(pathname, 0o600);
}

function readSession(root: string, opts: Options, outputDir: string) {
  const pathname = sessionPath(root, opts, outputDir);
  if (!fs.existsSync(pathname)) {
    throw new Error(`Missing session file: ${path.relative(root, pathname)}`);
  }
  const session = readJsonFile(pathname) as SessionFile;
  if (session.command !== "telegram-user-crabbox-session") {
    throw new Error(`Invalid Telegram Crabbox session file: ${path.relative(root, pathname)}`);
  }
  return {
    path: pathname,
    session,
  };
}

async function writeRemoteSessionScripts(params: {
  inspect: CrabboxInspect;
  localRoot: string;
  opts: Options;
  root: string;
  stateArchive: string;
  sutUsername: string;
}) {
  const setupScript = path.join(params.localRoot, "remote-setup.sh");
  const launchScript = path.join(params.localRoot, "launch-desktop.sh");
  const authorizeScript = path.join(params.localRoot, "authorize-desktop.sh");
  const selectChatScript = path.join(params.localRoot, "select-desktop-chat.sh");
  await writeExecutable(
    setupScript,
    renderRemoteSetup({ tdlibSha256: params.opts.tdlibSha256, tdlibUrl: params.opts.tdlibUrl }),
  );
  await writeExecutable(launchScript, renderLaunchDesktop());
  await writeExecutable(authorizeScript, renderAuthorizeDesktop());
  await writeExecutable(
    selectChatScript,
    renderSelectDesktopChat({ chatTitle: params.opts.desktopChatTitle }),
  );

  await sshRun(params.root, params.inspect, `rm -rf ${REMOTE_ROOT} && mkdir -p ${REMOTE_ROOT}`);
  await scpToRemote(params.root, params.inspect, params.stateArchive, `${REMOTE_ROOT}/state.tgz`);
  await scpToRemote(params.root, params.inspect, setupScript, `${REMOTE_ROOT}/remote-setup.sh`);
  await scpToRemote(params.root, params.inspect, launchScript, `${REMOTE_ROOT}/launch-desktop.sh`);
  await scpToRemote(
    params.root,
    params.inspect,
    authorizeScript,
    `${REMOTE_ROOT}/authorize-desktop.sh`,
  );
  await scpToRemote(
    params.root,
    params.inspect,
    selectChatScript,
    `${REMOTE_ROOT}/select-desktop-chat.sh`,
  );
  await sshRun(params.root, params.inspect, `bash ${REMOTE_ROOT}/remote-setup.sh`);
  await sshRun(params.root, params.inspect, `bash ${REMOTE_ROOT}/launch-desktop.sh`);
  await sshRun(params.root, params.inspect, `bash ${REMOTE_ROOT}/authorize-desktop.sh`);
  await sshRun(params.root, params.inspect, `bash ${REMOTE_ROOT}/select-desktop-chat.sh`);
  await sshRun(
    params.root,
    params.inspect,
    `cat >${REMOTE_ROOT}/env.sh <<'EOF'
export TELEGRAM_USER_DRIVER_STATE_DIR=${REMOTE_ROOT}/user-driver
export TELEGRAM_USER_DRIVER_SUT_USERNAME=${params.sutUsername}
EOF
`,
  );
}

async function startRemoteRecording(root: string, inspect: CrabboxInspect, opts: Options) {
  const command = `set -euo pipefail
export DISPLAY="\${DISPLAY:-:99}"
root=${REMOTE_ROOT}
video="$root/session.mp4"
log="$root/ffmpeg.log"
pid_file="$root/ffmpeg.pid"
rm -f "$video" "$log" "$pid_file"
size="$(xdpyinfo | awk '/dimensions:/ {size=$2} END {if (!size) exit 1; print size}')"
nohup ffmpeg -y -hide_banner -loglevel warning -f x11grab -framerate ${opts.recordFps} -video_size "$size" -i "$DISPLAY" -pix_fmt yuv420p "$video" >"$log" 2>&1 &
echo $! >"$pid_file"`;
  await sshRun(root, inspect, command);
  return {
    log: `${REMOTE_ROOT}/ffmpeg.log`,
    pidFile: `${REMOTE_ROOT}/ffmpeg.pid`,
    remoteVideo: `${REMOTE_ROOT}/session.mp4`,
  };
}

async function stopRemoteRecording(root: string, inspect: CrabboxInspect, session: SessionFile) {
  await sshRun(
    root,
    inspect,
    `set -euo pipefail
pid_file=${JSON.stringify(session.recorder.pidFile)}
if [ -s "$pid_file" ]; then
  pid="$(cat "$pid_file")"
  kill -INT "$pid" >/dev/null 2>&1 || true
  for _ in $(seq 1 20); do
    kill -0 "$pid" >/dev/null 2>&1 || exit 0
    sleep 0.5
  done
  kill -TERM "$pid" >/dev/null 2>&1 || true
fi`,
  );
}

async function terminateRemoteDesktopSession(root: string, inspect: CrabboxInspect) {
  await sshRun(
    root,
    inspect,
    `set -euo pipefail
root=${REMOTE_ROOT}
if [ ! -s "$root/desktop-session.json" ]; then
  exit 0
fi
session_id="$(python3 - "$root/desktop-session.json" <<'PY'
import json
import sys
payload = json.loads(open(sys.argv[1]).read())
print((payload.get("session") or {}).get("id") or "")
PY
)"
if [ -z "$session_id" ]; then
  exit 0
fi
export TELEGRAM_USER_DRIVER_STATE_DIR="$root/user-driver"
python3 "$root/user-driver.py" terminate-session --session-id "$session_id" --json --output "$root/desktop-session-terminated.json"`,
  );
}

async function startSession(root: string, opts: Options, outputDir: string) {
  const localRoot = path.join(outputDir, ".session");
  fs.rmSync(localRoot, { force: true, recursive: true });
  fs.mkdirSync(localRoot, { mode: 0o700, recursive: true });

  const convexEnvFile = expandHome(opts.envFile ?? DEFAULT_CONVEX_ENV_FILE);
  const hasConvexEnv =
    trimToValue(process.env.OPENCLAW_QA_CONVEX_SITE_URL) &&
    trimToValue(process.env.OPENCLAW_QA_CONVEX_SECRET_CI);
  if (!hasConvexEnv && !fs.existsSync(convexEnvFile)) {
    throw new Error(`Missing Convex env file: ${opts.envFile ?? DEFAULT_CONVEX_ENV_FILE}`);
  }
  await runCommand({ command: opts.crabboxBin, args: ["--version"], cwd: root });
  if (opts.dryRun) {
    return {
      command: "telegram-user-crabbox-session",
      crabboxClass: opts.crabboxClass,
      outputDir,
      provider: opts.provider,
      target: opts.target,
      tdlibSha256: opts.tdlibSha256,
      tdlibUrl: opts.tdlibUrl,
    };
  }

  requireUserDriverScript(opts);
  let credential: Awaited<ReturnType<typeof leaseCredential>> | undefined;
  let leaseId = opts.leaseId;
  let createdLease = false;
  let localSut: Awaited<ReturnType<typeof startLocalSutDaemon>> | undefined;
  try {
    credential = await leaseCredential({ localRoot, opts, root });
    const sut = opts.sutUsername
      ? { id: "", username: opts.sutUsername }
      : await sutIdentity(credential.sutToken);
    const stateArchive = await prepareRemoteState({ localRoot, opts, root });
    if (!leaseId) {
      leaseId = await warmupCrabbox(opts, root);
      createdLease = true;
    }
    const inspect = await inspectCrabbox(opts, root, leaseId);
    await writeRemoteSessionScripts({
      inspect,
      localRoot,
      opts,
      root,
      stateArchive,
      sutUsername: sut.username,
    });
    localSut = await startLocalSutDaemon({
      gatewayPort: opts.gatewayPort,
      groupId: credential.groupId,
      mockResponseText: opts.mockResponseText,
      mockPort: opts.mockPort,
      outputDir,
      repoRoot: root,
      sutToken: credential.sutToken,
      testerId: credential.testerUserId,
    });
    const recorder = await startRemoteRecording(root, inspect, opts);
    const session: SessionFile = {
      command: "telegram-user-crabbox-session",
      createdAt: new Date().toISOString(),
      crabbox: {
        class: opts.crabboxClass,
        createdLease,
        id: leaseId,
        inspect,
        provider: opts.provider,
        target: opts.target,
      },
      credential: {
        groupId: credential.groupId,
        leaseFile: credential.leaseFile,
        sutUsername: sut.username,
        testerUserId: credential.testerUserId,
        testerUsername: credential.testerUsername,
      },
      localRoot,
      localSut,
      outputDir,
      recorder,
      remoteRoot: REMOTE_ROOT,
    };
    const pathname = sessionPath(root, opts, outputDir);
    writeSession(pathname, session);
    return {
      session: path.relative(root, pathname),
      status: "pass",
      telegram: {
        groupId: credential.groupId,
        sutUsername: sut.username,
        testerUserId: credential.testerUserId,
        testerUsername: credential.testerUsername,
      },
      webvnc: `${opts.crabboxBin} webvnc --provider ${opts.provider} --target ${opts.target} --id ${leaseId} --open`,
      commands: {
        send: `openclaw-telegram-user-crabbox-proof send --session ${path.relative(root, pathname)} --text '/status'`,
        view: `openclaw-telegram-user-crabbox-proof view --session ${path.relative(root, pathname)} --message-id <message-id>`,
        run: `openclaw-telegram-user-crabbox-proof run --session ${path.relative(root, pathname)} -- bash -lc 'source ${REMOTE_ROOT}/env.sh && python3 ${REMOTE_ROOT}/user-driver.py transcript --limit 20 --json'`,
        finish: `openclaw-telegram-user-crabbox-proof finish --session ${path.relative(root, pathname)} --preview-crop telegram-window`,
      },
    };
  } catch (error) {
    killPidTree(localSut?.gatewayPid);
    killPidTree(localSut?.mockPid);
    if (credential) {
      await releaseCredential(root, opts, credential.leaseFile).catch(() => {});
    }
    if (leaseId && createdLease) {
      await stopCrabbox(root, opts, leaseId).catch(() => {});
    }
    throw error;
  }
}

async function sendSessionProbe(root: string, opts: Options, outputDir: string) {
  const { session } = readSession(root, opts, outputDir);
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
  const targetText = buildTargetText(opts.text, session.credential.sutUsername);
  const remoteProbe = `${REMOTE_ROOT}/probe-${stamp}.json`;
  const probeScript = path.join(session.localRoot, `remote-probe-${stamp}.sh`);
  await writeExecutable(
    probeScript,
    renderRemoteProbe({
      expect: opts.expect,
      outputPath: remoteProbe,
      sutUsername: session.credential.sutUsername,
      text: targetText,
      timeoutMs: opts.timeoutMs,
    }),
  );
  await scpToRemote(root, session.crabbox.inspect, probeScript, `${REMOTE_ROOT}/remote-probe.sh`);
  await sshRun(root, session.crabbox.inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
  const localProbe = path.join(session.outputDir, `probe-${stamp}.json`);
  await scpFromRemote(root, session.crabbox.inspect, remoteProbe, localProbe);
  return {
    probe: path.relative(root, localProbe),
    status: "pass",
    summary: summarizeProbe(localProbe),
    text: targetText,
  };
}

async function runSessionCommand(root: string, opts: Options, outputDir: string) {
  const { session } = readSession(root, opts, outputDir);
  const command = opts.remoteCommand.map(shellQuote).join(" ");
  const logPath = path.join(
    session.outputDir,
    `remote-command-${new Date().toISOString().replace(/[:.]/gu, "-")}.log`,
  );
  await sshRun(root, session.crabbox.inspect, command, { outputFile: logPath });
  return { command: opts.remoteCommand, log: path.relative(root, logPath), status: "pass" };
}

async function screenshotSession(root: string, opts: Options, outputDir: string) {
  const { session } = readSession(root, opts, outputDir);
  const screenshotPath = path.join(
    session.outputDir,
    `telegram-user-crabbox-${new Date().toISOString().replace(/[:.]/gu, "-")}.png`,
  );
  await runCommand({
    command: opts.crabboxBin,
    args: [
      "screenshot",
      "--provider",
      session.crabbox.provider,
      "--target",
      session.crabbox.target,
      "--id",
      session.crabbox.id,
      "--output",
      screenshotPath,
    ],
    cwd: root,
    stdio: "inherit",
  });
  return { screenshot: path.relative(root, screenshotPath), status: "pass" };
}

async function statusSession(root: string, opts: Options, outputDir: string) {
  const { path: pathname, session } = readSession(root, opts, outputDir);
  const inspect = await inspectCrabbox(opts, root, session.crabbox.id);
  return {
    crabbox: {
      id: session.crabbox.id,
      slug: inspect.slug,
      state: inspect.state,
    },
    session: path.relative(root, pathname),
    status: "pass",
    webvnc: `${opts.crabboxBin} webvnc --provider ${session.crabbox.provider} --target ${session.crabbox.target} --id ${session.crabbox.id} --open`,
  };
}

function telegramPrivatePostLink(groupId: string, messageId: string) {
  if (!/^-100\d+$/u.test(groupId)) {
    throw new Error(`Telegram privatepost links require a -100 group id, got ${groupId}.`);
  }
  return `tg://privatepost?channel=${groupId.slice(4)}&post=${messageId}`;
}

function renderProofViewCommand(link: string) {
  return `set -euo pipefail
export DISPLAY="\${DISPLAY:-:99}"
root=${REMOTE_ROOT}
win="$(wmctrl -lxG | awk 'tolower($0) ~ /telegramdesktop/ {print $1; exit}')"
if [ -z "$win" ]; then
  echo "Telegram Desktop window not found." >&2
  exit 1
fi
wmctrl -ir "$win" -b remove,maximized_vert,maximized_horz,fullscreen
wmctrl -ir "$win" -e 0,${TELEGRAM_PROOF_WINDOW.x},${TELEGRAM_PROOF_WINDOW.y},${TELEGRAM_PROOF_WINDOW.width},${TELEGRAM_PROOF_WINDOW.height}
telegram="$root/Telegram/Telegram"
test -x "$telegram"
set +e
timeout 5 "$telegram" -workdir "$root/desktop" ${shellQuote(link)}
status="$?"
set -e
if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
  exit "$status"
fi
sleep 1
wmctrl -lxG | awk 'tolower($0) ~ /telegramdesktop/'`;
}

async function viewSession(root: string, opts: Options, outputDir: string) {
  const { session } = readSession(root, opts, outputDir);
  const messageId = opts.messageId;
  if (!messageId) {
    throw new Error("view requires --message-id.");
  }
  const link = telegramPrivatePostLink(session.credential.groupId, messageId);
  const logPath = path.join(
    session.outputDir,
    `proof-view-${new Date().toISOString().replace(/[:.]/gu, "-")}.log`,
  );
  await sshRun(root, session.crabbox.inspect, renderProofViewCommand(link), {
    outputFile: logPath,
  });
  return {
    crop: TELEGRAM_PROOF_CROP,
    geometry: TELEGRAM_PROOF_WINDOW,
    link,
    log: path.relative(root, logPath),
    status: "pass",
  };
}

async function finishSession(root: string, opts: Options, outputDir: string) {
  const { path: pathname, session } = readSession(root, opts, outputDir);
  const summary: JsonObject = {
    artifacts: {},
    finishedAt: new Date().toISOString(),
    session: path.relative(root, pathname),
    startedAt: session.createdAt,
    status: "fail",
  };
  const videoPath = path.join(session.outputDir, "telegram-user-crabbox-session.mp4");
  const motionVideoPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.mp4");
  const motionGifPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.gif");
  const croppedMotionVideoPath = path.join(
    session.outputDir,
    "telegram-user-crabbox-session-motion-telegram-window.mp4",
  );
  const croppedMotionGifPath = path.join(
    session.outputDir,
    "telegram-user-crabbox-session-motion-telegram-window.gif",
  );
  const screenshotPath = path.join(session.outputDir, "telegram-user-crabbox-session.png");
  const desktopLogPath = path.join(session.outputDir, "telegram-desktop.log");
  const statusPath = path.join(session.outputDir, "status.json");
  const ffmpegLogPath = path.join(session.outputDir, "ffmpeg.log");
  const crop = previewCrop(opts);
  let desktopSessionTerminationAttempted = false;
  const terminateDesktopSession = async () => {
    if (opts.keepBox || desktopSessionTerminationAttempted) {
      return;
    }
    desktopSessionTerminationAttempted = true;
    await terminateRemoteDesktopSession(root, session.crabbox.inspect).catch((error: unknown) => {
      summary.desktopSessionTerminateError = error instanceof Error ? error.message : String(error);
    });
  };
  try {
    await stopRemoteRecording(root, session.crabbox.inspect, session);
    await scpFromRemote(root, session.crabbox.inspect, session.recorder.remoteVideo, videoPath);
    await scpFromRemote(
      root,
      session.crabbox.inspect,
      `${REMOTE_ROOT}/telegram-desktop.log`,
      desktopLogPath,
    ).catch(() => {});
    await scpFromRemote(
      root,
      session.crabbox.inspect,
      `${REMOTE_ROOT}/status.json`,
      statusPath,
    ).catch(() => {});
    await scpFromRemote(root, session.crabbox.inspect, session.recorder.log, ffmpegLogPath).catch(
      () => {},
    );
    await runCommand({
      command: opts.crabboxBin,
      args: [
        "screenshot",
        "--provider",
        session.crabbox.provider,
        "--target",
        session.crabbox.target,
        "--id",
        session.crabbox.id,
        "--output",
        screenshotPath,
      ],
      cwd: root,
      stdio: "inherit",
    });
    await terminateDesktopSession();
    summary.mediaPreview = await createMotionPreview({
      motionGifPath,
      motionVideoPath,
      opts,
      root,
      videoPath,
    });
    if (crop) {
      summary.croppedMediaPreview = await createCroppedMotionPreview({
        crop,
        croppedGifPath: croppedMotionGifPath,
        croppedVideoPath: croppedMotionVideoPath,
        opts,
        root,
        videoPath: motionVideoPath,
      });
    }
    summary.artifacts = {
      desktopLog: path.relative(root, desktopLogPath),
      ffmpegLog: path.relative(root, ffmpegLogPath),
      previewGif: path.relative(root, motionGifPath),
      ...(crop
        ? {
            previewGifCropped: path.relative(root, croppedMotionGifPath),
            trimmedVideoCropped: path.relative(root, croppedMotionVideoPath),
          }
        : {}),
      screenshot: path.relative(root, screenshotPath),
      status: path.relative(root, statusPath),
      trimmedVideo: path.relative(root, motionVideoPath),
      video: path.relative(root, videoPath),
    };
    summary.status = "pass";
  } finally {
    killPidTree(session.localSut.gatewayPid);
    killPidTree(session.localSut.mockPid);
    await terminateDesktopSession();
    await releaseCredential(root, opts, session.credential.leaseFile).catch((error: unknown) => {
      summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
    });
    if (session.crabbox.createdLease && !opts.keepBox) {
      await stopCrabbox(root, opts, session.crabbox.id).catch((error: unknown) => {
        summary.crabboxStopError = error instanceof Error ? error.message : String(error);
      });
    }
    if (opts.keepBox) {
      summary.keepBox = true;
      summary.webvnc = `${opts.crabboxBin} webvnc --provider ${session.crabbox.provider} --target ${session.crabbox.target} --id ${session.crabbox.id} --open`;
    }
    fs.rmSync(session.localRoot, { force: true, recursive: true });
    const summaryPath = path.join(session.outputDir, "telegram-user-crabbox-session-summary.json");
    fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
    const reportPath = writeReport({
      croppedMotionGifPath: crop ? croppedMotionGifPath : undefined,
      croppedMotionVideoPath: crop ? croppedMotionVideoPath : undefined,
      motionGifPath,
      motionVideoPath,
      outputDir: session.outputDir,
      screenshotPath,
      status: summary.status === "pass" ? "pass" : "fail",
      summaryPath,
      videoPath,
    });
    summary.report = path.relative(root, reportPath);
    fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
    console.log(JSON.stringify({ reportPath, status: summary.status, summaryPath }, null, 2));
  }
  if (summary.status !== "pass") {
    process.exitCode = 1;
  }
}

async function publishSessionArtifacts(root: string, opts: Options, outputDir: string) {
  const { session } = readSession(root, opts, outputDir);
  const motionGifPath = path.join(session.outputDir, "telegram-user-crabbox-session-motion.gif");
  const croppedMotionGifPath = path.join(
    session.outputDir,
    "telegram-user-crabbox-session-motion-telegram-window.gif",
  );
  const publishGifPath = fs.existsSync(croppedMotionGifPath) ? croppedMotionGifPath : motionGifPath;
  const publishDir = opts.publishFullArtifacts
    ? session.outputDir
    : path.join(session.outputDir, "publish-gif-only");
  if (!opts.publishFullArtifacts) {
    if (!fs.existsSync(publishGifPath)) {
      throw new Error(
        `Missing motion GIF. Run finish first: ${path.relative(root, motionGifPath)}`,
      );
    }
    fs.rmSync(publishDir, { force: true, recursive: true });
    fs.mkdirSync(publishDir, { recursive: true });
    fs.copyFileSync(
      publishGifPath,
      path.join(publishDir, "telegram-user-crabbox-session-motion.gif"),
    );
  }
  await runCommand({
    command: opts.crabboxBin,
    args: [
      "artifacts",
      "publish",
      "--pr",
      String(opts.publishPr),
      "--repo",
      opts.publishRepo,
      "--dir",
      publishDir,
      "--summary",
      opts.publishSummary ??
        (opts.publishFullArtifacts
          ? "Telegram real-user Crabbox session artifacts"
          : "Telegram real-user Crabbox session motion GIF"),
      "--template",
      "openclaw",
      ...(opts.dryRun ? ["--dry-run"] : []),
    ],
    cwd: root,
    stdio: "inherit",
  });
  return {
    artifactMode: opts.publishFullArtifacts
      ? "full"
      : publishGifPath === croppedMotionGifPath
        ? "gif-only-cropped"
        : "gif-only",
    publishDir: path.relative(root, publishDir),
    status: "pass",
  };
}

async function main() {
  const opts = parseArgs(process.argv.slice(2));
  const root = repoRoot();
  const outputDir = resolveRepoPath(root, opts.outputDir);
  fs.mkdirSync(outputDir, { recursive: true });
  opts.outputDir = outputDir;

  if (opts.command === "start") {
    console.log(JSON.stringify(await startSession(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "send") {
    console.log(JSON.stringify(await sendSessionProbe(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "run") {
    console.log(JSON.stringify(await runSessionCommand(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "screenshot") {
    console.log(JSON.stringify(await screenshotSession(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "status") {
    console.log(JSON.stringify(await statusSession(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "view") {
    console.log(JSON.stringify(await viewSession(root, opts, outputDir), null, 2));
    return;
  }
  if (opts.command === "finish") {
    await finishSession(root, opts, outputDir);
    return;
  }
  if (opts.command === "publish") {
    console.log(JSON.stringify(await publishSessionArtifacts(root, opts, outputDir), null, 2));
    return;
  }

  const localRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-crabbox-"));
  const summary: JsonObject = {
    artifacts: {},
    crabbox: { provider: opts.provider, target: opts.target },
    outputDir,
    startedAt: new Date().toISOString(),
    status: "fail",
  };

  let credential: Awaited<ReturnType<typeof leaseCredential>> | undefined;
  let leaseId = opts.leaseId;
  let createdLease = false;
  let localSut: LocalSut | undefined;

  try {
    const convexEnvFile = expandHome(opts.envFile ?? DEFAULT_CONVEX_ENV_FILE);
    const hasConvexEnv =
      trimToValue(process.env.OPENCLAW_QA_CONVEX_SITE_URL) &&
      trimToValue(process.env.OPENCLAW_QA_CONVEX_SECRET_CI);
    if (!hasConvexEnv && !fs.existsSync(convexEnvFile)) {
      throw new Error(`Missing Convex env file: ${opts.envFile ?? DEFAULT_CONVEX_ENV_FILE}`);
    }
    await runCommand({ command: opts.crabboxBin, args: ["--version"], cwd: root });
    if (opts.dryRun) {
      summary.status = "pass";
      summary.plan = {
        command: "telegram-user-crabbox-proof",
        crabboxClass: opts.crabboxClass,
        outputDir,
        provider: opts.provider,
        target: opts.target,
        tdlibSha256: opts.tdlibSha256,
        tdlibUrl: opts.tdlibUrl,
        text: opts.text,
      };
      return;
    }

    requireUserDriverScript(opts);
    credential = await leaseCredential({ localRoot, opts, root });
    const sut = opts.sutUsername
      ? { id: "", username: opts.sutUsername }
      : await sutIdentity(credential.sutToken);
    const targetText = buildTargetText(opts.text, sut.username);
    summary.telegram = {
      groupId: credential.groupId,
      sutUsername: sut.username,
      testerUserId: credential.testerUserId,
      testerUsername: credential.testerUsername,
      text: targetText,
    };

    const stateArchive = await prepareRemoteState({
      localRoot,
      opts,
      root,
    });
    if (!leaseId) {
      leaseId = await warmupCrabbox(opts, root);
      createdLease = true;
    }
    summary.crabbox = {
      createdLease,
      id: leaseId,
      provider: opts.provider,
      target: opts.target,
    };
    const inspect = await inspectCrabbox(opts, root, leaseId);
    summary.crabbox = {
      createdLease,
      id: leaseId,
      provider: opts.provider,
      slug: inspect.slug,
      state: inspect.state,
      target: opts.target,
    };

    const setupScript = path.join(localRoot, "remote-setup.sh");
    const launchScript = path.join(localRoot, "launch-desktop.sh");
    const authorizeScript = path.join(localRoot, "authorize-desktop.sh");
    const selectChatScript = path.join(localRoot, "select-desktop-chat.sh");
    const probeScript = path.join(localRoot, "remote-probe.sh");
    await writeExecutable(
      setupScript,
      renderRemoteSetup({ tdlibSha256: opts.tdlibSha256, tdlibUrl: opts.tdlibUrl }),
    );
    await writeExecutable(launchScript, renderLaunchDesktop());
    await writeExecutable(authorizeScript, renderAuthorizeDesktop());
    await writeExecutable(
      selectChatScript,
      renderSelectDesktopChat({ chatTitle: opts.desktopChatTitle }),
    );
    await writeExecutable(
      probeScript,
      renderRemoteProbe({
        expect: opts.expect,
        sutUsername: sut.username,
        text: targetText,
        timeoutMs: opts.timeoutMs,
      }),
    );

    await sshRun(root, inspect, `rm -rf ${REMOTE_ROOT} && mkdir -p ${REMOTE_ROOT}`);
    await scpToRemote(root, inspect, stateArchive, `${REMOTE_ROOT}/state.tgz`);
    await scpToRemote(root, inspect, setupScript, `${REMOTE_ROOT}/remote-setup.sh`);
    await scpToRemote(root, inspect, launchScript, `${REMOTE_ROOT}/launch-desktop.sh`);
    await scpToRemote(root, inspect, authorizeScript, `${REMOTE_ROOT}/authorize-desktop.sh`);
    await scpToRemote(root, inspect, selectChatScript, `${REMOTE_ROOT}/select-desktop-chat.sh`);
    await scpToRemote(root, inspect, probeScript, `${REMOTE_ROOT}/remote-probe.sh`);
    await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-setup.sh`);

    const sutRuntime = await startLocalSut({
      gatewayPort: opts.gatewayPort,
      groupId: credential.groupId,
      mockResponseText: opts.mockResponseText,
      mockPort: opts.mockPort,
      outputDir,
      repoRoot: root,
      sutToken: credential.sutToken,
      testerId: credential.testerUserId,
    });
    localSut = sutRuntime;
    summary.localSut = {
      drained: sutRuntime.drained,
      gatewayPort: opts.gatewayPort,
      mockPort: opts.mockPort,
      requestLog: path.relative(root, sutRuntime.requestLog),
    };

    await sshRun(root, inspect, `bash ${REMOTE_ROOT}/launch-desktop.sh`);
    await sshRun(root, inspect, `bash ${REMOTE_ROOT}/authorize-desktop.sh`);
    await sshRun(root, inspect, `bash ${REMOTE_ROOT}/select-desktop-chat.sh`);
    const videoPath = path.join(outputDir, "telegram-user-crabbox-proof.mp4");
    const recording = spawn(
      opts.crabboxBin,
      [
        "artifacts",
        "video",
        "--provider",
        opts.provider,
        "--target",
        opts.target,
        "--id",
        leaseId,
        "--duration",
        `${opts.recordSeconds}s`,
        "--output",
        videoPath,
      ],
      { cwd: root, stdio: "inherit" },
    );
    await new Promise((resolve) => setTimeout(resolve, 3_000));
    await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
    const recordCode = await new Promise<number | null>((resolve) => recording.on("exit", resolve));
    if (recordCode !== 0) {
      throw new Error(`Crabbox recording failed with exit code ${recordCode ?? "unknown"}.`);
    }
    const motionVideoPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.mp4");
    const motionGifPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.gif");
    summary.mediaPreview = await createMotionPreview({
      motionGifPath,
      motionVideoPath,
      opts,
      root,
      videoPath,
    });

    const screenshotPath = path.join(outputDir, "telegram-user-crabbox-proof.png");
    await runCommand({
      command: opts.crabboxBin,
      args: [
        "screenshot",
        "--provider",
        opts.provider,
        "--target",
        opts.target,
        "--id",
        leaseId,
        "--output",
        screenshotPath,
      ],
      cwd: root,
      stdio: "inherit",
    });
    const probePath = path.join(outputDir, "probe.json");
    const statusPath = path.join(outputDir, "status.json");
    const desktopLogPath = path.join(outputDir, "telegram-desktop.log");
    await scpFromRemote(root, inspect, `${REMOTE_ROOT}/probe.json`, probePath);
    await scpFromRemote(root, inspect, `${REMOTE_ROOT}/status.json`, statusPath);
    await scpFromRemote(root, inspect, `${REMOTE_ROOT}/telegram-desktop.log`, desktopLogPath);
    summary.artifacts = {
      desktopLog: path.relative(root, desktopLogPath),
      probe: path.relative(root, probePath),
      previewGif: path.relative(root, motionGifPath),
      screenshot: path.relative(root, screenshotPath),
      status: path.relative(root, statusPath),
      trimmedVideo: path.relative(root, motionVideoPath),
      video: path.relative(root, videoPath),
    };
    summary.probe = summarizeProbe(probePath);
    summary.status = "pass";
  } finally {
    killTree(localSut?.gateway);
    killTree(localSut?.mock);
    if (credential) {
      await releaseCredential(root, opts, credential.leaseFile).catch((error: unknown) => {
        summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
      });
    }
    if (leaseId && createdLease && !opts.keepBox) {
      await stopCrabbox(root, opts, leaseId).catch((error: unknown) => {
        summary.crabboxStopError = error instanceof Error ? error.message : String(error);
      });
    }
    if (opts.keepBox && leaseId) {
      summary.keepBox = true;
      summary.webvnc = `${opts.crabboxBin} webvnc --provider ${opts.provider} --target ${opts.target} --id ${leaseId} --open`;
    }
    summary.finishedAt = new Date().toISOString();
    const summaryPath = path.join(outputDir, "telegram-user-crabbox-proof-summary.json");
    fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
    const artifacts = summary.artifacts;
    const screenshotPath =
      artifacts &&
      typeof artifacts === "object" &&
      "screenshot" in artifacts &&
      typeof artifacts.screenshot === "string"
        ? path.join(root, artifacts.screenshot)
        : undefined;
    const motionGifPath =
      artifacts &&
      typeof artifacts === "object" &&
      "previewGif" in artifacts &&
      typeof artifacts.previewGif === "string"
        ? path.join(root, artifacts.previewGif)
        : undefined;
    const motionVideoPath =
      artifacts &&
      typeof artifacts === "object" &&
      "trimmedVideo" in artifacts &&
      typeof artifacts.trimmedVideo === "string"
        ? path.join(root, artifacts.trimmedVideo)
        : undefined;
    const videoPath =
      artifacts &&
      typeof artifacts === "object" &&
      "video" in artifacts &&
      typeof artifacts.video === "string"
        ? path.join(root, artifacts.video)
        : undefined;
    const reportPath = writeReport({
      motionGifPath,
      motionVideoPath,
      outputDir,
      screenshotPath,
      status: summary.status === "pass" ? "pass" : "fail",
      summaryPath,
      videoPath,
    });
    summary.report = path.relative(root, reportPath);
    fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
    fs.rmSync(localRoot, { force: true, recursive: true });
    console.log(JSON.stringify({ outputDir, reportPath, status: summary.status }, null, 2));
  }

  if (summary.status !== "pass") {
    process.exitCode = 1;
  }
}

function isMainModule(): boolean {
  return Boolean(
    process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url),
  );
}

if (isMainModule()) {
  main().catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  });
}
