diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 9e604cc4d52..acb9d3aa06d 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -50,7 +50,9 @@ Telegram uses a bot token from [BotFather](https://t.me/BotFather). Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token. For Telegram group chats, disable privacy mode before testing group replies: in @BotFather, run `/setprivacy`, choose the bot, then choose **Disable**. After changing privacy mode, remove the bot from each Telegram group and add it back so Telegram applies the new delivery setting to that group. -`TELEGRAM_ALLOWED_IDS` is a comma-separated list of Telegram user IDs for DM access. +`TELEGRAM_ALLOWED_IDS` is a comma-separated list of Telegram user or private-chat IDs for DM access. +For compatibility with older QA scripts, NemoClaw also treats `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` as aliases, but new automation should use `TELEGRAM_ALLOWED_IDS`. +Keep these aliases until QA automation and public repro templates have stopped exporting them for at least one full release. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. Set `TELEGRAM_REQUIRE_MENTION=1` to make the bot reply in Telegram groups only when users mention it. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. @@ -167,6 +169,8 @@ If applying the preset fails, NemoClaw warns and tells you to re-apply manually Choose the rebuild so the running sandbox image picks up the new channel. For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. +Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user. +For a repeatable live Telegram reply check, run `test/e2e/test-messaging-providers.sh` with `TELEGRAM_BOT_TOKEN_REAL`, `TELEGRAM_AUTHORIZED_CHAT_IDS` or `TELEGRAM_CHAT_ID`, and `NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E=1`. If you defer the rebuild, apply the change later: ```console diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 6a49696f882..b89ebeac30a 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -931,6 +931,14 @@ New Telegram bots default to privacy mode enabled, which prevents group messages In @BotFather, run `/setprivacy`, choose the bot, and choose **Disable**. Then remove the bot from the affected group and add it back; Telegram applies the privacy-mode change to group delivery only after the bot rejoins. +For Telegram direct messages, make sure the rebuilt sandbox has a DM allowlist. +Set `TELEGRAM_ALLOWED_IDS` before rebuild; `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` are accepted as compatibility aliases. +Keep the aliases until QA automation and public repro templates have stopped exporting them for at least one full release. +Bot API `sendMessage` sends from the bot to a chat, so it only proves outbound Telegram API access. +To prove inbound agent routing, send a message from the Telegram client as an allowed user and then watch the gateway log for the agent turn and outbound reply. +For a reproducible live check that also exercises an alias, run `test/e2e/test-messaging-providers.sh` with `TELEGRAM_BOT_TOKEN_REAL`, either `TELEGRAM_AUTHORIZED_CHAT_IDS` or `TELEGRAM_CHAT_ID`, and `NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E=1`; when prompted, send a fresh direct message from that Telegram client. +The check waits for `[telegram] [default] inbound update received` and `[telegram] [default] outbound sendMessage attempted` in `/tmp/gateway.log`. + To diagnose, open a shell in the sandbox and inspect the gateway log: ```console diff --git a/nemoclaw-blueprint/scripts/telegram-diagnostics.js b/nemoclaw-blueprint/scripts/telegram-diagnostics.js index fc88ffae80c..e1ca1a61f8f 100644 --- a/nemoclaw-blueprint/scripts/telegram-diagnostics.js +++ b/nemoclaw-blueprint/scripts/telegram-diagnostics.js @@ -22,6 +22,9 @@ var startupProbeLogged = false; var inferenceLogged = false; var credentialLogged = false; + var runtimeConfigLogged = false; + var sendMessageLogged = false; + var inboundUpdateLogged = false; var inDiagnosticWrite = false; function sanitize(value) { @@ -76,9 +79,15 @@ return { hostname: hostname, path: path }; } - function isTelegramStartupProbe(info) { + function telegramApiMethod(info) { if (!info || info.hostname !== 'api.telegram.org') return; - return /\/(?:bot[^/]+\/)?(?:getUpdates|getMe|getWebhookInfo)(?:\?|$)/.test(info.path); + var match = /\/(?:bot[^/]+\/)?([^/?]+)(?:\?|$)/.exec(info.path || ''); + return match && match[1] ? match[1] : ''; + } + + function isTelegramStartupProbe(info) { + var method = telegramApiMethod(info); + return method === 'getUpdates' || method === 'getMe' || method === 'getWebhookInfo'; } function maybeLogTelegramStartupProbe(info, statusCode) { @@ -110,20 +119,102 @@ emit('[telegram] [default] Bot API startup probe failed: ' + sanitize(detail).slice(0, 300)); } - function readTelegramBotToken(config) { - if (!config || typeof config !== 'object') return ''; + function maybeLogTelegramSendMessage(info, statusCode) { + if (sendMessageLogged || telegramApiMethod(info) !== 'sendMessage') return; + sendMessageLogged = true; + emit('[telegram] [default] outbound sendMessage attempted; Bot API returned HTTP ' + Number(statusCode || 0)); + } + + function senderAllowlistState(senderId) { + if (senderId === undefined || senderId === null) return 'unknown'; + var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; + try { + var fs = require('fs'); + var account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, 'utf8'))); + if (!account || account.dmPolicy !== 'allowlist') return 'not-applicable'; + var allowFrom = Array.isArray(account.allowFrom) ? account.allowFrom.map(String) : []; + return allowFrom.indexOf(String(senderId)) === -1 ? 'false' : 'true'; + } catch (_e) { + return 'unknown'; + } + } + + function maybeLogTelegramInboundUpdate(info, body) { + if (inboundUpdateLogged || telegramApiMethod(info) !== 'getUpdates') return; + var payload = null; + try { + payload = JSON.parse(String(body || '')); + } catch (_e) { + return; + } + if (!payload || payload.ok !== true || !Array.isArray(payload.result)) return; + for (var i = 0; i < payload.result.length; i += 1) { + var update = payload.result[i]; + if (!update || typeof update !== 'object') continue; + var message = update.message || update.edited_message || update.channel_post || update.edited_channel_post; + if (!message || typeof message !== 'object') continue; + inboundUpdateLogged = true; + var chat = message.chat && typeof message.chat === 'object' ? message.chat : {}; + var from = message.from && typeof message.from === 'object' ? message.from : {}; + var chatType = typeof chat.type === 'string' ? sanitize(chat.type).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) : 'unknown'; + var updateIdState = update.update_id === undefined || update.update_id === null ? 'missing' : 'present'; + var messageIdState = message.message_id === undefined || message.message_id === null ? 'missing' : 'present'; + emit( + '[telegram] [default] inbound update received (update_id=' + + updateIdState + + '; message_id=' + + messageIdState + + '; chat_type=' + + chatType + + '; sender_allowlisted=' + + senderAllowlistState(from.id) + + ')' + ); + return; + } + } + + function readTelegramAccount(config) { + if (!config || typeof config !== 'object') return null; var channel = config.channels && config.channels.telegram; - if (!channel || typeof channel !== 'object') return ''; + if (!channel || typeof channel !== 'object') return null; var accounts = channel.accounts; - if (!accounts || typeof accounts !== 'object') return ''; + if (!accounts || typeof accounts !== 'object') return null; var account = accounts.default || accounts.main; if (!account || typeof account !== 'object') { var keys = Object.keys(accounts); account = keys.length ? accounts[keys[0]] : null; } + return account && typeof account === 'object' ? account : null; + } + + function readTelegramBotToken(config) { + var account = readTelegramAccount(config); return account && typeof account.botToken === 'string' ? account.botToken : ''; } + function maybeLogRuntimeConfigDiagnostics() { + if (runtimeConfigLogged) return; + runtimeConfigLogged = true; + var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; + var account = null; + try { + var fs = require('fs'); + account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, 'utf8'))); + } catch (_e) { + return; + } + if (!account) return; + var allowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : []; + if (account.dmPolicy === 'allowlist') { + if (allowFrom.length > 0) { + emit('[telegram] [default] DM allowlist configured (' + allowFrom.length + ' entr' + (allowFrom.length === 1 ? 'y' : 'ies') + ')'); + } else { + emit('[telegram] [default] DM allowlist is empty; set TELEGRAM_ALLOWED_IDS before rebuild or complete OpenClaw pairing before expecting direct-message replies'); + } + } + } + function maybeLogCredentialPlaceholderDiagnostics() { if (credentialLogged) return; credentialLogged = true; @@ -157,6 +248,20 @@ if (info && info.hostname === 'api.telegram.org' && req && typeof req.once === 'function') { req.once('response', function (res) { maybeLogTelegramStartupProbe(info, res && res.statusCode); + maybeLogTelegramSendMessage(info, res && res.statusCode); + if (!inboundUpdateLogged && telegramApiMethod(info) === 'getUpdates' && res && typeof res.on === 'function') { + var responseChunks = []; + var responseBytes = 0; + res.on('data', function (chunk) { + if (responseBytes >= 65536) return; + var text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); + responseBytes += Buffer.byteLength(text); + if (responseBytes <= 65536) responseChunks.push(text); + }); + res.on('end', function () { + maybeLogTelegramInboundUpdate(info, responseChunks.join('')); + }); + } }); req.once('error', function (error) { maybeLogTelegramStartupError(info, error); @@ -215,6 +320,7 @@ return ''; } if (!gatewayProcessFlavor()) return; + process.nextTick(maybeLogRuntimeConfigDiagnostics); var STARTUP_GRACE_MS = Number(process.env.NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS || '') || 15000; var noStartupTimer = setTimeout(function () { if (providerStarted || startupProbeLogged) return; diff --git a/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md b/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md index 38114460ad0..7bbedeb5921 100644 --- a/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md +++ b/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md @@ -41,7 +41,9 @@ Telegram uses a bot token from [BotFather](https://t.me/BotFather). Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token. For Telegram group chats, disable privacy mode before testing group replies: in @BotFather, run `/setprivacy`, choose the bot, then choose **Disable**. After changing privacy mode, remove the bot from each Telegram group and add it back so Telegram applies the new delivery setting to that group. -`TELEGRAM_ALLOWED_IDS` is a comma-separated list of Telegram user IDs for DM access. +`TELEGRAM_ALLOWED_IDS` is a comma-separated list of Telegram user or private-chat IDs for DM access. +For compatibility with older QA scripts, NemoClaw also treats `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` as aliases, but new automation should use `TELEGRAM_ALLOWED_IDS`. +Keep these aliases until QA automation and public repro templates have stopped exporting them for at least one full release. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. Set `TELEGRAM_REQUIRE_MENTION=1` to make the bot reply in Telegram groups only when users mention it. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. @@ -156,6 +158,8 @@ If a matching built-in network policy preset exists, `channels add` applies it t If applying the preset fails, NemoClaw warns and tells you to re-apply manually with `nemoclaw policy-add ` after the rebuild. Choose the rebuild so the running sandbox image picks up the new channel. If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. +Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user. +For a repeatable live Telegram reply check, run `test/e2e/test-messaging-providers.sh` with `TELEGRAM_BOT_TOKEN_REAL`, `TELEGRAM_AUTHORIZED_CHAT_IDS` or `TELEGRAM_CHAT_ID`, and `NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E=1`. If you defer the rebuild, apply the change later: ```console diff --git a/skills/nemoclaw-user-reference/references/troubleshooting.md b/skills/nemoclaw-user-reference/references/troubleshooting.md index 81546590f64..bcee680bfe2 100644 --- a/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -917,6 +917,14 @@ New Telegram bots default to privacy mode enabled, which prevents group messages In @BotFather, run `/setprivacy`, choose the bot, and choose **Disable**. Then remove the bot from the affected group and add it back; Telegram applies the privacy-mode change to group delivery only after the bot rejoins. +For Telegram direct messages, make sure the rebuilt sandbox has a DM allowlist. +Set `TELEGRAM_ALLOWED_IDS` before rebuild; `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` are accepted as compatibility aliases. +Keep the aliases until QA automation and public repro templates have stopped exporting them for at least one full release. +Bot API `sendMessage` sends from the bot to a chat, so it only proves outbound Telegram API access. +To prove inbound agent routing, send a message from the Telegram client as an allowed user and then watch the gateway log for the agent turn and outbound reply. +For a reproducible live check that also exercises an alias, run `test/e2e/test-messaging-providers.sh` with `TELEGRAM_BOT_TOKEN_REAL`, either `TELEGRAM_AUTHORIZED_CHAT_IDS` or `TELEGRAM_CHAT_ID`, and `NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E=1`; when prompted, send a fresh direct message from that Telegram client. +The check waits for `[telegram] [default] inbound update received` and `[telegram] [default] outbound sendMessage attempted` in `/tmp/gateway.log`. + To diagnose, open a shell in the sandbox and inspect the gateway log: ```console diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d88c9d38b49..a92ea1ab30b 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -28,6 +28,7 @@ import { runOpenshell } from "../../adapters/openshell/runtime"; import { shellQuote } from "../../runner"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; +import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification"; import { type ChannelDef, KNOWN_CHANNELS, @@ -516,9 +517,10 @@ function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: strin return; } let channelEnabled = false; + let channelBlock: any = null; try { const cfg = JSON.parse(configProbe.stdout); - const channelBlock = cfg?.channels?.[channelName]; + channelBlock = cfg?.channels?.[channelName]; channelEnabled = Boolean(channelBlock?.enabled); } catch { // Malformed config — fall through to the log probe to capture context. @@ -584,6 +586,9 @@ function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: strin console.log( ` ${G}✓${R} '${channelName}' bridge startup detected in sandbox runtime log.`, ); + if (channelName === "telegram") { + printTelegramDirectMessageAllowlistWarning(channelBlock, console.log, `${YW}⚠${R}`); + } return; } console.log( diff --git a/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts b/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts new file mode 100644 index 00000000000..5db67c1b3eb --- /dev/null +++ b/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + getDefaultChannelAccount, + printTelegramDirectMessageAllowlistWarning, +} from "./telegram-channel-bridge-verification"; + +describe("telegram channel bridge verification", () => { + it("selects the default account when present", () => { + const account = { dmPolicy: "allowlist", allowFrom: ["123"] }; + + expect(getDefaultChannelAccount({ accounts: { other: {}, default: account } })).toBe(account); + }); + + it("falls back to the first account when default is absent", () => { + const account = { dmPolicy: "allowlist", allowFrom: ["123"] }; + + expect(getDefaultChannelAccount({ accounts: { main: account } })).toBe(account); + }); + + it("warns only when allowlist mode is active and no senders are configured", () => { + const log = vi.fn(); + + const emitted = printTelegramDirectMessageAllowlistWarning( + { accounts: { default: { dmPolicy: "allowlist", allowFrom: [] } } }, + log, + "WARN", + ); + + expect(emitted).toBe(true); + expect(log.mock.calls.map(([line]) => line).join("\n")).toContain( + "Telegram direct-message allowlist is empty", + ); + }); + + it("does not warn for pairing/default policy accounts", () => { + const log = vi.fn(); + + const emitted = printTelegramDirectMessageAllowlistWarning( + { accounts: { default: { allowFrom: [] } } }, + log, + ); + + expect(emitted).toBe(false); + expect(log).not.toHaveBeenCalled(); + }); + + it("does not warn when allowlist mode has senders", () => { + const log = vi.fn(); + + const emitted = printTelegramDirectMessageAllowlistWarning( + { accounts: { default: { dmPolicy: "allowlist", allowFrom: ["8388960805"] } } }, + log, + ); + + expect(emitted).toBe(false); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts b/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts new file mode 100644 index 00000000000..b402e711f4f --- /dev/null +++ b/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type ChannelAccount = { + dmPolicy?: unknown; + allowFrom?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object"; +} + +export function getDefaultChannelAccount(channelBlock: unknown): ChannelAccount | null { + if (!isRecord(channelBlock) || !isRecord(channelBlock.accounts)) return null; + const accounts = channelBlock.accounts; + if (isRecord(accounts.default)) return accounts.default; + const firstKey = Object.keys(accounts)[0]; + const firstAccount = firstKey ? accounts[firstKey] : null; + return isRecord(firstAccount) ? firstAccount : null; +} + +export function printTelegramDirectMessageAllowlistWarning( + channelBlock: unknown, + log: (message: string) => void = console.log, + warningMarker = "!", +): boolean { + const account = getDefaultChannelAccount(channelBlock); + const allowFrom = Array.isArray(account?.allowFrom) ? account.allowFrom : []; + if (account?.dmPolicy !== "allowlist" || allowFrom.length > 0) return false; + + log(` ${warningMarker} Telegram direct-message allowlist is empty in baked openclaw.json.`); + log( + " Set TELEGRAM_ALLOWED_IDS before rebuild, or complete OpenClaw pairing before expecting DM replies.", + ); + log( + " Telegram Bot API sendMessage tests outbound delivery only; send from a Telegram client to test inbound agent replies.", + ); + return true; +} diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 2ae549b5dc3..7672e91f02e 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -28,6 +28,7 @@ describe("messaging channel config", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_ALLOWED_IDS: " 123,456 ", + TELEGRAM_AUTHORIZED_CHAT_IDS: "ignored-because-canonical-wins", TELEGRAM_REQUIRE_MENTION: "yes", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", @@ -44,6 +45,24 @@ describe("messaging channel config", () => { }); }); + it("canonicalizes Telegram allowlist aliases from env and persisted config", () => { + expect( + sanitizeMessagingChannelConfig({ + TELEGRAM_AUTHORIZED_CHAT_IDS: " 123, 456 ", + }), + ).toEqual({ + TELEGRAM_ALLOWED_IDS: "123, 456", + }); + + expect( + readMessagingChannelConfigFromEnv({ + TELEGRAM_CHAT_ID: "8388960805", + }), + ).toEqual({ + TELEGRAM_ALLOWED_IDS: "8388960805", + }); + }); + it("hydrates missing env values but preserves explicit env overrides", () => { const env: NodeJS.ProcessEnv = { TELEGRAM_ALLOWED_IDS: "env-user", @@ -67,6 +86,17 @@ describe("messaging channel config", () => { expect(env.DISCORD_REQUIRE_MENTION).toBeUndefined(); }); + it("hydrates Telegram aliases into the canonical env key for downstream build code", () => { + const env: NodeJS.ProcessEnv = { + TELEGRAM_AUTHORIZED_CHAT_IDS: "alias-user", + }; + + expect(hydrateMessagingChannelConfig(null, env)).toEqual({ + TELEGRAM_ALLOWED_IDS: "alias-user", + }); + expect(env.TELEGRAM_ALLOWED_IDS).toBe("alias-user"); + }); + it("reads effective config from env", () => { expect( readMessagingChannelConfigFromEnv({ diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 88afca1b3d5..2460fd56762 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -6,6 +6,14 @@ import { listChannels } from "./sandbox/channels"; export type MessagingChannelConfig = Record; const channels = listChannels(); +const CONFIG_ALIASES: Record = { + TELEGRAM_ALLOWED_IDS: ["TELEGRAM_AUTHORIZED_CHAT_IDS", "TELEGRAM_CHAT_ID"], +}; +const aliasToCanonicalKey = new Map( + Object.entries(CONFIG_ALIASES).flatMap(([canonical, aliases]) => + aliases.map((alias) => [alias, canonical] as const), + ), +); const requireMentionKeys = new Set( channels .map((channel) => channel.requireMentionEnvKey) @@ -27,32 +35,72 @@ export const MESSAGING_CHANNEL_CONFIG_ENV_KEYS: readonly string[] = [ const knownConfigKeys = new Set(MESSAGING_CHANNEL_CONFIG_ENV_KEYS); +export type MessagingChannelConfigEnvResolution = { + canonicalKey: string | null; + sourceKey: string | null; + value: string | null; +}; + function normalizeValue(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.replace(/[\r\n]/g, "").trim(); return normalized || null; } +export function getCanonicalMessagingChannelConfigKey(key: string): string | null { + if (knownConfigKeys.has(key)) return key; + return aliasToCanonicalKey.get(key) ?? null; +} + +export function getMessagingChannelConfigEnvKeys(key: string): readonly string[] { + const canonical = getCanonicalMessagingChannelConfigKey(key); + if (!canonical) return []; + return [canonical, ...(CONFIG_ALIASES[canonical] ?? [])]; +} + export function normalizeMessagingChannelConfigValue( key: string, value: unknown, ): string | null { - if (!knownConfigKeys.has(key)) return null; + const canonical = getCanonicalMessagingChannelConfigKey(key); + if (!canonical) return null; const normalized = normalizeValue(value); if (!normalized) return null; - if (requireMentionKeys.has(key) && normalized !== "0" && normalized !== "1") { + if (requireMentionKeys.has(canonical) && normalized !== "0" && normalized !== "1") { return null; } return normalized; } +export function resolveMessagingChannelConfigEnvValue( + key: string, + env: NodeJS.ProcessEnv | Record = process.env, +): MessagingChannelConfigEnvResolution { + const canonical = getCanonicalMessagingChannelConfigKey(key); + if (!canonical) return { canonicalKey: null, sourceKey: null, value: null }; + for (const candidate of getMessagingChannelConfigEnvKeys(canonical)) { + const normalized = normalizeMessagingChannelConfigValue(canonical, env[candidate]); + if (normalized) { + return { canonicalKey: canonical, sourceKey: candidate, value: normalized }; + } + } + return { canonicalKey: canonical, sourceKey: null, value: null }; +} + export function sanitizeMessagingChannelConfig(value: unknown): MessagingChannelConfig | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; const result: MessagingChannelConfig = {}; - for (const [key, raw] of Object.entries(value)) { - const normalized = normalizeMessagingChannelConfigValue(key, raw); + const rawConfig = value as Record; + for (const key of MESSAGING_CHANNEL_CONFIG_ENV_KEYS) { + const normalized = normalizeMessagingChannelConfigValue(key, rawConfig[key]); if (normalized) result[key] = normalized; } + for (const [key, raw] of Object.entries(rawConfig)) { + const canonical = getCanonicalMessagingChannelConfigKey(key); + if (!canonical || result[canonical]) continue; + const normalized = normalizeMessagingChannelConfigValue(canonical, raw); + if (normalized) result[canonical] = normalized; + } return Object.keys(result).length > 0 ? result : null; } @@ -73,8 +121,8 @@ export function readMessagingChannelConfigFromEnv( ): MessagingChannelConfig | null { const result: MessagingChannelConfig = {}; for (const key of MESSAGING_CHANNEL_CONFIG_ENV_KEYS) { - const normalized = normalizeMessagingChannelConfigValue(key, env[key]); - if (normalized) result[key] = normalized; + const resolved = resolveMessagingChannelConfigEnvValue(key, env); + if (resolved.value) result[key] = resolved.value; } return Object.keys(result).length > 0 ? result : null; } @@ -86,9 +134,10 @@ export function hydrateMessagingChannelConfig( const sanitized = sanitizeMessagingChannelConfig(config); const effective: MessagingChannelConfig = {}; for (const key of MESSAGING_CHANNEL_CONFIG_ENV_KEYS) { - const envValue = normalizeMessagingChannelConfigValue(key, env[key]); - if (envValue) { - effective[key] = envValue; + const envValue = resolveMessagingChannelConfigEnvValue(key, env); + if (envValue.value) { + if (!env[key]) env[key] = envValue.value; + effective[key] = envValue.value; continue; } const storedValue = sanitized ? sanitized[key] : null; diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 7db2735d068..fd36c68c640 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -54,6 +54,26 @@ describe("setupSelectedMessagingChannels", () => { expect(output).toContain("reply mode already set: @mentions only"); }); + it("accepts Telegram allowlist aliases during channel setup", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; + process.env.TELEGRAM_CHAT_ID = "8388960805"; + process.env.TELEGRAM_REQUIRE_MENTION = "0"; + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["telegram"], + new Set(["telegram"]), + [{ name: "telegram", ...KNOWN_CHANNELS.telegram }], + ); + + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("8388960805"); + expect(prompt).not.toHaveBeenCalledWith(" Telegram User ID (for DM access): "); + expect(logs.join("\n")).toContain("telegram — allowed IDs already set: 8388960805"); + }); + it("#3715 re-prompts instead of accepting an invalid preconfigured Slack bot token", async () => { process.env.SLACK_BOT_TOKEN = "abcd"; process.env.SLACK_APP_TOKEN = "xapp-existing"; diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 19744858876..ad01c3d51f1 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -6,7 +6,10 @@ import { prompt, saveCredential, } from "../credentials/store"; -import { normalizeMessagingChannelConfigValue } from "../messaging-channel-config"; +import { + normalizeMessagingChannelConfigValue, + resolveMessagingChannelConfigEnvValue, +} from "../messaging-channel-config"; import { channelHasStaticToken, type ChannelDef } from "../sandbox/channels"; import { dispatchHostQrLogin } from "./host-qr-dispatch"; import { @@ -16,8 +19,14 @@ import { type ChannelEntry = { name: string } & ChannelDef; -const getMessagingConfigValue = (envKey: string): string | null => - normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); +const getMessagingConfigValue = (envKey: string): string | null => { + const resolved = resolveMessagingChannelConfigEnvValue(envKey, process.env); + if (resolved.value) { + if (!process.env[envKey]) process.env[envKey] = resolved.value; + return resolved.value; + } + return normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); +}; function getExistingMessagingToken( ch: ChannelEntry, diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts index c38b8822814..2ed4c9be205 100644 --- a/src/lib/onboard/messaging-config.test.ts +++ b/src/lib/onboard/messaging-config.test.ts @@ -46,6 +46,53 @@ describe("onboard messaging config", () => { }); }); + it("uses Telegram allowlist aliases when the canonical env key is absent", () => { + const warn = vi.fn(); + + expect( + collectMessagingBuildConfig({ + channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], + activeChannelNames: new Set(["telegram"]), + enabledTokenEnvKeys: new Set(), + env: { + TELEGRAM_AUTHORIZED_CHAT_IDS: "8388960805, 8388960806", + }, + discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, + warn, + }), + ).toEqual({ + messagingAllowedIds: { + telegram: ["8388960805", "8388960806"], + }, + discordGuilds: {}, + slackConfig: {}, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("TELEGRAM_AUTHORIZED_CHAT_IDS is treated as TELEGRAM_ALLOWED_IDS"), + ); + }); + + it("prefers TELEGRAM_ALLOWED_IDS over Telegram aliases", () => { + expect( + collectMessagingBuildConfig({ + channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], + activeChannelNames: new Set(["telegram"]), + enabledTokenEnvKeys: new Set(), + env: { + TELEGRAM_ALLOWED_IDS: "canonical", + TELEGRAM_CHAT_ID: "alias", + }, + discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, + }), + ).toEqual({ + messagingAllowedIds: { + telegram: ["canonical"], + }, + discordGuilds: {}, + slackConfig: {}, + }); + }); + it("collects Discord guild config and warns on malformed IDs", () => { const warn = vi.fn(); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index e5646660355..287d93a5d56 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -4,6 +4,7 @@ import { type MessagingChannelConfig, mergeMessagingChannelConfigs, + resolveMessagingChannelConfigEnvValue, sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import type { Session } from "../state/onboard-session"; @@ -61,8 +62,15 @@ export function collectMessagingBuildConfig({ }: CollectMessagingBuildConfigOptions): MessagingBuildConfig { const messagingAllowedIds: Record = {}; for (const ch of channels) { - if (activeChannelNames.has(ch.name) && ch.userIdEnvKey && env[ch.userIdEnvKey]) { - const ids = parseMessagingConfigList(env[ch.userIdEnvKey]); + if (activeChannelNames.has(ch.name) && ch.userIdEnvKey) { + const resolved = resolveMessagingChannelConfigEnvValue(ch.userIdEnvKey, env); + if (!resolved.value) continue; + if (resolved.sourceKey && resolved.sourceKey !== ch.userIdEnvKey) { + warn( + ` Warning: ${resolved.sourceKey} is treated as ${ch.userIdEnvKey} for ${ch.name} allowlisting; prefer ${ch.userIdEnvKey}.`, + ); + } + const ids = parseMessagingConfigList(resolved.value); if (ids.length > 0) messagingAllowedIds[ch.name] = ids; } } diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 9b76aa242b7..74a67ba234c 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -47,6 +47,8 @@ # TELEGRAM_BOT_TOKEN — defaults to fake token # DISCORD_BOT_TOKEN — defaults to fake token # TELEGRAM_ALLOWED_IDS — comma-separated Telegram user IDs for DM allowlisting +# TELEGRAM_AUTHORIZED_CHAT_IDS — compatibility alias for TELEGRAM_ALLOWED_IDS +# TELEGRAM_CHAT_ID — compatibility alias for TELEGRAM_ALLOWED_IDS # TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send # DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send # SLACK_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send @@ -68,6 +70,11 @@ # TELEGRAM_CHAT_ID_E2E — optional: target for real Telegram send # DISCORD_CHANNEL_ID_E2E — optional: target for real Discord send # SLACK_CHANNEL_ID_E2E — optional: target for real Slack send +# NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E=1 — optional: wait for a real Telegram-client DM +# from an allowed user and verify inbound + +# outbound gateway breadcrumbs +# NEMOCLAW_TELEGRAM_INBOUND_WAIT_SECONDS — optional: wait time for the live inbound +# proof (default: 90) # NEMOCLAW_OPENSHELL_BIN — optional OpenShell binary under test # NEMOCLAW_FRESH=1 — auto-set to discard interrupted onboard sessions # @@ -402,7 +409,19 @@ TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN_REAL:-${TELEGRAM_BOT_TOKEN:-test-fake-teleg DISCORD_TOKEN="${DISCORD_BOT_TOKEN_REAL:-${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}}" SLACK_TOKEN="${SLACK_BOT_TOKEN_REAL:-${SLACK_BOT_TOKEN:-xoxb-fake-slack-token-e2e}}" SLACK_APP="${SLACK_APP_TOKEN_REAL:-${SLACK_APP_TOKEN:-xapp-fake-slack-app-token-e2e}}" -TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789,987654321}" +if [ -n "${TELEGRAM_ALLOWED_IDS:-}" ]; then + TELEGRAM_IDS="$TELEGRAM_ALLOWED_IDS" + TELEGRAM_ALLOWLIST_ENV_KEY="TELEGRAM_ALLOWED_IDS" +elif [ -n "${TELEGRAM_AUTHORIZED_CHAT_IDS:-}" ]; then + TELEGRAM_IDS="$TELEGRAM_AUTHORIZED_CHAT_IDS" + TELEGRAM_ALLOWLIST_ENV_KEY="TELEGRAM_AUTHORIZED_CHAT_IDS" +elif [ -n "${TELEGRAM_CHAT_ID:-}" ]; then + TELEGRAM_IDS="$TELEGRAM_CHAT_ID" + TELEGRAM_ALLOWLIST_ENV_KEY="TELEGRAM_CHAT_ID" +else + TELEGRAM_IDS="123456789,987654321" + TELEGRAM_ALLOWLIST_ENV_KEY="TELEGRAM_AUTHORIZED_CHAT_IDS" +fi SLACK_IDS="${SLACK_ALLOWED_USERS-U0AR85ATALW,U09E2ESLACK}" # WeChat: pre-seeding WECHAT_BOT_TOKEN + the per-account metadata env vars lets # the non-interactive onboard path (src/lib/onboard.ts:8433) treat wechat as @@ -422,7 +441,19 @@ export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" export DISCORD_BOT_TOKEN="$DISCORD_TOKEN" export SLACK_BOT_TOKEN="$SLACK_TOKEN" export SLACK_APP_TOKEN="$SLACK_APP" -export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS" +case "$TELEGRAM_ALLOWLIST_ENV_KEY" in + TELEGRAM_ALLOWED_IDS) + export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS" + ;; + TELEGRAM_AUTHORIZED_CHAT_IDS) + unset TELEGRAM_ALLOWED_IDS + export TELEGRAM_AUTHORIZED_CHAT_IDS="$TELEGRAM_IDS" + ;; + TELEGRAM_CHAT_ID) + unset TELEGRAM_ALLOWED_IDS TELEGRAM_AUTHORIZED_CHAT_IDS + export TELEGRAM_CHAT_ID="$TELEGRAM_IDS" + ;; +esac export SLACK_ALLOWED_USERS="$SLACK_IDS" export WECHAT_BOT_TOKEN="$WECHAT_TOKEN" export WECHAT_ACCOUNT_ID="$WECHAT_ACCOUNT" @@ -475,6 +506,73 @@ sandbox_exec() { echo "$result" } +read_gateway_log() { + openshell sandbox exec --name "$SANDBOX_NAME" -- cat /tmp/gateway.log 2>/dev/null || true +} + +run_telegram_inbound_reply_probe() { + if [ "${NEMOCLAW_TELEGRAM_INBOUND_REPLY_E2E:-}" != "1" ]; then + return + fi + + section "Phase 6a: Live Telegram Inbound Reply Proof" + + local wait_seconds="${NEMOCLAW_TELEGRAM_INBOUND_WAIT_SECONDS:-90}" + if ! [[ "$wait_seconds" =~ ^[0-9]+$ ]]; then + wait_seconds=90 + fi + if [ "$wait_seconds" -lt 1 ]; then + wait_seconds=90 + fi + + if [ -z "${TELEGRAM_BOT_TOKEN_REAL:-}" ]; then + fail "M19b: Live Telegram inbound proof requires TELEGRAM_BOT_TOKEN_REAL" + return + fi + if [ "$TELEGRAM_ALLOWLIST_ENV_KEY" = "TELEGRAM_ALLOWED_IDS" ]; then + fail "M19b: Live Telegram inbound proof must be run with TELEGRAM_AUTHORIZED_CHAT_IDS or TELEGRAM_CHAT_ID to exercise alias compatibility" + return + fi + if [ -z "$TELEGRAM_IDS" ]; then + fail "M19b: Live Telegram inbound proof requires a non-empty Telegram allowlist alias" + return + fi + + local log_before_lines + log_before_lines=$(read_gateway_log | wc -l | tr -d ' ') + if [ -z "$log_before_lines" ]; then + log_before_lines=0 + fi + + info "Live Telegram inbound proof is using ${TELEGRAM_ALLOWLIST_ENV_KEY}; send a fresh direct message from an allowed Telegram client to the bot now." + info "Waiting up to ${wait_seconds}s for inbound getUpdates and outbound sendMessage breadcrumbs in /tmp/gateway.log..." + + local deadline now delta_log saw_inbound saw_outbound + deadline=$(($(date +%s) + wait_seconds)) + saw_inbound=0 + saw_outbound=0 + while true; do + delta_log=$(read_gateway_log | awk -v start="$log_before_lines" 'NR > start') + if echo "$delta_log" | grep -qF "[telegram] [default] inbound update received"; then + saw_inbound=1 + fi + if echo "$delta_log" | grep -qF "[telegram] [default] outbound sendMessage attempted"; then + saw_outbound=1 + fi + if [ "$saw_inbound" = "1" ] && [ "$saw_outbound" = "1" ]; then + pass "M19b: Telegram client DM produced inbound getUpdates and outbound reply breadcrumbs" + return + fi + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + break + fi + sleep 5 + done + + fail "M19b: Timed out waiting for Telegram inbound/reply breadcrumbs (inbound=${saw_inbound}, outbound=${saw_outbound})" +} + run_openclaw_message_send() { local channel="$1" local target="$2" @@ -531,6 +629,15 @@ fi pass "Docker is running" info "Telegram token: configured (${#TELEGRAM_TOKEN} chars)" +telegram_allowed_id_count=0 +if [ -n "$TELEGRAM_IDS" ]; then + IFS=',' read -ra _telegram_allowed_ids <<<"$TELEGRAM_IDS" + for _tid in "${_telegram_allowed_ids[@]}"; do + _tid="${_tid//[[:space:]]/}" + [ -n "$_tid" ] && ((telegram_allowed_id_count++)) + done +fi +info "Telegram allowlist source: ${TELEGRAM_ALLOWLIST_ENV_KEY} (${telegram_allowed_id_count} ID(s))" info "Discord token: configured (${#DISCORD_TOKEN} chars)" info "Slack bot token: configured (${#SLACK_TOKEN} chars)" info "Slack app token: configured (${#SLACK_APP} chars)" @@ -565,7 +672,10 @@ fi pass "Pre-cleanup complete" if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then - if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + if [ -z "${TELEGRAM_BOT_TOKEN_REAL:-}" ] && [[ "$TELEGRAM_TOKEN" == test-fake-* ]]; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Skipping onboarding Telegram reachability probe for fake-token E2E" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "Host cannot reach api.telegram.org; skipping onboarding Telegram reachability probe for fake-token E2E" fi @@ -1540,6 +1650,9 @@ print(','.join(str(i) for i in ids)) done if [ ${#missing_ids[@]} -eq 0 ]; then pass "M11c: Telegram allowFrom contains all expected user IDs: $tg_allow_from" + if [ "$TELEGRAM_ALLOWLIST_ENV_KEY" != "TELEGRAM_ALLOWED_IDS" ]; then + pass "M11c-alias: Telegram allowFrom honored ${TELEGRAM_ALLOWLIST_ENV_KEY} alias" + fi else fail "M11c: Telegram allowFrom ($tg_allow_from) is missing IDs: ${missing_ids[*]} (expected all of: $TELEGRAM_IDS)" fi @@ -2568,6 +2681,8 @@ else fi fi +run_telegram_inbound_reply_probe + if [ -n "${DISCORD_BOT_TOKEN_REAL:-}" ] && [ -n "${DISCORD_CHANNEL_ID_E2E:-}" ]; then if [ "$dc_status" = "200" ]; then pass "M20: Discord users/@me returned 200 with real token" diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 9b3e8d28f71..9ed50dde665 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -577,6 +577,22 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.channels.discord.accounts.default.enabled).toBe(true); }); + it("uses Telegram allowed IDs for direct-message allowlisting (#4553)", () => { + const allowedUsers = ["8388960805", "8388960806"]; + const channels = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); + const allowedIds = Buffer.from(JSON.stringify({ telegram: allowedUsers })).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: allowedIds, + }); + const telegram = config.channels.telegram.accounts.default; + + expect(config.channels.telegram.enabled).toBe(true); + expect(config.plugins.entries.telegram).toEqual({ enabled: true }); + expect(telegram.dmPolicy).toBe("allowlist"); + expect(telegram.allowFrom).toEqual(allowedUsers); + }); + it("uses Slack allowed IDs for DMs and channel mention allowlisting (#3729)", () => { const allowedUsers = ["U01ABC2DEF3", "U04GHI5JKL6"]; const channels = Buffer.from(JSON.stringify(["slack"])).toString("base64"); diff --git a/test/telegram-diagnostics.test.ts b/test/telegram-diagnostics.test.ts index 4f1d2119e4c..7090a1e85bb 100644 --- a/test/telegram-diagnostics.test.ts +++ b/test/telegram-diagnostics.test.ts @@ -124,4 +124,123 @@ describe("telegram-diagnostics: startup-grace breadcrumb (#4314, #4390)", () => expect(result.status).toBe(0); expect(result.stderr).not.toMatch(/bridge did not start within/); }); + + it("logs Telegram DM allowlist state without exposing IDs", () => { + const driver = ` + ${GATEWAY_TITLE_SETUP} + const fs = require("fs"); + fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({ + channels: { telegram: { enabled: true, accounts: { default: { dmPolicy: "allowlist", allowFrom: ["8388960805"] } } } }, + })); + require(process.env.DIAGNOSTICS_PATH); + setTimeout(() => process.exit(0), 100); + `; + const { result } = runDriver(driver, { NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS: "1000" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("DM allowlist configured (1 entry)"); + expect(result.stderr).not.toContain("8388960805"); + }); + + it("logs an actionable warning when Telegram DM allowlist is empty", () => { + const driver = ` + ${GATEWAY_TITLE_SETUP} + const fs = require("fs"); + fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({ + channels: { telegram: { enabled: true, accounts: { default: { dmPolicy: "allowlist", allowFrom: [] } } } }, + })); + require(process.env.DIAGNOSTICS_PATH); + setTimeout(() => process.exit(0), 100); + `; + const { result } = runDriver(driver, { NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS: "1000" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("DM allowlist is empty; set TELEGRAM_ALLOWED_IDS"); + }); + + it("does not warn about an empty allowlist when Telegram is not in allowlist mode", () => { + const driver = ` + ${GATEWAY_TITLE_SETUP} + const fs = require("fs"); + fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({ + channels: { telegram: { enabled: true, accounts: { default: {} } } }, + })); + require(process.env.DIAGNOSTICS_PATH); + setTimeout(() => process.exit(0), 100); + `; + const { result } = runDriver(driver, { NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS: "1000" }); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("DM allowlist is empty"); + }); + + it("logs outbound sendMessage attempts without leaking the bot token", () => { + const driver = ` + ${GATEWAY_TITLE_SETUP} + const fs = require("fs"); + const { EventEmitter } = require("events"); + const http = require("http"); + fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({ + channels: { telegram: { enabled: true, accounts: { default: { dmPolicy: "allowlist", allowFrom: ["123"] } } } }, + })); + http.request = function () { + const req = new EventEmitter(); + req.end = function () { + process.nextTick(() => req.emit("response", { statusCode: 200 })); + }; + return req; + }; + require(process.env.DIAGNOSTICS_PATH); + http.request({ hostname: "api.telegram.org", path: "/bot123456:SECRET/sendMessage" }).end(); + setTimeout(() => process.exit(0), 100); + `; + const { result } = runDriver(driver, { NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS: "1000" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("outbound sendMessage attempted; Bot API returned HTTP 200"); + expect(result.stderr).not.toContain("123456:SECRET"); + }); + + it("logs inbound getUpdates metadata without exposing Telegram IDs or message text", () => { + const driver = ` + ${GATEWAY_TITLE_SETUP} + const fs = require("fs"); + const { EventEmitter } = require("events"); + const http = require("http"); + fs.writeFileSync(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify({ + channels: { telegram: { enabled: true, accounts: { default: { dmPolicy: "allowlist", allowFrom: ["8388960805"] } } } }, + })); + http.request = function () { + const req = new EventEmitter(); + req.end = function () { + const res = new EventEmitter(); + res.statusCode = 200; + process.nextTick(() => { + req.emit("response", res); + res.emit("data", JSON.stringify({ + ok: true, + result: [{ + update_id: 111111, + message: { + message_id: 42, + from: { id: 8388960805 }, + chat: { id: 8388960805, type: "private" }, + text: "hello bot please reply", + }, + }], + })); + res.emit("end"); + }); + }; + return req; + }; + require(process.env.DIAGNOSTICS_PATH); + http.request({ hostname: "api.telegram.org", path: "/bot123456:SECRET/getUpdates" }).end(); + setTimeout(() => process.exit(0), 100); + `; + const { result } = runDriver(driver, { NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS: "1000" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain( + "inbound update received (update_id=present; message_id=present; chat_type=private; sender_allowlisted=true)", + ); + expect(result.stderr).not.toContain("8388960805"); + expect(result.stderr).not.toContain("hello bot please reply"); + expect(result.stderr).not.toContain("123456:SECRET"); + }); });