diff --git a/extensions/qa-lab/src/crabline-transport.test.ts b/extensions/qa-lab/src/crabline-transport.test.ts index 51e8383e63c0..ee2e152f6f45 100644 --- a/extensions/qa-lab/src/crabline-transport.test.ts +++ b/extensions/qa-lab/src/crabline-transport.test.ts @@ -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 { diff --git a/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.test.ts index fdc0630d7ee7..90cb0f27a580 100644 --- a/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.test.ts @@ -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", }, }, diff --git a/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.ts index c60c2a5560f8..74ccab2b198c 100644 --- a/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/adapter.runtime.ts @@ -29,6 +29,21 @@ type FactoryContext = Parameters[0]; type AdapterDefinition = Awaited> & { cleanupAfterGatewayStop?: () => Promise; }; + +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 { @@ -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, diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index 971c1dfea8d0..36261fd74b03 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -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) => + 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: { diff --git a/qa/scenarios/channels/telegram-commands-command.yaml b/qa/scenarios/channels/telegram-commands-command.yaml index c8634da1f927..b57448a1577a 100644 --- a/qa/scenarios/channels/telegram-commands-command.yaml +++ b/qa/scenarios/channels/telegram-commands-command.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-context-command.yaml b/qa/scenarios/channels/telegram-context-command.yaml index 083879aa53ef..6a7b8f9a66ab 100644 --- a/qa/scenarios/channels/telegram-context-command.yaml +++ b/qa/scenarios/channels/telegram-context-command.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-help-command.yaml b/qa/scenarios/channels/telegram-help-command.yaml index c5b475ff9a58..eaff7df81336 100644 --- a/qa/scenarios/channels/telegram-help-command.yaml +++ b/qa/scenarios/channels/telegram-help-command.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-other-bot-command-gating.yaml b/qa/scenarios/channels/telegram-other-bot-command-gating.yaml index bea68e4e7441..f3caefa450b4 100644 --- a/qa/scenarios/channels/telegram-other-bot-command-gating.yaml +++ b/qa/scenarios/channels/telegram-other-bot-command-gating.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-repeated-command-authorization.yaml b/qa/scenarios/channels/telegram-repeated-command-authorization.yaml index 98bae19461b9..fbbee6b19c00 100644 --- a/qa/scenarios/channels/telegram-repeated-command-authorization.yaml +++ b/qa/scenarios/channels/telegram-repeated-command-authorization.yaml @@ -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 diff --git a/qa/scenarios/channels/telegram-startup-getme-live.yaml b/qa/scenarios/channels/telegram-startup-getme-live.yaml new file mode 100644 index 000000000000..9f3b2895e23d --- /dev/null +++ b/qa/scenarios/channels/telegram-startup-getme-live.yaml @@ -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} diff --git a/qa/scenarios/channels/telegram-startup-getme-outcomes.yaml b/qa/scenarios/channels/telegram-startup-getme-outcomes.yaml new file mode 100644 index 000000000000..610e26a3ded1 --- /dev/null +++ b/qa/scenarios/channels/telegram-startup-getme-outcomes.yaml @@ -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. diff --git a/qa/scenarios/channels/telegram-status-command.yaml b/qa/scenarios/channels/telegram-status-command.yaml index 400d63c98898..0630ac42ee1c 100644 --- a/qa/scenarios/channels/telegram-status-command.yaml +++ b/qa/scenarios/channels/telegram-status-command.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-token-source-precedence.yaml b/qa/scenarios/channels/telegram-token-source-precedence.yaml new file mode 100644 index 000000000000..17204748b9fd --- /dev/null +++ b/qa/scenarios/channels/telegram-token-source-precedence.yaml @@ -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. diff --git a/qa/scenarios/channels/telegram-tool-only-usage-footer.yaml b/qa/scenarios/channels/telegram-tool-only-usage-footer.yaml index 30c4b56cfd8e..cb2307879777 100644 --- a/qa/scenarios/channels/telegram-tool-only-usage-footer.yaml +++ b/qa/scenarios/channels/telegram-tool-only-usage-footer.yaml @@ -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" diff --git a/qa/scenarios/channels/telegram-tools-compact-command.yaml b/qa/scenarios/channels/telegram-tools-compact-command.yaml index be574422aa92..c057abfc4e48 100644 --- a/qa/scenarios/channels/telegram-tools-compact-command.yaml +++ b/qa/scenarios/channels/telegram-tools-compact-command.yaml @@ -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 } diff --git a/qa/scenarios/channels/telegram-whoami-command.yaml b/qa/scenarios/channels/telegram-whoami-command.yaml index 985c9d6312a3..042dcae241c4 100644 --- a/qa/scenarios/channels/telegram-whoami-command.yaml +++ b/qa/scenarios/channels/telegram-whoami-command.yaml @@ -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 } diff --git a/taxonomy.yaml b/taxonomy.yaml index d7dcec89f16c..393f03c2948e 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -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 diff --git a/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.test.ts b/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.test.ts index 16280d3163d5..3c6668acc675 100644 --- a/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.test.ts +++ b/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.test.ts @@ -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 | 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; + 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); }); }); diff --git a/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts b/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts index 421ccb2ca0ca..e50456d5ebca 100644 --- a/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts +++ b/test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts @@ -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, timeoutMs: number) { - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, 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[0], + ) => Promise; +}; + +const defaultDependencies: TelegramRuntimeDependencies = { + createInstance: createOpenClawTestInstance, +}; + +const wait = (durationMs: number) => + new Promise((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; - startupPromise: Promise; - timeoutMs: number; -}) { - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, 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((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; }); }