Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ Set `TELEGRAM_GROUP_POLICY=allowlist` to block OpenClaw group chats until explic
Hermes does not have an equivalent disable-groups policy; `TELEGRAM_ALLOWED_IDS` maps to Hermes `TELEGRAM_ALLOWED_USERS`, which authorizes those users across DMs, groups, and forums.
NemoClaw defaults `TELEGRAM_REQUIRE_MENTION` to `1`, so the bot replies in Telegram groups only when users mention it.
Set `TELEGRAM_REQUIRE_MENTION=0` when you want the bot to reply to all group messages.
<AgentOnly variant="openclaw">
When `TELEGRAM_GROUP_POLICY=open`, NemoClaw writes the selected mention mode explicitly so `TELEGRAM_REQUIRE_MENTION=0` does not fall back to OpenClaw's mention-only runtime default.
NemoClaw does not summarize one global mention mode when `TELEGRAM_GROUP_POLICY` is `allowlist` or `disabled` because group access is blocked or controlled per group before mention handling.
</AgentOnly>
<AgentOnly variant="hermes">
For Hermes, NemoClaw maps `TELEGRAM_REQUIRE_MENTION` to `telegram.require_mention`, and `channels status` and `doctor` compare the stored setting with that rendered value.
</AgentOnly>
Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages.

Discord uses a bot token from the Discord Developer Portal.
Expand Down Expand Up @@ -385,6 +392,17 @@ After the sandbox is running, send a message to the configured bot or app.
If delivery fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the channel API.
Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples).

For Telegram, use the channel status and doctor commands to verify the group mention mode against the rendered agent configuration:

```bash
$$nemoclaw my-assistant channels status --channel telegram
$$nemoclaw my-assistant doctor
```

When NemoClaw can reach the sandbox and read its rendered configuration, both commands report `Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)` as `mention-only (1)` or `all-messages (0)`.
If the stored setting and rendered configuration differ, the check reports a warning with the expected and rendered modes.
Rebuild the sandbox with `$$nemoclaw my-assistant rebuild` to regenerate the channel configuration after you correct the setting.

## Tunnel Command

<AgentOnly variant="openclaw">
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,8 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa

Run a focused health check for one sandbox and the host services it depends on.
The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state.
When the sandbox is reachable and Telegram has one global mention mode, the Messaging section compares the stored setting with the rendered agent configuration.
The check reports `mention-only (1)` or `all-messages (0)` and warns when the expected and rendered modes differ.

Warnings do not make the command fail.
Failed checks exit non-zero so scripts can use `doctor` as a readiness gate.
Expand Down Expand Up @@ -1106,6 +1108,8 @@ For registered non-WhatsApp channel details and the compact summary, the status
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)`.
Optional unset inputs remain informational.
For Telegram configurations with one global mention mode, the output reports `Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)` as `mention-only (1)` or `all-messages (0)`.
If the stored setting differs from the rendered mode, the comparison warns and displays both values.

```bash
nemohermes my-assistant channels status
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,15 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa

Run a focused health check for one sandbox and the host services it depends on.
The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state.
When the sandbox is reachable and Telegram has one global mention mode, the Messaging section compares the stored setting with the rendered agent configuration.
The check reports `mention-only (1)` or `all-messages (0)` and warns when the expected and rendered modes differ.

<AgentOnly variant="openclaw">

For OpenClaw, the Telegram mention-mode check applies when `TELEGRAM_GROUP_POLICY=open`.
The command does not summarize one global mention mode when the policy is `allowlist` or `disabled`.

</AgentOnly>

Warnings do not make the command fail.
Failed checks exit non-zero so scripts can use `doctor` as a readiness gate.
Expand Down Expand Up @@ -1410,6 +1419,15 @@ For registered non-WhatsApp channel details and the compact summary, the status
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)`.
Optional unset inputs remain informational.
For Telegram configurations with one global mention mode, the output reports `Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)` as `mention-only (1)` or `all-messages (0)`.
If the stored setting differs from the rendered mode, the comparison warns and displays both values.

<AgentOnly variant="openclaw">

For OpenClaw, this Telegram comparison applies when `TELEGRAM_GROUP_POLICY=open`.
The command does not summarize one global mention mode when the policy is `allowlist` or `disabled`.

</AgentOnly>

```bash
$$nemoclaw my-assistant channels status
Expand Down
163 changes: 163 additions & 0 deletions src/lib/actions/sandbox/channel-status-config-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { RenderedConfigVisibilityKey } from "../../messaging";
import type {
ChannelConfigInputSpec,
MessagingSerializableValue,
SandboxMessagingInputReference,
} from "../../messaging/manifest";
import type { DiagnosticSignal } from "../../sandbox/whatsapp-diagnostics";
import {
booleanConfigValue,
configInputDetail,
configValuesEqual,
} from "./channel-status-config-values";

export type ChannelStatusConfigSignal = DiagnosticSignal & {
readonly kind: "config-input" | "rendered-config-source";
};

export interface ConfigRenderSource extends RenderedConfigVisibilityKey {
readonly resolvedTarget: string;
}

export type ConfigSourceRead =
| {
readonly ok: true;
readonly value: MessagingSerializableValue | undefined;
}
| {
readonly ok: false;
readonly error: string;
};

type SandboxMessagingInputWithValue = SandboxMessagingInputReference & {
readonly value: Exclude<MessagingSerializableValue, null | undefined>;
};

type ExpectedConfigValue = {
readonly value: MessagingSerializableValue | undefined;
readonly detail: string;
readonly hasValue: boolean;
};

export function configInputSignal(
input: ChannelConfigInputSpec,
planInput: SandboxMessagingInputReference | undefined,
renderSources: readonly ConfigRenderSource[],
sourceValues: ReadonlyMap<string, ConfigSourceRead>,
): ChannelStatusConfigSignal | null {
const label = configInputLabel(input, planInput);
const expected = expectedConfigValue(input, planInput);
const sources = renderSources.filter((source) => source.inputId === input.id);
if (sources.length === 0) return null;

const comparisons = sources.map((source) =>
compareConfigSource(input, expected, source, sourceValues),
);
const checkedComparisons = comparisons.filter((comparison) => comparison.checked);
const hasMismatch = checkedComparisons.some((comparison) => !comparison.matches);
const allSourcesChecked =
checkedComparisons.length === comparisons.length && checkedComparisons.length > 0;
const hasUncheckedExpectedValue = expected.hasValue && !allSourcesChecked;
return {
kind: "config-input",
label,
severity:
hasMismatch || hasUncheckedExpectedValue
? "warn"
: expected.hasValue && allSourcesChecked
? "ok"
: "info",
detail: Array.from(new Set(comparisons.map((comparison) => comparison.detail))).join("; "),
};
}

function planInputHasValue(
input: SandboxMessagingInputReference | undefined,
): input is SandboxMessagingInputWithValue {
return input?.value !== undefined && input.value !== null;
}

function configInputLabel(
input: ChannelConfigInputSpec,
planInput: SandboxMessagingInputReference | undefined,
): string {
const label = input.prompt?.label ?? input.envKey ?? input.id;
const envKey = input.envKey ?? planInput?.sourceEnv;
if (!envKey || label === envKey) return label;
return `${label} (${envKey})`;
}

function expectedConfigValue(
input: ChannelConfigInputSpec,
planInput: SandboxMessagingInputReference | undefined,
): ExpectedConfigValue {
if (planInputHasValue(planInput)) {
return {
value: planInput.value,
detail: configInputValueDetail(input, planInput.value),
hasValue: true,
};
}

const defaultValue = input.defaultValue?.trim();
if (defaultValue) {
return {
value: defaultValue,
detail: configInputValueDetail(input, defaultValue, { isDefault: true }),
hasValue: true,
};
}

return {
value: undefined,
detail: configInputDetail(undefined),
hasValue: false,
};
}

function configInputValueDetail(
input: ChannelConfigInputSpec,
value: MessagingSerializableValue | undefined,
options: { readonly isDefault?: boolean } = {},
): string {
const booleanValue = value === undefined ? null : booleanConfigValue(value);
const labelKey =
booleanValue === null ? configInputDetail(value) : booleanValue === true ? "1" : "0";
const label = input.diagnostics?.valueLabels?.[labelKey];
if (label) {
return options.isDefault ? `${label} (${labelKey}, default)` : `${label} (${labelKey})`;
}
const renderedValue = configInputDetail(value);
return options.isDefault ? `${renderedValue} (default)` : renderedValue;
}

function compareConfigSource(
input: ChannelConfigInputSpec,
expected: ExpectedConfigValue,
source: ConfigRenderSource,
sourceValues: ReadonlyMap<string, ConfigSourceRead>,
): { readonly checked: boolean; readonly matches: boolean; readonly detail: string } {
const actual = sourceValues.get(configSourceKey(source));
if (!actual?.ok) {
return {
checked: false,
matches: false,
detail: `${expected.detail} (not checked)`,
};
}
const matches = configValuesEqual(expected.value, actual.value);
return {
checked: true,
matches,
detail: matches
? expected.detail
: `expected ${expected.detail}; rendered ${configInputValueDetail(input, actual.value)}`,
};
}

export function configSourceKey(source: ConfigRenderSource): string {
return `${source.resolvedTarget}:${source.kind}:${source.key}`;
}
80 changes: 3 additions & 77 deletions src/lib/actions/sandbox/channel-status-config-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,82 +5,6 @@ import { describe, expect, it } from "vitest";
import { entry, makeDeps, showSandboxChannelStatus } 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) =>
command.includes("/sandbox/.openclaw/openclaw.json")
? {
status: 0,
stdout: JSON.stringify({
channels: {
telegram: {
accounts: {
default: {
groupPolicy: "allowlist",
},
},
},
},
}),
stderr: "",
}
: { status: 1, stdout: "", stderr: "" },
sandbox: entry(["telegram"], [], {
telegram: [
{
channelId: "telegram",
inputId: "botToken",
kind: "secret",
required: true,
sourceEnv: "TELEGRAM_BOT_TOKEN",
credentialAvailable: true,
},
{
channelId: "telegram",
inputId: "requireMention",
kind: "config",
required: false,
sourceEnv: "TELEGRAM_REQUIRE_MENTION",
statePath: "telegramConfig.requireMention",
value: "1",
},
{
channelId: "telegram",
inputId: "groupPolicy",
kind: "config",
required: false,
sourceEnv: "TELEGRAM_GROUP_POLICY",
statePath: "telegramConfig.groupPolicy",
value: "allowlist",
},
],
}),
appliedPresets: ["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(
signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"),
).toMatchObject({
severity: "ok",
detail: "allowlist",
});
expect(
signals.find(
(signal) => signal.label === "Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)",
),
).toBeUndefined();
const dump = out_lines.join("\n");
expect(dump).toMatch(/Telegram group policy \(TELEGRAM_GROUP_POLICY\):\s+allowlist/);
expect(dump).not.toMatch(/Telegram Bot Token/);
expect(dump).not.toMatch(/TELEGRAM_BOT_TOKEN/);
});

it("does not compare Hermes Telegram group policy when the manifest does not render it", async () => {
const { deps } = makeDeps({
exec: (_sandbox, command) =>
Expand Down Expand Up @@ -160,7 +84,7 @@ describe("showSandboxChannelStatus config comparison", () => {
),
).toMatchObject({
severity: "ok",
detail: "1",
detail: "mention-only (1)",
});
expect(
signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"),
Expand Down Expand Up @@ -197,6 +121,7 @@ describe("showSandboxChannelStatus config comparison", () => {
const signals = result && "signals" in result ? result.signals : [];
expect(signals.filter((signal) => signal.label === "Rendered config source")).toEqual([
expect.objectContaining({
kind: "rendered-config-source",
severity: "warn",
detail:
"rendered config source too large: /sandbox/.openclaw/openclaw.json; config comparisons not checked",
Expand Down Expand Up @@ -395,6 +320,7 @@ describe("showSandboxChannelStatus config comparison", () => {
const signals = result && "signals" in result ? result.signals : [];
expect(signals.filter((signal) => signal.label === "Rendered config source")).toEqual([
expect.objectContaining({
kind: "rendered-config-source",
severity: "warn",
detail: "could not parse /sandbox/.openclaw/openclaw.json; config comparisons not checked",
}),
Expand Down
Loading
Loading