improve(qa): align Telegram coverage with startup behavior (#113527)

* test(telegram): align coverage with startup behavior

* fix(qa): register Slack MPIM coverage
This commit is contained in:
Dallin Romney
2026-07-29 12:59:42 +08:00
committed by GitHub
parent 233d16b76f
commit 96ea0fbf56
19 changed files with 448 additions and 150 deletions
@@ -195,6 +195,13 @@ describe("crabline transport", () => {
senderId: "alice",
senderName: "Alice",
});
await transport.sendInbound({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "/status",
nativeCommand: { name: "status" },
});
const config = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" });
const telegram = config.channels?.telegram as
@@ -211,6 +218,12 @@ describe("crabline transport", () => {
text: "/stop",
},
},
{
message: {
entities: [{ length: 7, offset: 0, type: "bot_command" }],
text: "/status",
},
},
],
});
} finally {
@@ -78,7 +78,7 @@ describe("Telegram QA transport adapter", () => {
getMeCalls += 1;
return getMeCalls === 1
? { id: 1, is_bot: true, first_name: "driver", username: "driver_bot" }
: { id: 2, is_bot: true, first_name: "sut", username: "sut_bot" };
: { id: 2, is_bot: true, first_name: "sut", username: "openclaw_qa_bot" };
}
if (method === "sendMessage") {
sendMessageCalls += 1;
@@ -111,7 +111,21 @@ describe("Telegram QA transport adapter", () => {
"sendMessage",
expect.objectContaining({
chat_id: "-100123",
text: "@sut_bot reply exactly: QA-MARKER",
text: "@openclaw_qa_bot reply exactly: QA-MARKER",
}),
);
await adapter.sendInbound?.({
conversation: { id: "logical-room", kind: "group" },
senderId: "driver",
text: "/status",
nativeCommand: { name: "status" },
});
expect(mocks.callTelegramApi).toHaveBeenCalledWith(
"placeholder",
"sendMessage",
expect.objectContaining({
chat_id: "-100123",
text: "/status@openclaw_qa_bot",
}),
);
@@ -122,7 +136,7 @@ describe("Telegram QA transport adapter", () => {
message_id: 11,
date: 100,
chat: { id: -100123 },
from: { id: 2, is_bot: true, username: "sut_bot" },
from: { id: 2, is_bot: true, username: "openclaw_qa_bot" },
text: "preview",
reply_to_message: { message_id: 10 },
},
@@ -161,7 +175,7 @@ describe("Telegram QA transport adapter", () => {
message_id: 11,
date: 101,
chat: { id: -100123 },
from: { id: 2, is_bot: true, username: "sut_bot" },
from: { id: 2, is_bot: true, username: "openclaw_qa_bot" },
text: "final",
},
},
@@ -29,6 +29,21 @@ type FactoryContext = Parameters<AdapterFactory["create"]>[0];
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>> & {
cleanupAfterGatewayStop?: () => Promise<void>;
};
function renderTelegramQaInboundText(
input: { text: string; nativeCommand?: { name: string } },
botUsername: string,
) {
const commandName = input.nativeCommand?.name.trim().toLowerCase();
const renderedText = input.text.replaceAll("@openclaw", `@${botUsername}`);
const commandToken = renderedText.match(/^\S+/u)?.[0];
// Scenarios declare command semantics once; the live adapter owns Telegram's
// bot-username targeting while local drivers may encode the same metadata differently.
return commandName && commandToken?.toLowerCase() === `/${commandName}`
? `/${commandName}@${botUsername}${renderedText.slice(commandToken.length)}`
: renderedText;
}
export async function createTelegramQaTransportAdapter(
context: FactoryContext,
): Promise<AdapterDefinition> {
@@ -58,6 +73,7 @@ export async function createTelegramQaTransportAdapter(
const runtimeEnv = credentialLease.payload;
let driverIdentity: TelegramBotIdentity;
let sutIdentity: TelegramBotIdentity;
let sutUsername: string;
let offset: number;
try {
[driverIdentity, sutIdentity] = await Promise.all([
@@ -73,6 +89,7 @@ export async function createTelegramQaTransportAdapter(
if (!sutIdentity.username?.trim()) {
throw new Error("Telegram QA requires the SUT bot to have a Telegram username.");
}
sutUsername = sutIdentity.username.trim();
[offset] = await Promise.all([
flushTelegramUpdates(runtimeEnv.driverToken),
flushTelegramUpdates(runtimeEnv.sutToken),
@@ -167,9 +184,7 @@ export async function createTelegramQaTransportAdapter(
heartbeat.throwIfFailed();
logicalConversationId = input.conversation.id;
logicalConversationKind = input.conversation.kind;
const text = sutIdentity.username
? input.text.replaceAll("@openclaw", `@${sutIdentity.username}`)
: input.text;
const text = renderTelegramQaInboundText(input, sutUsername);
const nativeReplyToId = input.replyToId ? nativeMessageIds.get(input.replyToId) : undefined;
const sent = await callTelegramApi<{ message_id: number }>(
runtimeEnv.driverToken,
+45
View File
@@ -285,6 +285,51 @@ describe("resolveTelegramToken", () => {
expect(res.source).toBe("tokenFile");
});
it("applies account file, account config, channel file, channel config, then env precedence", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token");
const accountTokenFile = createTokenFile("account-token.txt", "account-file-token\n");
const channelTokenFile = createTokenFile("channel-token.txt", "channel-file-token\n");
const baseTelegramConfig = {
botToken: "channel-config-token",
tokenFile: channelTokenFile,
accounts: {
default: {
botToken: "account-config-token",
tokenFile: accountTokenFile,
},
},
};
const resolve = (telegram: Record<string, unknown>) =>
resolveTelegramToken({ channels: { telegram } } as OpenClawConfig);
expect(resolve(baseTelegramConfig)).toEqual({
token: "account-file-token",
source: "tokenFile",
});
expect(
resolve({
...baseTelegramConfig,
accounts: { default: { botToken: "account-config-token" } },
}),
).toEqual({ token: "account-config-token", source: "config" });
expect(resolve({ ...baseTelegramConfig, accounts: { default: {} } })).toEqual({
token: "channel-file-token",
source: "tokenFile",
});
expect(
resolve({
...baseTelegramConfig,
tokenFile: undefined,
accounts: { default: {} },
}),
).toEqual({ token: "channel-config-token", source: "config" });
expect(resolve({ accounts: { default: {} } })).toEqual({
token: "env-token",
source: "env",
});
});
it("falls back to top-level tokenFile for non-default accounts", () => {
const cfg = {
channels: {
@@ -6,7 +6,6 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
objective: Verify Telegram renders the full native command catalog.
successCriteria:
@@ -34,8 +33,8 @@ flow:
conversation: { id: telegram-command-room, kind: group }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/commands@openclaw' : '/commands'"
text: /commands
nativeCommand: { name: commands }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: group }
sinceIndex: { ref: startIndex }
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
objective: Verify Telegram context help reaches native command routing.
successCriteria:
- The context command returns the context list shortcut.
@@ -33,8 +33,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/context@openclaw' : '/context'"
text: /context
nativeCommand: { name: context }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
objective: Verify the Telegram help command returns the concise command guide.
successCriteria:
- The help command is accepted in the Telegram group command path.
@@ -37,7 +37,10 @@ flow:
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? `/${config.command}@openclaw` : `/${config.command}`"
expr: "`/${config.command}`"
nativeCommand:
name:
expr: config.command
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.other-bot-command-gating
objective: Verify a command addressed to another Telegram bot does not wake OpenClaw.
successCriteria:
- The driver sends a native status command addressed to another bot.
@@ -32,6 +32,7 @@ flow:
senderId: qa-command-operator
senderName: QA Command Operator
text: /status@OpenClawQaOtherBot
nativeCommand: { name: status }
- waitForNoOutbound:
quietMs: 8000
sinceIndex: { ref: outboundStartIndex }
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.command-authorization-in-groups
objective: Verify an allowlisted Telegram bot operator remains authorized across repeated native commands.
successCriteria:
- The same operator is blocked while removed from the group allowlist.
@@ -25,12 +25,9 @@ flow:
steps:
- name: repeated commands reuse restored authorization
actions:
- assert:
expr: "transport.id === 'telegram'"
message: repeated authorization requires the live Telegram adapter
- set: authorizationState
value:
expr: "({ allowFrom: [] })"
expr: "({ allowFrom: [], hadGroupAllowFrom: false })"
- try:
actions:
- call: env.gateway.restartAfterStateMutation
@@ -38,7 +35,7 @@ flow:
- lambda:
async: true
params: [ctx]
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts[transport.accountId]; const group = account.groups[Object.keys(account.groups)[0]]; authorizationState.allowFrom = [...(group.allowFrom ?? [])]; group.allowFrom = []; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts?.[transport.accountId] ?? cfg.channels.telegram; const group = account.groups[Object.keys(account.groups)[0]]; authorizationState.hadGroupAllowFrom = Array.isArray(group.allowFrom); authorizationState.allowFrom = [...(group.allowFrom ?? [])]; group.allowFrom = []; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
- call: waitForTransportReady
args: [{ ref: env }, 60000]
- resetTransport: true
@@ -46,7 +43,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text: /status@openclaw
text: /status
nativeCommand: { name: status }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: You are not authorized to use this command.
@@ -57,7 +55,7 @@ flow:
- lambda:
async: true
params: [ctx]
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts[transport.accountId]; const group = account.groups[Object.keys(account.groups)[0]]; group.allowFrom = authorizationState.allowFrom; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts?.[transport.accountId] ?? cfg.channels.telegram; const group = account.groups[Object.keys(account.groups)[0]]; if (authorizationState.hadGroupAllowFrom) group.allowFrom = authorizationState.allowFrom; else delete group.allowFrom; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
- call: waitForTransportReady
args: [{ ref: env }, 60000]
- resetTransport: true
@@ -65,7 +63,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text: /status@openclaw
text: /status
nativeCommand: { name: status }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: "Session:"
@@ -74,7 +73,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text: /help@openclaw
text: /help
nativeCommand: { name: help }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: /commands for full list
@@ -83,7 +83,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text: /commands@openclaw
text: /commands
nativeCommand: { name: commands }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: /stop
@@ -0,0 +1,31 @@
title: Telegram product-startup getMe live
scenario:
id: telegram-startup-getme-live
surface: channels
category: channels.channel-setup
coverage:
primary:
- telegram.startup-getme
objective: Verify a leased Telegram bot reaches polling through the real Gateway product-startup getMe path.
successCriteria:
- The live producer launches an isolated real OpenClaw Gateway with the leased bot configured on a named Telegram account.
- Product startAccount logs the getMe-derived bot username before the polling monitor starts.
- Missing dedicated leased credentials produce blocked evidence and never pass startup coverage.
docsRefs:
- docs/channels/telegram.md
- docs/gateway/config-channels.md
codeRefs:
- test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts
- extensions/telegram/src/channel.ts
- extensions/telegram/src/probe.ts
- extensions/telegram/src/monitor.ts
execution:
kind: script
channel: telegram
path: test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts
summary: Launch a real isolated Gateway and require product-startup getMe before Telegram polling begins.
timeoutMs: 240000
args:
- --output-dir
- ${outputDir}
@@ -0,0 +1,26 @@
title: Telegram product-startup getMe outcomes
scenario:
id: telegram-startup-getme-outcomes
surface: channels
category: channels.channel-setup
coverage:
primary:
- telegram.startup-getme
objective: Verify the Telegram gateway owner handles startup getMe success, authentication failure, cache fallback, and probe throttling before monitor startup.
successCriteria:
- A successful product-startup probe passes botInfo into the polling monitor and refreshes the cache.
- A 401 rejects startup before the monitor, while a non-authentication failure may reuse matching cached botInfo.
- Concurrent account probes respect the shared startup limit and an aborted queued account never probes.
docsRefs:
- docs/channels/telegram.md
- docs/gateway/config-channels.md
codeRefs:
- extensions/telegram/src/channel.ts
- extensions/telegram/src/channel.gateway.test.ts
- extensions/telegram/src/bot-info-cache.ts
- extensions/telegram/src/startup-probe-limiter.ts
execution:
kind: vitest
path: extensions/telegram/src/channel.gateway.test.ts
summary: Run the Telegram gateway startAccount outcome, cache, and cross-account probe-limiter assertions.
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
regressionRefs:
- openclaw/openclaw#74698
objective: Verify Telegram status returns model, session, and activation details.
@@ -35,8 +35,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/status@openclaw' : '/status'"
text: /status
nativeCommand: { name: status }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
@@ -0,0 +1,24 @@
title: Telegram token-source precedence
scenario:
id: telegram-token-source-precedence
surface: channels
category: channels.channel-setup
coverage:
primary:
- telegram.telegram-bot-token
objective: Verify Telegram resolves account and channel token sources in the documented product precedence order.
successCriteria:
- Account tokenFile wins over account botToken and all channel-level sources.
- Account botToken wins over channel tokenFile, channel botToken, and TELEGRAM_BOT_TOKEN.
- Channel tokenFile wins over channel botToken and TELEGRAM_BOT_TOKEN, while channel botToken wins over TELEGRAM_BOT_TOKEN.
docsRefs:
- docs/channels/telegram.md
- docs/gateway/config-channels.md
codeRefs:
- extensions/telegram/src/token.ts
- extensions/telegram/src/token.test.ts
execution:
kind: vitest
path: extensions/telegram/src/token.test.ts
summary: Run the Telegram token resolver assertions, including the complete account-to-environment precedence chain.
@@ -8,7 +8,7 @@ scenario:
primary:
- observability.model-usage
secondary:
- channels.channel-native-commands
- telegram.built-in-commands
regressionRefs:
- openclaw/openclaw#87392
objective: Verify Telegram usage mode decorates a message-tool-only visible reply.
@@ -34,8 +34,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/usage@openclaw tokens' : '/usage tokens'"
text: /usage tokens
nativeCommand: { name: usage }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: tokens
@@ -70,8 +70,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/usage@openclaw off' : '/usage off'"
text: /usage off
nativeCommand: { name: usage }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
textIncludes: "Usage footer: off"
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
objective: Verify Telegram renders the compact model-tool inventory.
successCriteria:
- The compact tools command reaches native command dispatch.
@@ -33,8 +33,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/tools@openclaw compact' : '/tools compact'"
text: /tools compact
nativeCommand: { name: tools }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
@@ -6,7 +6,7 @@ scenario:
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- channels.channel-native-commands
- telegram.built-in-commands
objective: Verify Telegram command identity includes the active channel context.
successCriteria:
- The whoami command returns an identity block.
@@ -31,8 +31,8 @@ flow:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text:
expr: "transport.id === 'telegram' ? '/whoami@openclaw' : '/whoami'"
text: /whoami
nativeCommand: { name: whoami }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
+16 -4
View File
@@ -7955,15 +7955,15 @@ surfaces:
- name: BotFather token creation
coverageIds: [telegram.botfather-token-creation]
description: BotFather token creation and first gateway start
- name: TELEGRAM_BOT_TOKEN
- name: Token-source precedence
coverageIds: [telegram.telegram-bot-token]
description: TELEGRAM_BOT_TOKEN, botToken, tokenFile, and account-scoped token
description: Account tokenFile, account botToken, channel tokenFile, channel botToken, then TELEGRAM_BOT_TOKEN precedence.
- name: Setup wizard credential capture
coverageIds: [telegram.setup-wizard-credential-capture]
description: Setup wizard credential capture, allowlist prompts, and DM policy defaults
- name: Startup getMe
coverageIds: [telegram.startup-getme]
description: Startup getMe, bot-info cache, account throttling, and multi-account default
description: Product-startup getMe success and authentication failure, bot-info cache fallback, and account probe throttling.
- name: Doctor/status surfacing
coverageIds: [telegram.doctor-status-surfacing]
description: Doctor/status surfacing for invalid tokens, missing defaults, and read-only
@@ -8094,7 +8094,13 @@ surfaces:
description: Built-in commands such as /help, /commands, /whoami, /status, and related command UI.
- name: Command authorization in DMs
coverageIds: [telegram.command-authorization-in-dms]
description: Command authorization in DMs, groups, and commands addressed to other bots
description: Command authorization for Telegram direct messages.
- name: Command authorization in groups
coverageIds: [telegram.command-authorization-in-groups]
description: Group allowlist authorization for Telegram commands.
- name: Other-bot command gating
coverageIds: [telegram.other-bot-command-gating]
description: Commands addressed to another Telegram bot remain ignored.
- name: Model buttons
coverageIds: [telegram.model-buttons]
description: Model buttons and command UI helpers
@@ -8362,6 +8368,12 @@ surfaces:
- name: Sender Authorization
coverageIds: [slack.sender-authorization]
description: Covers Sender Authorization across Slack DM routing, `dmPolicy`, `allowFrom`, pairing approvals, group DMs/MPIMs, account-level allowlist inheritance, command authorization in DMs, and sender identity normalization.
- name: MPIM routing
coverageIds: [slack.mpim]
description: Covers group direct-message routing and app-mention dispatch in Slack MPIM conversations.
- name: Delivery dedupe
coverageIds: [slack.delivery-dedupe]
description: Covers single-delivery enforcement when one Slack event arrives through overlapping message and app-mention paths.
docs:
- docs/channels/slack.md
- docs/channels/bot-loop-protection.md
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runTelegramBotTokenRuntime, testing } from "./telegram-bot-token-runtime.js";
const tempDirs: string[] = [];
@@ -30,14 +30,107 @@ describe("telegram bot token runtime evidence", () => {
);
expect(evidence.entries[0]?.result.status).toBe("blocked");
const log = await fs.readFile(path.join(artifactBase, "telegram-bot-token.log"), "utf8");
const log = await fs.readFile(
path.join(artifactBase, "telegram-startup-getme-live.log"),
"utf8",
);
expect(log).toContain("blocked");
expect(log).not.toContain("generic-token");
});
it("bounds monitor shutdown", async () => {
await expect(testing.waitForMonitorShutdown(new Promise(() => {}), 10)).rejects.toThrow(
"Telegram runtime shutdown timed out",
it("launches the real Gateway shape and redacts the leased token from evidence", async () => {
const artifactBase = await fs.mkdtemp(path.join(os.tmpdir(), "telegram-startup-getme-"));
tempDirs.push(artifactBase);
const leasedToken = "123456:leased-secret";
const cleanup = vi.fn(async () => undefined);
const startGateway = vi.fn(async () => undefined);
let instanceOptions: Record<string, unknown> | undefined;
const evidence = await runTelegramBotTokenRuntime(
{ artifactBase, repoRoot: process.cwd(), startupTimeoutMs: 100 },
{ OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: leasedToken },
{
createInstance: async (options) => {
instanceOptions = options as unknown as Record<string, unknown>;
return {
cleanup,
startGateway,
logs: () =>
`[qa-live] starting provider (@qa_test_bot) token=${leasedToken}\n` +
"[telegram][diag] polling cycle started\n",
};
},
},
);
expect(evidence.entries[0]?.result.status).toBe("pass");
expect(startGateway).toHaveBeenCalledOnce();
expect(cleanup).toHaveBeenCalledOnce();
expect(instanceOptions).toMatchObject({
config: {
channels: {
telegram: {
defaultAccount: "qa-live",
accounts: { "qa-live": { botToken: leasedToken } },
},
},
},
env: {
OPENCLAW_SKIP_CHANNELS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
},
});
const log = await fs.readFile(
path.join(artifactBase, "telegram-startup-getme-live.log"),
"utf8",
);
expect(log).toContain("product startAccount resolved getMe bot identity before polling");
expect(log).toContain("[REDAC");
expect(log).not.toContain(leasedToken);
});
it("requires product startup to precede polling and bounds the wait", async () => {
await expect(
testing.waitForProductStartup(
{
cleanup: async () => undefined,
startGateway: async () => undefined,
logs: () =>
"[telegram][diag] polling cycle started\n" +
"[qa-live] starting provider (@qa_test_bot)\n",
},
10,
),
).rejects.toThrow("product startup getMe timed out");
});
it("redacts leased tokens from startup failure evidence", async () => {
const artifactBase = await fs.mkdtemp(path.join(os.tmpdir(), "telegram-startup-failure-"));
tempDirs.push(artifactBase);
const leasedToken = "123456:leased-secret";
const evidence = await runTelegramBotTokenRuntime(
{ artifactBase, repoRoot: process.cwd(), startupTimeoutMs: 100 },
{ OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: leasedToken },
{
createInstance: async () => ({
cleanup: async () => undefined,
logs: () => "",
startGateway: async () => {
throw new Error(
`Telegram startup failed for https://api.telegram.org/bot${leasedToken}/getMe`,
);
},
}),
},
);
expect(evidence.entries[0]?.result.status).toBe("fail");
expect(JSON.stringify(evidence)).not.toContain(leasedToken);
const log = await fs.readFile(
path.join(artifactBase, "telegram-startup-getme-live.log"),
"utf8",
);
expect(log).not.toContain(leasedToken);
});
});
@@ -1,14 +1,18 @@
// Telegram bot-token runtime evidence starts the real monitor through getMe.
// Telegram startup evidence launches the real Gateway product path through getMe.
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { monitorTelegramProvider } from "../../../../extensions/telegram/runtime-api.js";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import {
createOpenClawTestInstance,
type OpenClawTestInstance,
} from "../../../helpers/openclaw-test-instance.js";
import { createQaScriptEvidenceWriter } from "./script-evidence.js";
const STARTUP_TIMEOUT_MS = 30_000;
const LIVE_ACCOUNT_ID = "qa-live";
const PRODUCT_STARTUP_LOG = `[${LIVE_ACCOUNT_ID}] starting provider (@`;
const POLLING_STARTUP_LOGS = ["isolated polling ingress started", "polling cycle started"] as const;
const TOKEN_ENV_KEYS = [
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
"TELEGRAM_E2E_SUT_BOT_TOKEN",
@@ -20,18 +24,62 @@ type TelegramRuntimeOptions = {
startupTimeoutMs: number;
};
async function waitForMonitorShutdown(monitorPromise: Promise<void>, timeoutMs: number) {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error("Telegram runtime shutdown timed out")), timeoutMs);
type TelegramProductStartupInstance = Pick<
OpenClawTestInstance,
"child" | "cleanup" | "logs" | "startGateway"
>;
type TelegramRuntimeDependencies = {
createInstance: (
options: Parameters<typeof createOpenClawTestInstance>[0],
) => Promise<TelegramProductStartupInstance>;
};
const defaultDependencies: TelegramRuntimeDependencies = {
createInstance: createOpenClawTestInstance,
};
const wait = (durationMs: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
try {
await Promise.race([monitorPromise.catch(() => undefined), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
function sanitizeRuntimeLogs(logs: string, token: string) {
if (!token) {
return logs;
}
return logs
.replaceAll(token, "[REDACTED_TELEGRAM_TOKEN]")
.replaceAll(encodeURIComponent(token), "[REDACTED_TELEGRAM_TOKEN]");
}
async function waitForProductStartup(instance: TelegramProductStartupInstance, timeoutMs: number) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const logs = instance.logs();
const productStartupIndex = logs.indexOf(PRODUCT_STARTUP_LOG);
const pollingStartupIndex = Math.min(
...POLLING_STARTUP_LOGS.map((marker) => {
const index = logs.indexOf(marker);
return index < 0 ? Number.POSITIVE_INFINITY : index;
}),
);
if (
productStartupIndex >= 0 &&
Number.isFinite(pollingStartupIndex) &&
pollingStartupIndex > productStartupIndex
) {
return;
}
if (
instance.child &&
(instance.child.exitCode !== null || instance.child.signalCode !== null)
) {
throw new Error("Telegram Gateway stopped before product startup completed");
}
await wait(50);
}
throw new Error("Telegram product startup getMe timed out before polling began");
}
function parseOptions(argv: string[], repoRoot = process.cwd()): TelegramRuntimeOptions {
@@ -71,64 +119,38 @@ function resolveLeasedToken(env: NodeJS.ProcessEnv = process.env) {
function createWriter(options: TelegramRuntimeOptions) {
return createQaScriptEvidenceWriter({
artifactBase: options.artifactBase,
logFileName: "telegram-bot-token.log",
logFileName: "telegram-startup-getme-live.log",
primaryModel: "telegram/bot-api",
providerMode: "live-frontier",
repoRoot: options.repoRoot,
target: {
id: "telegram-bot-token",
title: "Telegram bot token runtime startup",
id: "telegram-startup-getme-live",
title: "Telegram product-startup getMe live",
sourcePath: "test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts",
docsRefs: ["docs/channels/telegram.md"],
codeRefs: [
"test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts",
"extensions/telegram/src/channel.ts",
"extensions/telegram/src/probe.ts",
"extensions/telegram/src/monitor.ts",
"extensions/telegram/src/polling-session.ts",
],
},
});
}
async function waitForStartup(params: {
abortController: AbortController;
monitorPromise: Promise<void>;
startupPromise: Promise<void>;
timeoutMs: number;
}) {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error("Telegram runtime startup timed out")),
params.timeoutMs,
);
});
try {
await Promise.race([
params.startupPromise,
params.monitorPromise.then(() => {
throw new Error("Telegram runtime stopped before polling startup");
}),
timeoutPromise,
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
params.abortController.abort();
await waitForMonitorShutdown(params.monitorPromise, params.timeoutMs);
}
}
export async function runTelegramBotTokenRuntime(
options: TelegramRuntimeOptions,
env: NodeJS.ProcessEnv = process.env,
dependencies: TelegramRuntimeDependencies = defaultDependencies,
) {
await fs.mkdir(options.artifactBase, { recursive: true });
const writer = createWriter(options);
const startedAt = Date.now();
const credential = resolveLeasedToken(env);
if (!credential) {
writer.appendLog(`telegram-bot-token: blocked; none of ${TOKEN_ENV_KEYS.join(", ")} is set\n`);
writer.appendLog(
`telegram-startup-getme: blocked; none of ${TOKEN_ENV_KEYS.join(", ")} is set\n`,
);
return await writer.write({
details: "Telegram runtime proof requires a leased bot token",
durationMs: Math.max(1, Date.now() - startedAt),
@@ -136,81 +158,80 @@ export async function runTelegramBotTokenRuntime(
});
}
writer.appendLog(`telegram-bot-token: using leased credential from ${credential.key}\n`);
const abortController = new AbortController();
let markStarted: (() => void) | undefined;
const startupPromise = new Promise<void>((resolve) => {
markStarted = resolve;
});
const runtime: RuntimeEnv = {
log: (...args) => {
const line = args.map(String).join(" ");
writer.appendLog(`${line}\n`);
if (
line.includes("isolated polling ingress started") ||
line.includes("polling cycle started")
) {
markStarted?.();
}
},
error: (...args) => writer.appendLog(`${args.map(String).join(" ")}\n`),
exit: (code) => {
throw new Error(`Telegram runtime requested exit ${code}`);
},
};
const config: OpenClawConfig = {
channels: { telegram: { enabled: true } },
};
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = path.join(options.artifactBase, "state");
writer.appendLog(`telegram-startup-getme: using leased credential from ${credential.key}\n`);
let instance: TelegramProductStartupInstance | undefined;
try {
const monitorPromise = monitorTelegramProvider({
abortSignal: abortController.signal,
config,
isolatedIngress: { enabled: true },
runtime,
token: credential.token,
instance = await dependencies.createInstance({
name: "qa-telegram-startup-getme",
config: {
channels: {
telegram: {
enabled: true,
defaultAccount: LIVE_ACCOUNT_ID,
dmPolicy: "disabled",
groupPolicy: "disabled",
commands: { native: false, nativeSkills: false },
accounts: {
[LIVE_ACCOUNT_ID]: {
enabled: true,
botToken: credential.token,
},
},
},
},
},
env: {
OPENCLAW_SKIP_CHANNELS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
TELEGRAM_BOT_TOKEN: "qa-invalid-precedence-decoy",
},
startTimeoutMs: options.startupTimeoutMs,
});
await waitForStartup({
abortController,
monitorPromise,
startupPromise,
timeoutMs: options.startupTimeoutMs,
});
writer.appendLog("telegram-bot-token: runtime started after Telegram getMe\n");
await instance.startGateway();
await waitForProductStartup(instance, options.startupTimeoutMs);
writer.appendLog(sanitizeRuntimeLogs(instance.logs(), credential.token));
writer.appendLog(
"telegram-startup-getme: product startAccount resolved getMe bot identity before polling\n",
);
await instance.cleanup();
instance = undefined;
return await writer.write({
details: `Telegram runtime startup completed with ${credential.key}`,
details: `Telegram product-startup getMe completed with ${credential.key}`,
durationMs: Math.max(1, Date.now() - startedAt),
status: "pass",
});
} catch (error) {
const details = formatErrorMessage(error);
writer.appendLog(`telegram-bot-token: ${details}\n`);
const details = sanitizeRuntimeLogs(formatErrorMessage(error), credential.token);
if (instance) {
writer.appendLog(sanitizeRuntimeLogs(instance.logs(), credential.token));
}
writer.appendLog(`telegram-startup-getme: ${details}\n`);
return await writer.write({
details,
durationMs: Math.max(1, Date.now() - startedAt),
status: "fail",
});
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await instance?.cleanup().catch(() => undefined);
}
}
export const testing = { parseOptions, resolveLeasedToken, waitForMonitorShutdown };
export const testing = {
parseOptions,
resolveLeasedToken,
sanitizeRuntimeLogs,
waitForProductStartup,
};
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
runTelegramBotTokenRuntime(parseOptions(process.argv.slice(2)))
.then((evidence) => {
const status = evidence.entries[0]?.result.status;
process.stdout.write(`telegram-bot-token: ${status}\n`);
process.stdout.write(`telegram-startup-getme: ${status}\n`);
process.exitCode = status === "fail" ? 1 : 0;
})
.catch((error: unknown) => {
process.stderr.write(`telegram-bot-token: ${formatErrorMessage(error)}\n`);
process.stderr.write(`telegram-startup-getme: ${formatErrorMessage(error)}\n`);
process.exitCode = 1;
});
}