Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
866ef75
feat(messaging): telegram channels-status health probe
hunglp6d Jul 14, 2026
b04f82b
feat(messaging): scope telegram health probe to OpenClaw sandboxes
hunglp6d Jul 14, 2026
6a4cafe
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 14, 2026
f8bebef
feat(messaging): classify network-blocked telegram as unreachable
hunglp6d Jul 14, 2026
3dca287
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 14, 2026
87646c9
feat(messaging): make telegram health verdict reflect the latest log …
hunglp6d Jul 14, 2026
4736e0e
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 14, 2026
62c5271
feat(messaging): document telegram channels-status health probe
hunglp6d Jul 14, 2026
7b7a39d
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 14, 2026
3090a25
feat(messaging): recency-gate telegram token-rejection evidence
hunglp6d Jul 14, 2026
05aedb4
feat(messaging): clarify telegram probe reduces gateway log lines hos…
hunglp6d Jul 14, 2026
e56b931
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 14, 2026
d57671f
feat(messaging): report paused channels as paused in channel detail view
hunglp6d Jul 15, 2026
6bc8d00
feat(messaging): recency-gate telegram startup HTTP error evidence
hunglp6d Jul 15, 2026
3784010
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 15, 2026
671e13d
Merge branch 'main' of github.com:NVIDIA/NemoClaw into feat/telegram-…
hunglp6d Jul 15, 2026
0af080a
feat(messaging): move telegram channels-status health into a status hook
hunglp6d Jul 15, 2026
f49e260
feat(messaging): cover nonzero exit for an unhealthy telegram channel…
hunglp6d Jul 15, 2026
5defae4
Merge branch 'main' into feat/telegram-channels-status-health
hunglp6d Jul 15, 2026
9ffa3a4
feat(messaging): gate telegram probe on clean exec + validate channel…
hunglp6d Jul 15, 2026
1368f53
feat(messaging): add issue suffix to readChannelHealthOutputs suite t…
hunglp6d Jul 15, 2026
02a3770
feat(messaging): recency-gate telegram inbound so a stale inbound isn…
hunglp6d Jul 15, 2026
5df2bd2
feat(messaging): rank telegram http 5xx as a recency peer among start…
hunglp6d Jul 15, 2026
5d9b048
Merge branch 'main' into feat/telegram-channels-status-health
hunglp6d Jul 15, 2026
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
11 changes: 9 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1894,12 +1894,18 @@ $$nemoclaw my-assistant channels start telegram

Run messaging channel status checks.
Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons.
For channels that support a live health probe (WhatsApp, Telegram), the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check.
With `--channel`, it prints the detailed status for that channel.

For WhatsApp, `--channel whatsapp` also probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage.
A paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy.
The detailed WhatsApp probe stays focused on QR/session runtime diagnostics and does not include rendered-config comparison lines.

For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison, and classifies the current state into a verdict such as `healthy`, `idle`, `unreachable` (network or egress), `token_rejected`, or `not_started`.
It reads the gateway's own startup and poll log breadcrumbs rather than issuing its own Bot API request, so the resolved bot token never leaves the gateway.
The verdict reflects the most recent evidence in the log window, so a bridge that recovered after a blocked start reports `healthy` while one blocked again reports `unreachable`.
Telegram health is probed only for OpenClaw sandboxes; a Hermes Telegram sandbox uses the basic config report.

For registered non-WhatsApp channel details and the compact summary, the status output compares non-secret config inputs from the sandbox registry against the values rendered into the agent config, such as Telegram group policy in `openclaw.json` or mention mode in Hermes config.
Secret inputs, including tokens, are not printed.
If the registry contains a non-secret expected value but NemoClaw cannot read or check the rendered source, the comparison is a warning and the detail includes `(not checked)`.
Expand All @@ -1908,14 +1914,15 @@ Optional unset inputs remain informational.
```bash
$$nemoclaw my-assistant channels status
$$nemoclaw my-assistant channels status --channel whatsapp
$$nemoclaw my-assistant channels status --channel telegram
```

| Flag | Description |
|------|-------------|
| `--channel <channel>` | Channel to inspect in detail |
| `--json` | Emit the status report as JSON (for the detailed WhatsApp probe, exit non-zero when the verdict is not `healthy` or `unknown`) |
| `--json` | Emit the status report as JSON (for the detailed WhatsApp and Telegram probes, exit non-zero when the verdict is not `healthy` or `unknown`) |

The probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout, captures only short matched bridge log signals (e.g. `connection.open`, `401 unauthorized`, `qr expired`), and never forwards message bodies to the host diagnostic output.
Each probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout and returns only matched bridge/gateway log lines (e.g. `connection.open`, `401 unauthorized`, `qr expired`, or `[telegram]` startup breadcrumbs) to the host, where NemoClaw reduces them to fixed classifications; the raw lines are never rendered, so the diagnostic output carries only those classifications, never message bodies or tokens.

</AgentOnly>

Expand Down
46 changes: 29 additions & 17 deletions src/lib/actions/sandbox/channel-status-config-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test-helpers";
import {
entry,
makeDeps,
reportSignals,
showSandboxChannelStatus,
withTelegramProbe,
} from "./channel-status.test-helpers";

describe("showSandboxChannelStatus config comparison", () => {
it("marks rendered config ok when the sandbox config matches the sandbox entry", async () => {
const { deps, out_lines } = makeDeps({
exec: (_sandbox, command) =>
exec: withTelegramProbe((_sandbox, command) =>
command.includes("/sandbox/.openclaw/openclaw.json")
? {
status: 0,
Expand All @@ -30,6 +36,7 @@ describe("showSandboxChannelStatus config comparison", () => {
stderr: "",
}
: { status: 1, stdout: "", stderr: "" },
),
sandbox: entry(["telegram"], [], {
telegram: [
{
Expand Down Expand Up @@ -61,14 +68,15 @@ describe("showSandboxChannelStatus config comparison", () => {
],
}),
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});

expect(result && "verdict" in result && result.verdict).toBe("info");
const signals = result && "signals" in result ? result.signals : [];
expect(result && "report" in result && result.report.verdict).toBe("unknown");
const signals = reportSignals(result);
expect(
signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"),
).toMatchObject({
Expand All @@ -92,7 +100,7 @@ describe("showSandboxChannelStatus config comparison", () => {

it("marks Telegram all-message mode ok when OpenClaw omits the groups stanza (#5691)", async () => {
const { deps } = makeDeps({
exec: () => ({
exec: withTelegramProbe(() => ({
status: 0,
stdout: JSON.stringify({
channels: {
Expand All @@ -106,7 +114,7 @@ describe("showSandboxChannelStatus config comparison", () => {
},
}),
stderr: "",
}),
})),
sandbox: entry(["telegram"], [], {
telegram: [
{
Expand All @@ -130,13 +138,14 @@ describe("showSandboxChannelStatus config comparison", () => {
],
}),
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(
signals.find(
(signal) => signal.label === "Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)",
Expand All @@ -149,7 +158,7 @@ describe("showSandboxChannelStatus config comparison", () => {

it("does not compare Hermes Telegram group policy when the manifest does not render it", async () => {
const { deps } = makeDeps({
exec: (_sandbox, command) =>
exec: withTelegramProbe((_sandbox, command) =>
command.includes("/sandbox/.hermes/.env")
? {
status: 0,
Expand All @@ -167,6 +176,7 @@ describe("showSandboxChannelStatus config comparison", () => {
stdout: "",
stderr: "",
},
),
agentName: "hermes",
sandbox: entry(
["telegram"],
Expand Down Expand Up @@ -205,13 +215,14 @@ describe("showSandboxChannelStatus config comparison", () => {
"hermes",
),
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(
signals.find(
(signal) => signal.label === "Telegram User ID (for DM access) (TELEGRAM_ALLOWED_IDS)",
Expand Down Expand Up @@ -260,7 +271,7 @@ describe("showSandboxChannelStatus config comparison", () => {
channel: "teams",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(signals.filter((signal) => signal.label === "Rendered config source")).toEqual([
expect.objectContaining({
severity: "warn",
Expand All @@ -278,7 +289,7 @@ describe("showSandboxChannelStatus config comparison", () => {

it("warns when rendered config differs from the sandbox entry", async () => {
const { deps } = makeDeps({
exec: () => ({
exec: withTelegramProbe(() => ({
status: 0,
stdout: JSON.stringify({
channels: {
Expand All @@ -292,7 +303,7 @@ describe("showSandboxChannelStatus config comparison", () => {
},
}),
stderr: "",
}),
})),
sandbox: entry(["telegram"], [], {
telegram: [
{
Expand All @@ -307,13 +318,14 @@ describe("showSandboxChannelStatus config comparison", () => {
],
}),
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(
signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"),
).toMatchObject({
Expand Down Expand Up @@ -385,7 +397,7 @@ describe("showSandboxChannelStatus config comparison", () => {
channel: "teams",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
const sourceWarnings = signals.filter((signal) => signal.label === "Rendered config source");
expect(sourceWarnings).toHaveLength(1);
expect(sourceWarnings[0]).toMatchObject({
Expand Down Expand Up @@ -458,7 +470,7 @@ describe("showSandboxChannelStatus config comparison", () => {
channel: "teams",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(signals.filter((signal) => signal.label === "Rendered config source")).toEqual([
expect.objectContaining({
severity: "warn",
Expand Down Expand Up @@ -506,7 +518,7 @@ describe("showSandboxChannelStatus config comparison", () => {
channel: "teams",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(
signals.find(
(signal) => signal.label === "Microsoft Teams mention mode (TEAMS_REQUIRE_MENTION)",
Expand Down Expand Up @@ -550,7 +562,7 @@ describe("showSandboxChannelStatus config comparison", () => {
channel: "teams",
});

const signals = result && "signals" in result ? result.signals : [];
const signals = reportSignals(result);
expect(
signals.find(
(signal) =>
Expand Down
37 changes: 31 additions & 6 deletions src/lib/actions/sandbox/channel-status-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";
import { entry, makeDeps, showSandboxChannelStatus } from "./channel-status.test-helpers";
import {
entry,
makeDeps,
showSandboxChannelStatus,
withTelegramProbe,
} from "./channel-status.test-helpers";

describe("showSandboxChannelStatus summary", () => {
it("emits a compact all-channel report when no channel is selected", async () => {
Expand Down Expand Up @@ -102,19 +107,39 @@ describe("showSandboxChannelStatus summary", () => {
expect(dump).not.toMatch(/NemoClaw channels status:/);
});

it("emits a basic per-channel report for non-whatsapp channels", async () => {
it("runs the telegram health probe for an explicit --channel telegram", async () => {
const { deps, out_lines } = makeDeps({
exec: () => ({ status: 0, stdout: "", stderr: "" }),
exec: withTelegramProbe(() => ({ status: 0, stdout: "", stderr: "" })),
sandbox: entry(["telegram"]),
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});
expect(result && "verdict" in result && result.verdict).toBe("info");
expect(result && "report" in result && result.report.verdict).toBe("unknown");
const dump = out_lines.join("\n");
expect(dump).toMatch(/telegram registered/);
expect(dump).toMatch(/preset applied/);
expect(dump).toMatch(/telegram channel registered/);
expect(dump).toMatch(/telegram preset applied/);
});

it("uses the basic config report for a Hermes telegram sandbox (no OpenClaw producer)", async () => {
const { deps } = makeDeps({
exec: () => ({ status: 0, stdout: "", stderr: "" }),
sandbox: entry(["telegram"], [], {}, "hermes"),
agentName: "hermes",
appliedPresets: ["telegram"],
gatewayPresets: ["telegram"],
});
const result = await showSandboxChannelStatus("alpha", {
deps,
channel: "telegram",
});
// Hermes lacks the OpenClaw log-tail breadcrumb producer, so it falls back
// to the basic { verdict: "info" } report instead of a misleading health
// verdict (#6743 review).
expect(result && "verdict" in result && result.verdict).toBe("info");
expect(result && "report" in result).toBe(false);
});
});
Loading
Loading