Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 112 additions & 6 deletions nemoclaw-blueprint/scripts/telegram-diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <sandbox> policy-add <channel>` 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
Expand Down
8 changes: 8 additions & 0 deletions skills/nemoclaw-user-reference/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 39 additions & 0 deletions src/lib/actions/sandbox/telegram-channel-bridge-verification.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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;
}
Loading
Loading