mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
feat(telegram): add real user crabbox proof
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 }`
|
||||
|
||||
@@ -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",
|
||||
|
||||
1239
scripts/e2e/telegram-user-crabbox-proof.ts
Normal file
1239
scripts/e2e/telegram-user-crabbox-proof.ts
Normal file
File diff suppressed because it is too large
Load Diff
618
scripts/e2e/telegram-user-credential.ts
Normal file
618
scripts/e2e/telegram-user-credential.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user