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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=
# (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}).
# Used to enable guild-channel responses for native Discord. Default: empty map.
ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=
# Base64-encoded JSON Telegram config (e.g. {"requireMention":true}).
# When requireMention is true, Telegram groups get groupPolicy: mentions;
# otherwise groupPolicy: open (existing default). See #1737. Default: empty map.
ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=
# Set to "1" to force-disable device-pairing auth. Also auto-disabled when
# CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments)
# since terminal-based pairing is impossible in those contexts.
Expand Down Expand Up @@ -305,6 +309,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \
NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \
NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \
NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \
NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \
NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \
Expand Down
10 changes: 9 additions & 1 deletion scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map
NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config
NEMOCLAW_TELEGRAM_CONFIG_B64 Base64-encoded Telegram config (e.g. {"requireMention": true})
NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth
NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1)
NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128)
Expand Down Expand Up @@ -110,6 +111,11 @@ def build_config(env: dict | None = None) -> dict:
env.get("NEMOCLAW_DISCORD_GUILDS_B64", "e30=") or "e30="
).decode("utf-8")
)
_telegram_config = json.loads(
base64.b64decode(
env.get("NEMOCLAW_TELEGRAM_CONFIG_B64", "e30=") or "e30="
).decode("utf-8")
)

_token_keys = {"discord": "token", "telegram": "botToken", "slack": "botToken"}
_env_keys = {
Expand Down Expand Up @@ -145,7 +151,9 @@ def _placeholder(channel: str, env_key: str) -> str:
if ch in ("telegram", "discord"):
account["proxy"] = proxy_url
if ch == "telegram":
account["groupPolicy"] = "open"
account["groupPolicy"] = (
"mentions" if _telegram_config.get("requireMention") else "open"
)
if ch in _allowed_ids and _allowed_ids[ch]:
account["dmPolicy"] = "allowlist"
account["allowFrom"] = _allowed_ids[ch]
Expand Down
64 changes: 64 additions & 0 deletions src/lib/onboard-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,44 @@ describe("onboard session", () => {
expect(fresh.messagingChannels).toBeNull();
});

it("#1737: persists telegramConfig across save/load roundtrips (requireMention=true)", () => {
const created = session.createSession();
created.telegramConfig = { requireMention: true };
session.saveSession(created);

const loaded = session.loadSession()!;
expect(loaded.telegramConfig).toEqual({ requireMention: true });
});

it("#1737: persists telegramConfig across save/load roundtrips (requireMention=false)", () => {
const created = session.createSession();
created.telegramConfig = { requireMention: false };
session.saveSession(created);

const loaded = session.loadSession()!;
expect(loaded.telegramConfig).toEqual({ requireMention: false });
});

it("#1737: rejects malformed telegramConfig on load", () => {
// Simulate a hand-edited session file with garbage in telegramConfig.
// Going through saveSession() would re-normalize the value before it
// hits disk, so write raw JSON directly to exercise the load-time
// parseTelegramConfig() path.
const seed = session.createSession();
session.saveSession(seed);
const onDisk = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8"));
onDisk.telegramConfig = { requireMention: "yes" };
fs.writeFileSync(session.SESSION_FILE, JSON.stringify(onDisk));

Comment thread
latenighthackathon marked this conversation as resolved.
const loaded = session.loadSession()!;
expect(loaded.telegramConfig).toBeNull();
});

it("#1737: defaults telegramConfig to null for fresh sessions", () => {
const fresh = session.createSession();
expect(fresh.telegramConfig).toBeNull();
});

it("persists and clears web search config through safe session updates", () => {
session.saveSession(session.createSession());
session.markStepComplete("provider_selection", {
Expand Down Expand Up @@ -409,6 +447,32 @@ describe("onboard session", () => {
expect(loaded.messagingChannels).toEqual(["slack", "discord"]);
});

it("#1737: filterSafeUpdates routes telegramConfig through markStepComplete", () => {
session.saveSession(session.createSession());
session.markStepComplete("provider_selection", {
telegramConfig: { requireMention: true },
});

const loaded = session.loadSession()!;
expect(loaded.telegramConfig).toEqual({ requireMention: true });

// Explicit null (clearing the field) should also round-trip.
session.markStepComplete("provider_selection", { telegramConfig: null });
const cleared = session.loadSession()!;
expect(cleared.telegramConfig).toBeNull();
});

it("#1737: filterSafeUpdates drops malformed telegramConfig values", () => {
session.saveSession(session.createSession());
// Non-boolean requireMention — must not leak through.
session.markStepComplete("provider_selection", {
telegramConfig: { requireMention: "yes" } as unknown as { requireMention: boolean },
});

const loaded = session.loadSession()!;
expect(loaded.telegramConfig).toBeNull();
});

it("createSession with messagingChannels override", () => {
const created = session.createSession({ messagingChannels: ["telegram", "slack"] });
expect(created.messagingChannels).toEqual(["telegram", "slack"]);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/onboard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,15 @@ export interface Session {
// migrated set is NOT seeded from the persisted record, so the cleanup
// gate keeps the file until the *current* value is actually re-migrated.
migratedLegacyValueHashes: Record<string, string> | null;
telegramConfig: TelegramConfig | null;
metadata: SessionMetadata;
steps: Record<string, StepState>;
}

export interface TelegramConfig {
requireMention: boolean;
}

export interface LockInfo {
pid: number;
startedAt: string | null;
Expand Down Expand Up @@ -120,6 +125,7 @@ export interface SessionUpdates {
policyPresets?: string[];
messagingChannels?: string[];
migratedLegacyValueHashes?: Record<string, string>;
telegramConfig?: TelegramConfig | null;
Comment thread
latenighthackathon marked this conversation as resolved.
metadata?: { gatewayName?: string; fromDockerfile?: string | null };
}

Expand Down Expand Up @@ -209,6 +215,13 @@ function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchCon
return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null;
}

function parseTelegramConfig(value: unknown): TelegramConfig | null {
if (!isObject(value)) return null;
if (value.requireMention === true) return { requireMention: true };
if (value.requireMention === false) return { requireMention: false };
return null;
}

function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined {
if (!isObject(value)) return undefined;
return {
Expand Down Expand Up @@ -288,6 +301,7 @@ export function createSession(overrides: Partial<Session> = {}): Session {
migratedLegacyValueHashes: overrides.migratedLegacyValueHashes
? readStringRecord(overrides.migratedLegacyValueHashes)
: null,
telegramConfig: parseTelegramConfig(overrides.telegramConfig),
metadata: {
gatewayName: overrides.metadata?.gatewayName ?? "nemoclaw",
fromDockerfile: overrides.metadata?.fromDockerfile ?? null,
Expand Down Expand Up @@ -320,6 +334,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined):
policyPresets: readStringArray(data.policyPresets),
messagingChannels: readStringArray(data.messagingChannels),
migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes),
telegramConfig: parseTelegramConfig(data.telegramConfig),
lastStepStarted: readString(data.lastStepStarted),
lastCompletedStep: readString(data.lastCompletedStep),
failure: sanitizeFailure(isObject(data.failure) ? data.failure : null),
Expand Down Expand Up @@ -633,6 +648,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial<Session> {
}
safe.migratedLegacyValueHashes = cleaned;
}
if (isObject(updates.telegramConfig) && typeof updates.telegramConfig.requireMention === "boolean") {
safe.telegramConfig = { requireMention: updates.telegramConfig.requireMention };
} else if (updates.telegramConfig === null) {
safe.telegramConfig = null;
}
if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") {
safe.metadata = {
gatewayName: updates.metadata.gatewayName,
Expand Down
82 changes: 78 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,18 @@ let RECREATE_SANDBOX = false;
// null means "use auto-allocation" (skip dashboard port check in preflight).
let _preflightDashboardPort: number | null = null;

// Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt
// or by the user's shell) and map it to a boolean, or null when the env var
// is unset / invalid. Used at build time to bake groupPolicy into
// openclaw.json and at resume time to detect drift against the recorded
// session state. See #1737 and the CodeRabbit follow-up on #2417.
function computeTelegramRequireMention(): boolean | null {
const raw = process.env.TELEGRAM_REQUIRE_MENTION;
if (raw === "1") return true;
if (raw === "0") return false;
return null;
Comment on lines +414 to +418

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject invalid TELEGRAM_REQUIRE_MENTION values instead of silently treating them as unset.

Any value other than "0" or "1" falls through to null, and the rest of the flow interprets that as default-open behavior. In non-interactive onboarding, a typo like TELEGRAM_REQUIRE_MENTION=true will quietly disable mention-only mode and make the bot reply to every group message.

Proposed fix
 function computeTelegramRequireMention(): boolean | null {
   const raw = process.env.TELEGRAM_REQUIRE_MENTION;
+  if (raw === undefined || raw === "") return null;
   if (raw === "1") return true;
   if (raw === "0") return false;
-  return null;
+  console.error("  TELEGRAM_REQUIRE_MENTION must be set to 0 or 1.");
+  process.exit(1);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 332 - 336, The function
computeTelegramRequireMention currently returns null for any value other than
"0" or "1", silently treating typos as unset; update
computeTelegramRequireMention to explicitly validate
process.env.TELEGRAM_REQUIRE_MENTION: return true for "1", false for "0", return
null only if the env var is undefined or empty, and throw a clear Error (or
raise a validation exception) if the value is present but not "0" or "1" so
invalid configs like "true" are rejected during startup.

}

function isNonInteractive(): boolean {
return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
}
Expand Down Expand Up @@ -1709,6 +1721,7 @@ function patchStagedDockerfile(
messagingAllowedIds: LooseObject = {},
discordGuilds: LooseObject = {},
baseImageRef: string | null = null,
telegramConfig: LooseObject = {},
) {
const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } =
getSandboxInferenceConfig(model, provider, preferredInferenceApi);
Expand Down Expand Up @@ -1850,6 +1863,12 @@ function patchStagedDockerfile(
`ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`,
);
}
if (telegramConfig && Object.keys(telegramConfig).length > 0) {
dockerfile = dockerfile.replace(
/^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m,
`ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`,
);
}
fs.writeFileSync(dockerfilePath, dockerfile);
}

Expand Down Expand Up @@ -4216,6 +4235,31 @@ async function createSandbox(
};
}
}
// Telegram mention-only mode — parity with Discord's requireMention.
// Off by default so existing sandboxes behave the same; opt-in via
// TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737.
const telegramConfig: { requireMention?: boolean } = {};
if (enabledTokenEnvKeys.has("TELEGRAM_BOT_TOKEN")) {
const telegramRequireMention = computeTelegramRequireMention();
if (telegramRequireMention !== null) {
telegramConfig.requireMention = telegramRequireMention;
}
}
// Persist the effective Telegram config into the session so a later resume
// can detect drift (TELEGRAM_REQUIRE_MENTION changed since last build) and
// force a sandbox recreate — otherwise the old groupPolicy would stay baked
// in. Mirrors the pattern used for webSearchConfig. See CodeRabbit on #2417.
if (typeof telegramConfig.requireMention === "boolean") {
onboardSession.updateSession((current) => {
current.telegramConfig = { requireMention: telegramConfig.requireMention as boolean };
return current;
});
} else {
onboardSession.updateSession((current) => {
current.telegramConfig = null;
return current;
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Pull the base image and resolve its digest so the Dockerfile is pinned to
// exactly what we just fetched. This prevents stale :latest tags from
// silently reusing a cached old image after NemoClaw upgrades (#1904).
Expand Down Expand Up @@ -4254,6 +4298,7 @@ async function createSandbox(
messagingAllowedIds,
discordGuilds,
resolved ? resolved.ref : null,
telegramConfig,
);
// Only pass non-sensitive env vars to the sandbox. Credentials flow through
// OpenShell providers — the gateway injects them as placeholders and the L7
Expand Down Expand Up @@ -6088,17 +6133,23 @@ async function setupMessagingChannels(): Promise<string[]> {
}
}
}
if (ch.requireMentionEnvKey && ch.serverIdEnvKey && process.env[ch.serverIdEnvKey]) {
const existingRequireMention = process.env[ch.requireMentionEnvKey];
// Mention-control prompt: fires for any channel that exposes a
// requireMention env key. Discord gates the prompt behind a configured
// server ID (mention control only makes sense in a guild). Telegram
// has no serverIdEnvKey because mention control applies to every group
// the bot is added to, so the prompt always fires there. See #1737.
const requireMentionKey = ch.requireMentionEnvKey;
if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) {
const existingRequireMention = process.env[requireMentionKey];
if (existingRequireMention === "0" || existingRequireMention === "1") {
const mode = existingRequireMention === "0" ? "all messages" : "@mentions only";
console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`);
} else {
console.log(` ${ch.requireMentionHelp}`);
const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase();
process.env[ch.requireMentionEnvKey] = answer === "n" || answer === "no" ? "0" : "1";
process.env[requireMentionKey] = answer === "n" || answer === "no" ? "0" : "1";
const mode =
process.env[ch.requireMentionEnvKey] === "0" ? "all messages" : "@mentions only";
process.env[requireMentionKey] === "0" ? "all messages" : "@mentions only";
console.log(` ✓ ${ch.name} reply mode saved: ${mode}`);
}
}
Expand Down Expand Up @@ -8179,9 +8230,27 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {

const sandboxReuseState = getSandboxReuseState(sandboxName);
const webSearchConfigChanged = Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig);
// Telegram mention-mode is baked into openclaw.json at sandbox build time, so
// changes to TELEGRAM_REQUIRE_MENTION only take effect after a rebuild. Treat
// a mismatch between the recorded config and the current env value as drift
// so the reuse path forces a recreate (mirrors webSearchConfigChanged). See
// #1737 and the CodeRabbit review on #2417.
//
// Compare *effective* modes — null and false both produce groupPolicy: open
// at config-generation time (default behavior), so they collapse to the same
// bucket here. Without this, a sandbox built before TELEGRAM_REQUIRE_MENTION
// existed (recordedTelegramRequireMention === null) would be reused with the
// old groupPolicy: open even after the user sets TELEGRAM_REQUIRE_MENTION=1,
// and vice versa.
const currentTelegramRequireMention = computeTelegramRequireMention();
const recordedTelegramRequireMention = session?.telegramConfig?.requireMention ?? null;
const effectiveCurrent = currentTelegramRequireMention ?? false;
const effectiveRecorded = recordedTelegramRequireMention ?? false;
const telegramConfigChanged = effectiveCurrent !== effectiveRecorded;
const resumeSandbox =
resume &&
!webSearchConfigChanged &&
!telegramConfigChanged &&
session?.steps?.sandbox?.status === "complete" &&
sandboxReuseState === "ready";
if (resumeSandbox) {
Expand All @@ -8197,6 +8266,11 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
if (sandboxName) {
registry.removeSandbox(sandboxName);
}
} else if (telegramConfigChanged) {
note(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox.");
if (sandboxName) {
registry.removeSandbox(sandboxName);
}
} else if (sandboxReuseState === "not_ready") {
note(
` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/sandbox-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export const KNOWN_CHANNELS: Record<string, ChannelDef> = {
userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.",
userIdLabel: "Telegram User ID (for DM access)",
allowIdsMode: "dm",
requireMentionEnvKey: "TELEGRAM_REQUIRE_MENTION",
requireMentionHelp:
"Controls Telegram group-chat behavior only — reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS.",
},
discord: {
envKey: "DISCORD_BOT_TOKEN",
Expand Down
Loading
Loading