feat(telegram): add real user crabbox proof

This commit is contained in:
Ayaan Zaidi
2026-05-10 14:29:54 +05:30
parent 1b2f4d87ef
commit ecb7ea19a5
5 changed files with 1907 additions and 0 deletions

View File

@@ -572,9 +572,15 @@ Telegram, Discord, Slack, and WhatsApp lanes can lease credentials from a shared
Payload shapes the broker validates on `admin/add`:
- Telegram (`kind: "telegram"`): `{ groupId: string, driverToken: string, sutToken: string }` - `groupId` must be a numeric chat-id string.
- Telegram real user (`kind: "telegram-user"`): `{ groupId: string, sutToken: string, testerUserId: string, testerUsername: string, telegramApiId: string, telegramApiHash: string, tdlibDatabaseEncryptionKey: string, tdlibArchiveBase64: string, tdlibArchiveSha256: string, desktopTdataArchiveBase64: string, desktopTdataArchiveSha256: string }` - one exclusive burner-account lease used by both the TDLib CLI driver and Telegram Desktop visual witness.
- Discord (`kind: "discord"`): `{ guildId: string, channelId: string, driverBotToken: string, sutBotToken: string, sutApplicationId: string }`.
- WhatsApp (`kind: "whatsapp"`): `{ driverPhoneE164: string, sutPhoneE164: string, driverAuthArchiveBase64: string, sutAuthArchiveBase64: string, groupJid?: string }` - phone numbers must be distinct E.164 strings.
For visual real-user Telegram proof, prefer `pnpm qa:telegram-user:crabbox -- --text /status`.
It uses one Convex `telegram-user` lease for both the TDLib CLI driver and the
Telegram Desktop witness, captures a Crabbox recording plus motion-trimmed
video/GIF artifacts, and releases the lease on shutdown.
Slack lanes can also use the pool. Slack payload shape checks currently live in the Slack QA runner rather than the broker; use `{ channelId: string, driverBotToken: string, sutBotToken: string, sutAppToken: string }`, with a Slack channel id like `Cxxxxxxxxxx`. See [Setting up the Slack workspace](#setting-up-the-slack-workspace) for app and scope provisioning.
Operational env vars and the Convex broker endpoint contract live in [Testing → Shared Telegram credentials via Convex](/help/testing#shared-telegram-credentials-via-convex-v1) (the section name predates the multi-channel pool; the lease semantics are shared across kinds).

View File

@@ -404,6 +404,9 @@ Default endpoint contract (`OPENCLAW_QA_CONVEX_SITE_URL` + `/qa-credentials/v1`)
- Request: `{ kind, ownerId, actorRole, leaseTtlMs, heartbeatIntervalMs }`
- Success: `{ status: "ok", credentialId, leaseToken, payload, leaseTtlMs?, heartbeatIntervalMs? }`
- Exhausted/retryable: `{ status: "error", code: "POOL_EXHAUSTED" | "NO_CREDENTIAL_AVAILABLE", ... }`
- `POST /payload-chunk`
- Request: `{ kind, ownerId, actorRole, credentialId, leaseToken, index }`
- Success: `{ status: "ok", index, data }`
- `POST /heartbeat`
- Request: `{ kind, ownerId, actorRole, credentialId, leaseToken, leaseTtlMs }`
- Success: `{ status: "ok" }` (or empty `2xx`)
@@ -427,6 +430,46 @@ Payload shape for Telegram kind:
- `groupId` must be a numeric Telegram chat id string.
- `admin/add` validates this shape for `kind: "telegram"` and rejects malformed payloads.
Payload shape for Telegram real-user kind:
- `{ groupId: string, sutToken: string, testerUserId: string, testerUsername: string, telegramApiId: string, telegramApiHash: string, tdlibDatabaseEncryptionKey: string, tdlibArchiveBase64: string, tdlibArchiveSha256: string, desktopTdataArchiveBase64: string, desktopTdataArchiveSha256: string }`
- `groupId`, `testerUserId`, and `telegramApiId` must be numeric strings.
- `tdlibArchiveSha256` and `desktopTdataArchiveSha256` must be SHA-256 hex strings.
- `kind: "telegram-user"` represents one Telegram burner account. Treat the lease as account-wide: the TDLib CLI driver and Telegram Desktop visual witness restore from the same payload, and only one job should hold the lease at a time.
Telegram real-user lease restore:
```bash
tmp=$(mktemp -d /tmp/openclaw-telegram-user.XXXXXX)
node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore \
--user-driver-dir "$tmp/user-driver" \
--desktop-workdir "$tmp/desktop" \
--lease-file "$tmp/lease.json"
TELEGRAM_USER_DRIVER_STATE_DIR="$tmp/user-driver" \
uv run ~/.codex/skills/custom/telegram-e2e-bot-to-bot/scripts/user-driver.py status --json
node --import tsx scripts/e2e/telegram-user-credential.ts release --lease-file "$tmp/lease.json"
```
Use the restored Desktop profile with `Telegram -workdir "$tmp/desktop"` when a visual recording is needed. In local operator environments, `scripts/e2e/telegram-user-credential.ts` reads `~/.codex/skills/custom/telegram-e2e-bot-to-bot/convex.local.env` by default if process env vars are absent.
One-command Crabbox proof:
```bash
pnpm qa:telegram-user:crabbox -- --text /status
```
That command leases the `telegram-user` credential, restores the same account
into TDLib and Telegram Desktop on a Crabbox Linux desktop, starts a local mock
SUT gateway from the current checkout, sends the command as the real QA user,
records the visible Telegram Desktop session, trims the recording to the motion
window, writes artifacts under `.artifacts/qa-e2e/telegram-user-crabbox/`, then
releases the credential and stops the box. Use `--id <cbx_...>` to reuse a warm
desktop lease, `--keep-box` to keep VNC open after failure,
`--desktop-chat-title <name>` to pick the visible chat, and `--tdlib-url <tgz>`
when using a prebaked Linux `libtdjson.so` archive instead of building TDLib on
a fresh box. The runner verifies `--tdlib-url` with `--tdlib-sha256 <hex>` or,
by default, a sibling `<url>.sha256` file.
Broker-validated multi-channel payloads:
- Discord: `{ guildId: string, channelId: string, driverBotToken: string, sutBotToken: string, sutApplicationId: string, voiceChannelId?: string }`

View File

@@ -1506,6 +1506,7 @@
"qa:lab:up:fast": "node --import tsx scripts/qa-lab-up.ts --use-prebuilt-image --bind-ui-dist --skip-ui-build",
"qa:lab:watch": "vite build --watch --config extensions/qa-lab/web/vite.config.ts",
"qa:otel:smoke": "node --import tsx scripts/qa-otel-smoke.ts",
"qa:telegram-user:crabbox": "node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts",
"release-metadata:check": "node scripts/check-release-metadata-only.mjs",
"release:beta-smoke": "node --import tsx scripts/release-beta-smoke.ts",
"release:check": "pnpm release:generated:check && node --import tsx scripts/release-check.ts",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,618 @@
#!/usr/bin/env -S node --import tsx
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, copyFile, mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
type JsonObject = Record<string, unknown>;
const DEFAULT_USER_DRIVER_DIR = "~/.codex/skills/custom/telegram-e2e-bot-to-bot/user-driver";
const DEFAULT_BOT_CREDENTIALS_FILE =
"~/.codex/skills/custom/telegram-e2e-bot-to-bot/credentials.local.json";
const DEFAULT_CONVEX_ENV_FILE = "~/.codex/skills/custom/telegram-e2e-bot-to-bot/convex.local.env";
const TELEGRAM_USER_KIND = "telegram-user";
const CHUNKED_PAYLOAD_MARKER = "__openclawQaCredentialPayloadChunksV1";
function usage(): never {
throw new Error(
[
"Usage:",
" node --import tsx scripts/e2e/telegram-user-credential.ts export (--desktop-tdata-dir <path> | --desktop-tdata-archive <tdata.tgz>) --output <payload.json>",
" node --import tsx scripts/e2e/telegram-user-credential.ts restore --payload-file <payload.json> --user-driver-dir <path> --desktop-workdir <path>",
" node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore --user-driver-dir <path> --desktop-workdir <path> --lease-file <lease.json> [--payload-output <payload.json>] [--env-file <path>]",
" node --import tsx scripts/e2e/telegram-user-credential.ts release --lease-file <lease.json> [--env-file <path>]",
].join("\n"),
);
}
function printUsage() {
console.log(
[
"Usage:",
" node --import tsx scripts/e2e/telegram-user-credential.ts export (--desktop-tdata-dir <path> | --desktop-tdata-archive <tdata.tgz>) --output <payload.json>",
" node --import tsx scripts/e2e/telegram-user-credential.ts restore --payload-file <payload.json> --user-driver-dir <path> --desktop-workdir <path>",
" node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore --user-driver-dir <path> --desktop-workdir <path> --lease-file <lease.json> [--payload-output <payload.json>] [--env-file <path>]",
" node --import tsx scripts/e2e/telegram-user-credential.ts release --lease-file <lease.json> [--env-file <path>]",
].join("\n"),
);
}
function expandHome(path: string) {
if (path === "~") {
return process.env.HOME || path;
}
if (path.startsWith("~/")) {
return `${process.env.HOME || "~"}${path.slice(1)}`;
}
return path;
}
function parseArgs(argv: string[]) {
const args = argv.slice(2);
const command = args[0] || usage();
if (command === "--help" || command === "-h") {
printUsage();
process.exit(0);
}
const opts = new Map<string, string>();
for (let index = 1; index < args.length; index += 1) {
if (args[index] === "--") {
continue;
}
const key = args[index];
if (!key.startsWith("--")) {
usage();
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
usage();
}
opts.set(key.slice(2), value);
index += 1;
}
return { command, opts };
}
async function readJson(path: string): Promise<JsonObject> {
try {
return JSON.parse(await readFile(expandHome(path), "utf8")) as JsonObject;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return {};
}
throw error;
}
}
function fileExists(path: string) {
return readFile(expandHome(path))
.then(() => true)
.catch((error: unknown) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return false;
}
throw error;
});
}
async function readEnvFile(path: string) {
if (!(await fileExists(path))) {
return {};
}
const env: Record<string, string> = {};
const text = await readFile(expandHome(path), "utf8");
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const separator = trimmed.indexOf("=");
if (separator < 1) {
throw new Error(`Invalid env line in ${path}.`);
}
const key = trimmed.slice(0, separator).trim();
const value = trimmed
.slice(separator + 1)
.trim()
.replace(/^['"]|['"]$/gu, "");
env[key] = value;
}
return env;
}
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 optionalString(source: JsonObject, key: string) {
const value = source[key];
if (typeof value === "number") {
return String(value);
}
if (typeof value === "string" && value.trim()) {
return value.trim();
}
return undefined;
}
function optionalPositiveInteger(value: string | undefined, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`Expected positive integer, got ${value}.`);
}
return parsed;
}
async function fileSha256(path: string) {
return createHash("sha256")
.update(await readFile(path))
.digest("hex");
}
async function tgzBase64(path: string) {
return (await readFile(path)).toString("base64");
}
async function writePrivateJson(path: string, payload: JsonObject) {
const expanded = expandHome(path);
await mkdir(expanded.substring(0, expanded.lastIndexOf("/")), { recursive: true });
await writeFile(expanded, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
await chmodPrivate(expanded);
}
async function chmodPrivate(path: string) {
await chmod(path, 0o600);
}
function runCommand(command: string, args: string[], cwd?: string) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${command} ${args.join(" ")} failed with ${detail}\n${stdout}${stderr}`));
});
});
}
function joinBrokerEndpoint(siteUrl: string, endpoint: string) {
const normalized = siteUrl.replace(/\/+$/u, "");
return `${normalized}/qa-credentials/v1/${endpoint}`;
}
function assertBrokerSuccess(payload: JsonObject, action: string) {
if (payload.status === "error") {
throw new Error(
`${action} failed: ${requireString(payload, "code")} ${optionalString(payload, "message") || ""}`.trim(),
);
}
if (payload.status !== "ok") {
throw new Error(`${action} returned an invalid response.`);
}
}
async function postBroker(params: {
action: string;
body: JsonObject;
siteUrl: string;
token: string;
}) {
const response = await fetch(joinBrokerEndpoint(params.siteUrl, params.action), {
method: "POST",
headers: {
authorization: `Bearer ${params.token}`,
"content-type": "application/json",
},
body: JSON.stringify(params.body),
});
const payload = (await response.json()) as JsonObject;
if (!response.ok) {
assertBrokerSuccess(payload, params.action);
throw new Error(`${params.action} failed with HTTP ${response.status}.`);
}
assertBrokerSuccess(payload, params.action);
return payload;
}
async function resolveConvexLeaseConfig(opts: Map<string, string>) {
const envFile = opts.get("env-file") || DEFAULT_CONVEX_ENV_FILE;
const fileEnv = await readEnvFile(envFile);
const siteUrl =
opts.get("site-url") ||
process.env.OPENCLAW_QA_CONVEX_SITE_URL?.trim() ||
fileEnv.OPENCLAW_QA_CONVEX_SITE_URL;
const token =
opts.get("ci-secret") ||
process.env.OPENCLAW_QA_CONVEX_SECRET_CI?.trim() ||
fileEnv.OPENCLAW_QA_CONVEX_SECRET_CI;
if (!siteUrl) {
throw new Error("Missing OPENCLAW_QA_CONVEX_SITE_URL.");
}
if (!token) {
throw new Error("Missing OPENCLAW_QA_CONVEX_SECRET_CI.");
}
return {
siteUrl,
token,
leaseTtlMs: optionalPositiveInteger(
opts.get("lease-ttl-ms") ||
process.env.OPENCLAW_QA_CREDENTIAL_LEASE_TTL_MS?.trim() ||
fileEnv.OPENCLAW_QA_CREDENTIAL_LEASE_TTL_MS,
20 * 60 * 1_000,
),
heartbeatIntervalMs: optionalPositiveInteger(
opts.get("heartbeat-interval-ms") ||
process.env.OPENCLAW_QA_CREDENTIAL_HEARTBEAT_INTERVAL_MS?.trim() ||
fileEnv.OPENCLAW_QA_CREDENTIAL_HEARTBEAT_INTERVAL_MS,
30_000,
),
ownerId:
opts.get("owner-id") ||
process.env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
`telegram-user-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
};
}
function parseChunkedPayloadMarker(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const record = payload as Record<string, unknown>;
if (record[CHUNKED_PAYLOAD_MARKER] !== true) {
return null;
}
if (
typeof record.chunkCount !== "number" ||
!Number.isInteger(record.chunkCount) ||
record.chunkCount < 1
) {
throw new Error("Chunked payload marker has invalid chunkCount.");
}
if (
typeof record.byteLength !== "number" ||
!Number.isInteger(record.byteLength) ||
record.byteLength < 0
) {
throw new Error("Chunked payload marker has invalid byteLength.");
}
return {
chunkCount: record.chunkCount,
byteLength: record.byteLength,
};
}
async function hydratePayloadFromLease(params: {
acquired: JsonObject;
ownerId: string;
siteUrl: string;
token: string;
}) {
const marker = parseChunkedPayloadMarker(params.acquired.payload);
if (!marker) {
return params.acquired.payload as JsonObject;
}
const credentialId = requireString(params.acquired, "credentialId");
const leaseToken = requireString(params.acquired, "leaseToken");
const chunks: string[] = [];
for (let index = 0; index < marker.chunkCount; index += 1) {
const chunk = await postBroker({
action: "payload-chunk",
siteUrl: params.siteUrl,
token: params.token,
body: {
kind: TELEGRAM_USER_KIND,
ownerId: params.ownerId,
actorRole: "ci",
credentialId,
leaseToken,
index,
},
});
chunks.push(requireString(chunk, "data"));
}
const serialized = chunks.join("");
if (serialized.length !== marker.byteLength) {
throw new Error("Chunked payload length mismatch.");
}
return JSON.parse(serialized) as JsonObject;
}
async function createTelegramUserPayload(opts: Map<string, string>) {
const userDriverDir = expandHome(opts.get("user-driver-dir") || DEFAULT_USER_DRIVER_DIR);
const botCredentialsFile = expandHome(
opts.get("bot-credentials-file") || DEFAULT_BOT_CREDENTIALS_FILE,
);
const desktopTdataDir = opts.get("desktop-tdata-dir");
const desktopTdataArchiveInput = opts.get("desktop-tdata-archive");
const output = opts.get("output");
if (
(!desktopTdataDir && !desktopTdataArchiveInput) ||
(desktopTdataDir && desktopTdataArchiveInput) ||
!output
) {
usage();
}
const config = await readJson(`${userDriverDir}/config.local.json`);
const botCredentials = await readJson(botCredentialsFile);
const sutToken =
process.env.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN?.trim() ||
process.env.TELEGRAM_E2E_SUT_BOT_TOKEN?.trim() ||
(typeof botCredentials.sutBotToken === "string" ? botCredentials.sutBotToken.trim() : "") ||
(typeof botCredentials.botAToken === "string" ? botCredentials.botAToken.trim() : "") ||
(typeof botCredentials.BOTA === "string" ? botCredentials.BOTA.trim() : "");
if (!sutToken) {
throw new Error("Missing SUT token in env or bot credentials file.");
}
const groupId =
process.env.OPENCLAW_QA_TELEGRAM_GROUP_ID?.trim() ||
process.env.TELEGRAM_E2E_GROUP_ID?.trim() ||
(typeof config.defaultChatId === "string" ? config.defaultChatId.trim() : "") ||
(typeof botCredentials.groupId === "string" ? botCredentials.groupId.trim() : "");
if (!groupId) {
throw new Error("Missing group id in env, user-driver config, or bot credentials file.");
}
const tempRoot = `/tmp/openclaw-telegram-user-credential-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
const tdlibArchive = `${tempRoot}/tdlib.tgz`;
const desktopArchive = `${tempRoot}/desktop-tdata.tgz`;
await mkdir(tempRoot, { recursive: true });
try {
await runCommand("tar", ["-C", userDriverDir, "-czf", tdlibArchive, "db", "files"]);
if (desktopTdataArchiveInput) {
await copyFile(expandHome(desktopTdataArchiveInput), desktopArchive);
} else {
await runCommand("tar", [
"-C",
`${expandHome(desktopTdataDir!)}/..`,
"--exclude",
"tdata/countries",
"--exclude",
"tdata/dictionaries",
"--exclude",
"tdata/dumps",
"--exclude",
"tdata/emoji",
"--exclude",
"tdata/user_data",
"--exclude",
"tdata/working",
"-czf",
desktopArchive,
"tdata",
]);
}
await writePrivateJson(output, {
groupId,
sutToken,
testerUserId: requireString(config, "testerUserId"),
testerUsername: requireString(config, "testerUsername"),
telegramApiId: requireString(config, "apiId"),
telegramApiHash: requireString(config, "apiHash"),
tdlibDatabaseEncryptionKey: requireString(config, "databaseEncryptionKey"),
tdlibArchiveBase64: await tgzBase64(tdlibArchive),
tdlibArchiveSha256: await fileSha256(tdlibArchive),
desktopTdataArchiveBase64: await tgzBase64(desktopArchive),
desktopTdataArchiveSha256: await fileSha256(desktopArchive),
});
} finally {
await rm(tempRoot, { force: true, recursive: true });
}
}
async function restoreTelegramUserPayloadFromFile(opts: Map<string, string>) {
const payloadFile = opts.get("payload-file");
if (!payloadFile) {
usage();
}
await restoreTelegramUserPayload({
payload: await readJson(payloadFile),
userDriverDir: opts.get("user-driver-dir"),
desktopWorkdir: opts.get("desktop-workdir"),
});
}
async function restoreTelegramUserPayload(params: {
payload: JsonObject;
userDriverDir: string | undefined;
desktopWorkdir: string | undefined;
}) {
const userDriverDir = params.userDriverDir;
const desktopWorkdir = params.desktopWorkdir;
if (!userDriverDir || !desktopWorkdir) {
usage();
}
const payload = params.payload;
const tempRoot = `/tmp/openclaw-telegram-user-restore-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
const tdlibArchive = `${tempRoot}/tdlib.tgz`;
const desktopArchive = `${tempRoot}/desktop-tdata.tgz`;
await mkdir(tempRoot, { recursive: true });
await mkdir(expandHome(userDriverDir), { recursive: true });
await mkdir(expandHome(desktopWorkdir), { recursive: true });
try {
await writeFile(
tdlibArchive,
Buffer.from(requireString(payload, "tdlibArchiveBase64"), "base64"),
);
await writeFile(
desktopArchive,
Buffer.from(requireString(payload, "desktopTdataArchiveBase64"), "base64"),
);
if ((await fileSha256(tdlibArchive)) !== requireString(payload, "tdlibArchiveSha256")) {
throw new Error("TDLib archive SHA-256 mismatch.");
}
if (
(await fileSha256(desktopArchive)) !== requireString(payload, "desktopTdataArchiveSha256")
) {
throw new Error("Telegram Desktop archive SHA-256 mismatch.");
}
await runCommand("tar", ["-C", expandHome(userDriverDir), "-xzf", tdlibArchive]);
await runCommand("tar", ["-C", expandHome(desktopWorkdir), "-xzf", desktopArchive]);
await writePrivateJson(`${expandHome(userDriverDir)}/config.local.json`, {
apiId: Number(requireString(payload, "telegramApiId")),
apiHash: requireString(payload, "telegramApiHash"),
databaseEncryptionKey: requireString(payload, "tdlibDatabaseEncryptionKey"),
defaultChatId: requireString(payload, "groupId"),
testerUserId: Number(requireString(payload, "testerUserId")),
testerUsername: requireString(payload, "testerUsername"),
});
} finally {
await rm(tempRoot, { force: true, recursive: true });
}
}
async function leaseAndRestoreTelegramUser(opts: Map<string, string>) {
const userDriverDir = opts.get("user-driver-dir");
const desktopWorkdir = opts.get("desktop-workdir");
const leaseFile = opts.get("lease-file");
const payloadOutput = opts.get("payload-output");
if (!userDriverDir || !desktopWorkdir || !leaseFile) {
usage();
}
const config = await resolveConvexLeaseConfig(opts);
const acquired = await postBroker({
action: "acquire",
siteUrl: config.siteUrl,
token: config.token,
body: {
kind: TELEGRAM_USER_KIND,
ownerId: config.ownerId,
actorRole: "ci",
leaseTtlMs: config.leaseTtlMs,
heartbeatIntervalMs: config.heartbeatIntervalMs,
},
});
const lease = {
siteUrl: config.siteUrl,
kind: TELEGRAM_USER_KIND,
ownerId: config.ownerId,
actorRole: "ci",
credentialId: requireString(acquired, "credentialId"),
leaseToken: requireString(acquired, "leaseToken"),
};
try {
const payload = await hydratePayloadFromLease({
acquired,
siteUrl: config.siteUrl,
token: config.token,
ownerId: config.ownerId,
});
await restoreTelegramUserPayload({ payload, userDriverDir, desktopWorkdir });
await writePrivateJson(leaseFile, lease);
if (payloadOutput) {
await writePrivateJson(payloadOutput, payload);
}
console.log(
JSON.stringify(
{
status: "ok",
credentialId: lease.credentialId,
ownerId: lease.ownerId,
leaseFile,
userDriverDir,
desktopWorkdir,
testerUserId: requireString(payload, "testerUserId"),
testerUsername: requireString(payload, "testerUsername"),
groupId: requireString(payload, "groupId"),
},
null,
2,
),
);
} catch (error) {
await releaseTelegramUserLeaseBody({
siteUrl: lease.siteUrl,
token: config.token,
lease,
});
throw error;
}
}
async function releaseTelegramUserLeaseBody(params: {
siteUrl: string;
token: string;
lease: JsonObject;
}) {
return postBroker({
action: "release",
siteUrl: params.siteUrl,
token: params.token,
body: {
kind: requireString(params.lease, "kind"),
ownerId: requireString(params.lease, "ownerId"),
actorRole: requireString(params.lease, "actorRole"),
credentialId: requireString(params.lease, "credentialId"),
leaseToken: requireString(params.lease, "leaseToken"),
},
});
}
async function releaseTelegramUserLease(opts: Map<string, string>) {
const leaseFile = opts.get("lease-file");
if (!leaseFile) {
usage();
}
const config = await resolveConvexLeaseConfig(opts);
const lease = await readJson(leaseFile);
await releaseTelegramUserLeaseBody({
siteUrl: config.siteUrl,
token: config.token,
lease,
});
await unlink(expandHome(leaseFile)).catch((error: unknown) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return;
}
throw error;
});
console.log(
JSON.stringify({ status: "ok", credentialId: requireString(lease, "credentialId") }, null, 2),
);
}
const { command, opts } = parseArgs(process.argv);
if (command === "export") {
await createTelegramUserPayload(opts);
} else if (command === "restore") {
await restoreTelegramUserPayloadFromFile(opts);
} else if (command === "lease-restore") {
await leaseAndRestoreTelegramUser(opts);
} else if (command === "release") {
await releaseTelegramUserLease(opts);
} else {
usage();
}