diff --git a/docs/_components/StarterPromptButton.tsx b/docs/_components/StarterPromptButton.tsx index 168f6543fd9..a7867e5aa33 100644 --- a/docs/_components/StarterPromptButton.tsx +++ b/docs/_components/StarterPromptButton.tsx @@ -122,7 +122,7 @@ Channel credential requirements: | Channel | Required values | |---|---| -| Telegram | \`TELEGRAM_BOT_TOKEN\`; optional \`TELEGRAM_ALLOWED_IDS\`, \`TELEGRAM_REQUIRE_MENTION\` | +| Telegram | \`TELEGRAM_BOT_TOKEN\`; optional \`TELEGRAM_ALLOWED_IDS\`, \`TELEGRAM_REQUIRE_MENTION\`, \`TELEGRAM_GROUP_POLICY\` (OpenClaw only) | | Discord | \`DISCORD_BOT_TOKEN\`; optional \`DISCORD_SERVER_ID\`, \`DISCORD_USER_ID\`, \`DISCORD_REQUIRE_MENTION\` | | Slack | \`SLACK_BOT_TOKEN\`, \`SLACK_APP_TOKEN\`; optional \`SLACK_ALLOWED_USERS\`, \`SLACK_ALLOWED_CHANNELS\` | | WhatsApp | No host token; add the channel, rebuild, then complete QR pairing inside the sandbox as documented | diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index ab805c4c113..7ab86d9713c 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -54,7 +54,7 @@ For details, refer to [Commands](../reference/commands). | Channel | Required tokens | Optional settings | |---------|-----------------|-------------------| -| Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_ALLOWED_IDS` for DM allowlisting, `TELEGRAM_REQUIRE_MENTION` for group-chat replies | +| Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_ALLOWED_IDS` for DM allowlisting, `TELEGRAM_REQUIRE_MENTION` for group-chat replies, `TELEGRAM_GROUP_POLICY` for OpenClaw group access | | Discord | `DISCORD_BOT_TOKEN` | `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION` | | Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting | | WeChat (experimental) | None. Captured through host-side QR scan during `$$nemoclaw onboard` | `WECHAT_ALLOWED_IDS` for DM allowlisting | @@ -68,12 +68,17 @@ After changing privacy mode, remove the bot from each Telegram group and add it 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. +NemoClaw defaults `TELEGRAM_GROUP_POLICY` to `open` for OpenClaw. +Set `TELEGRAM_GROUP_POLICY=allowlist` to block OpenClaw group chats until explicit group entries are configured, or `TELEGRAM_GROUP_POLICY=disabled` to disable OpenClaw Telegram group access. +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. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. Discord uses a bot token from the Discord Developer Portal. For server channels, enable Developer Mode in Discord, right-click the server, and copy the Server ID into `DISCORD_SERVER_ID`. By default, NemoClaw configures the bot to reply only when mentioned. +If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw defaults `DISCORD_REQUIRE_MENTION` to `1`. Set `DISCORD_REQUIRE_MENTION=0` if you want it to reply to all messages in the configured server. Set `DISCORD_USER_ID` to restrict access to one user; otherwise, any member of the configured server can message the bot. @@ -201,7 +206,9 @@ Verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. 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. +If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. +You can omit `TELEGRAM_REQUIRE_MENTION` and `DISCORD_REQUIRE_MENTION` when you want the default mention-only mode. +You can omit `TELEGRAM_GROUP_POLICY` when you want OpenClaw Telegram group access to stay open. 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: @@ -211,6 +218,8 @@ $$nemoclaw my-assistant rebuild ``` In non-interactive mode, set the required environment variables before running `channels add`. +Optional mention-mode settings that declare defaults are still written when unset. +Telegram mention mode defaults to `1`; Discord mention mode defaults to `1` when `DISCORD_SERVER_ID` is set. Missing credentials fail fast, and the command queues the change for a manual rebuild: ```bash diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index b13e20a93cc..380dedd614d 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -255,6 +255,7 @@ The following environment variables configure optional services and local access |---|---| | `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `$$nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | | `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | +| `TELEGRAM_GROUP_POLICY` | OpenClaw Telegram group access policy: `open` by default, `allowlist` to require explicit group entries, or `disabled` to turn off OpenClaw group access. Hermes ignores this value. | | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) you provide before `$$nemoclaw onboard`. Stored as an OpenShell provider; never passed directly to the sandbox. | | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`) required for Socket Mode. Stored alongside `SLACK_BOT_TOKEN` during onboarding. | | `SLACK_ALLOWED_USERS` | Comma-separated Slack member IDs for DM and channel `@mention` user allowlisting. | diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index d79758d8732..bf48a80887f 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -207,9 +207,13 @@ If you enable Discord during onboarding, the wizard can also prompt for a Discor NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. Guild responses remain mention-gated by default unless you opt into all-message replies. +If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw records the existing mention-only default (`DISCORD_REQUIRE_MENTION=1`). If you enable Telegram during onboarding, the wizard can also prompt for whether group chats should reply only to `@mentions` or to all group messages. -Set `TELEGRAM_REQUIRE_MENTION=1` for non-interactive onboarding when you want mention-only group replies. +Mention-only group replies are the default. +Set `TELEGRAM_REQUIRE_MENTION=0` for non-interactive onboarding when you want all group messages to trigger replies. +For OpenClaw, Telegram group access defaults to `TELEGRAM_GROUP_POLICY=open`; set `TELEGRAM_GROUP_POLICY=allowlist` or `TELEGRAM_GROUP_POLICY=disabled` before non-interactive onboarding when you want stricter group access. +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. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. If you cancel a brand-new onboarding run at the policy preset step, NemoClaw rolls back the sandbox, registry entry, and onboarding session instead of leaving a default sandbox with unfinished policy state. @@ -880,6 +884,8 @@ nemohermes my-assistant channels add telegram Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. +Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. +Discord applies that default only when a server ID is configured. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `nemohermes rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 679ab4e59de..aa3ec686942 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -289,9 +289,13 @@ If you enable Discord during onboarding, the wizard can also prompt for a Discor NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. Guild responses remain mention-gated by default unless you opt into all-message replies. +If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw records the existing mention-only default (`DISCORD_REQUIRE_MENTION=1`). If you enable Telegram during onboarding, the wizard can also prompt for whether group chats should reply only to `@mentions` or to all group messages. -Set `TELEGRAM_REQUIRE_MENTION=1` for non-interactive onboarding when you want mention-only group replies. +Mention-only group replies are the default. +Set `TELEGRAM_REQUIRE_MENTION=0` for non-interactive onboarding when you want all group messages to trigger replies. +For OpenClaw, Telegram group access defaults to `TELEGRAM_GROUP_POLICY=open`; set `TELEGRAM_GROUP_POLICY=allowlist` or `TELEGRAM_GROUP_POLICY=disabled` before non-interactive onboarding when you want stricter group access. +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. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. If you cancel a brand-new onboarding run at the policy preset step, NemoClaw rolls back the sandbox, registry entry, and onboarding session instead of leaving a default sandbox with unfinished policy state. @@ -1126,6 +1130,8 @@ $$nemoclaw my-assistant channels add telegram Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. +Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. +Discord applies that default only when a server ID is configured. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 0bda82ff97e..2d1cb5a05d0 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -22,17 +22,20 @@ describe("messaging channel config", () => { "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", + "TELEGRAM_GROUP_POLICY", "WECHAT_ACCOUNT_ID", "WECHAT_BASE_URL", "WECHAT_USER_ID", ]); }); - it("sanitizes persisted config and rejects malformed reply-mode values", () => { + it("sanitizes persisted config and rejects malformed choice values", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_ALLOWED_IDS: " 123,456 ", TELEGRAM_REQUIRE_MENTION: "yes", + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_GROUP_POLICY_INVALID: "disabled", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: " U01ABC2DEF3, U04GHI5JKL6 ", @@ -41,6 +44,7 @@ describe("messaging channel config", () => { }), ).toEqual({ TELEGRAM_ALLOWED_IDS: "123,456", + TELEGRAM_GROUP_POLICY: "allowlist", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: "U01ABC2DEF3, U04GHI5JKL6", @@ -93,6 +97,7 @@ describe("messaging channel config", () => { { TELEGRAM_ALLOWED_IDS: "stored-user", TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_GROUP_POLICY: "nonsense", DISCORD_REQUIRE_MENTION: "maybe", }, env, @@ -103,6 +108,7 @@ describe("messaging channel config", () => { }); expect(env.TELEGRAM_ALLOWED_IDS).toBe("env-user"); expect(env.TELEGRAM_REQUIRE_MENTION).toBe("1"); + expect(env.TELEGRAM_GROUP_POLICY).toBeUndefined(); expect(env.DISCORD_REQUIRE_MENTION).toBeUndefined(); }); @@ -121,10 +127,12 @@ describe("messaging channel config", () => { DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "2", TELEGRAM_REQUIRE_MENTION: "0", + TELEGRAM_GROUP_POLICY: "disabled", }), ).toEqual({ DISCORD_SERVER_ID: "1491590992753590594", TELEGRAM_REQUIRE_MENTION: "0", + TELEGRAM_GROUP_POLICY: "disabled", }); }); }); diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 19402ab53c3..6ca2c37f397 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -15,12 +15,21 @@ const manifestConfigInputs = BUILT_IN_CHANNEL_MANIFESTS.flatMap((manifest) => validValues: "validValues" in input ? input.validValues : undefined, })), ); -const requireMentionKeys = new Set( - [ - ...channels.map((channel) => channel.requireMentionEnvKey), - ...manifestConfigInputs.filter(hasBooleanStringValues).map((input) => input.envKey), - ].filter((key): key is string => typeof key === "string" && key.length > 0), +const validValuesByKey = new Map>( + manifestConfigInputs.flatMap((input) => { + if ( + typeof input.envKey !== "string" || + input.envKey.length === 0 || + !Array.isArray(input.validValues) + ) { + return []; + } + return [[input.envKey, new Set(input.validValues)] as const]; + }), ); +for (const key of channels.map((channel) => channel.requireMentionEnvKey)) { + if (key && !validValuesByKey.has(key)) validValuesByKey.set(key, new Set(["0", "1"])); +} const configKeyAliases = getMessagingConfigEnvAliases(); @@ -49,10 +58,6 @@ export const MESSAGING_CHANNEL_CONFIG_ENV_KEYS: readonly string[] = [ const knownConfigKeys = new Set(MESSAGING_CHANNEL_CONFIG_ENV_KEYS); -function hasBooleanStringValues(input: { readonly validValues?: readonly string[] }): boolean { - return input.validValues?.includes("0") === true && input.validValues.includes("1"); -} - export type MessagingChannelConfigEnvResolution = { canonicalKey: string | null; sourceKey: string | null; @@ -83,7 +88,8 @@ export function normalizeMessagingChannelConfigValue(key: string, value: unknown if (!canonical) return null; const normalized = normalizeValue(value); if (!normalized) return null; - if (requireMentionKeys.has(canonical) && normalized !== "0" && normalized !== "1") { + const validValues = validValuesByKey.get(canonical); + if (validValues && !validValues.has(normalized)) { return null; } return normalized; diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 1956b8c0e8c..248e80688ca 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -377,7 +377,7 @@ describe("MessagingSetupApplier", () => { enabled: true, groupPolicy: "open", }); - expect(openclawConfig.channels.telegram.groups).toBeUndefined(); + expect(openclawConfig.channels.telegram.groups).toEqual({ "*": { requireMention: true } }); expect(result.appliedTargets).toEqual(["/sandbox/.openclaw/openclaw.json"]); expect(result.appliedHooks).toEqual([]); expect(result.unresolvedTemplateRefs).toEqual([]); diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 1913f5b27cf..0db2158bdd2 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -44,6 +44,7 @@ export const discordManifest = { statePath: "discordGuilds.requireMention", promptWhenInput: "serverId", validValues: ["0", "1"], + defaultValue: "1", prompt: { label: "Discord mention mode", help: "Choose whether the bot should reply only when @mentioned or to all messages in this server.", diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index d12299f2970..9654133aabd 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -304,10 +304,19 @@ describe("built-in channel manifests", () => { const botToken = findInput(telegramManifest, "botToken"); const allowedIds = findInput(telegramManifest, "allowedIds"); const requireMention = findInput(telegramManifest, "requireMention"); + const groupPolicy = findInput(telegramManifest, "groupPolicy"); expect(getChannelTokenKeys(KNOWN_CHANNELS.telegram)).toEqual(["TELEGRAM_BOT_TOKEN"]); expect(botToken.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(allowedIds.envKey).toBe("TELEGRAM_ALLOWED_IDS"); expect(requireMention.envKey).toBe("TELEGRAM_REQUIRE_MENTION"); + expect(requireMention).toMatchObject({ kind: "config", defaultValue: "1" }); + expect(groupPolicy).toMatchObject({ + kind: "config", + envKey: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + defaultValue: "open", + validValues: ["open", "allowlist", "disabled"], + }); expect(KNOWN_CHANNELS.telegram.allowIdsMode).toBe("dm"); expect(telegramManifest.credentials).toEqual([ { @@ -327,6 +336,8 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("groupPolicy"); expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); + expect(renderJson(telegramManifest)).toContain("telegramConfig.groupPolicy"); + expect(renderJson(telegramManifest)).toContain("telegramConfig.openclawGroups"); expect(renderJson(telegramManifest)).toContain("platforms.telegram"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expect(telegramManifest.hooks).toContainEqual({ @@ -341,6 +352,18 @@ describe("built-in channel manifests", () => { ], }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); + expect(telegramManifest.hooks).toContainEqual({ + id: "telegram-openclaw-config-prompt", + phase: "enroll", + handler: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + agents: ["openclaw"], + outputs: [ + { + id: "groupPolicy", + kind: "config", + }, + ], + }); expectReachabilityHook(telegramManifest, ["botToken"]); expectOpenClawNodePreload(telegramManifest, "telegram-diagnostics"); expect(JSON.stringify(telegramManifest.runtime?.openclaw)).toContain("telegram-diagnostics"); @@ -367,6 +390,7 @@ describe("built-in channel manifests", () => { expect(botToken.envKey).toBe("DISCORD_BOT_TOKEN"); expect(serverId.envKey).toBe("DISCORD_SERVER_ID"); expect(requireMention.envKey).toBe("DISCORD_REQUIRE_MENTION"); + expect(requireMention).toMatchObject({ kind: "config", defaultValue: "1" }); expect(userId.envKey).toBe("DISCORD_USER_ID"); expect(KNOWN_CHANNELS.discord.allowIdsMode).toBe("guild"); expect(discordManifest.credentials).toEqual([ diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 7eaf2f8d583..1e498d9110a 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -67,6 +67,7 @@ describe("built-in messaging channel metadata", () => { expect(listMessagingConfigEnvKeys()).toEqual([ "TELEGRAM_ALLOWED_IDS", "TELEGRAM_REQUIRE_MENTION", + "TELEGRAM_GROUP_POLICY", "DISCORD_SERVER_ID", "DISCORD_REQUIRE_MENTION", "DISCORD_USER_ID", diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 3981612c2e6..0bed20f86e1 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -46,11 +46,25 @@ export const telegramManifest = { envKey: "TELEGRAM_REQUIRE_MENTION", statePath: "telegramConfig.requireMention", validValues: ["0", "1"], + defaultValue: "1", prompt: { label: "Telegram group mention mode", help: "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.", }, }, + { + id: "groupPolicy", + kind: "config", + required: false, + envKey: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + validValues: ["open", "allowlist", "disabled"], + defaultValue: "open", + prompt: { + label: "Telegram group policy", + help: "Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy.", + }, + }, ], credentials: [ { @@ -88,7 +102,7 @@ export const telegramManifest = { enabled: false, }, proxy: "{{proxyUrl}}", - groupPolicy: "open", + groupPolicy: "{{telegramConfig.groupPolicy}}", dmPolicy: "{{allowedIds.telegram.dmPolicy}}", allowFrom: "{{allowedIds.telegram.values}}", }, @@ -101,14 +115,10 @@ export const telegramManifest = { kind: "json-fragment", agent: "openclaw", target: "openclaw.json", - when: "{{telegramConfig.requireMention}}", + when: "{{telegramConfig.openclawGroups}}", fragment: { path: "channels.telegram.groups", - value: { - "*": { - requireMention: "{{telegramConfig.requireMention}}", - }, - }, + value: "{{telegramConfig.openclawGroups}}", }, }, { @@ -180,7 +190,7 @@ export const telegramManifest = { state: { persist: { allowedIds: ["allowedIds"], - telegramConfig: ["requireMention"], + telegramConfig: ["requireMention", "groupPolicy"], }, rebuildHydration: [ { @@ -191,6 +201,10 @@ export const telegramManifest = { statePath: "telegramConfig.requireMention", env: "TELEGRAM_REQUIRE_MENTION", }, + { + statePath: "telegramConfig.groupPolicy", + env: "TELEGRAM_GROUP_POLICY", + }, ], }, hooks: [ @@ -233,6 +247,18 @@ export const telegramManifest = { }, ], }, + { + id: "telegram-openclaw-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + agents: ["openclaw"], + outputs: [ + { + id: "groupPolicy", + kind: "config", + }, + ], + }, { id: "telegram-get-me-reachability", phase: "reachability-check", diff --git a/src/lib/messaging/channels/telegram/template-resolver.test.ts b/src/lib/messaging/channels/telegram/template-resolver.test.ts new file mode 100644 index 00000000000..d3b7d7ab607 --- /dev/null +++ b/src/lib/messaging/channels/telegram/template-resolver.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingInputReference } from "../../manifest"; +import { resolveTelegramTemplateReference } from "./template-resolver"; + +describe("Telegram template resolver", () => { + it.each([ + ["open", { "*": { requireMention: true } }], + ["allowlist", undefined], + ["disabled", undefined], + ] as const)("resolves OpenClaw group policy %s", (groupPolicy, expectedGroups) => { + const inputs: SandboxMessagingInputReference[] = [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + statePath: "telegramConfig.requireMention", + value: "1", + }, + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + statePath: "telegramConfig.groupPolicy", + value: groupPolicy, + }, + ]; + + expect(resolveTelegramTemplateReference("telegramConfig.groupPolicy", { inputs })?.value).toBe( + groupPolicy, + ); + expect( + resolveTelegramTemplateReference("telegramConfig.openclawGroups", { inputs })?.value, + ).toEqual(expectedGroups); + }); +}); diff --git a/src/lib/messaging/channels/telegram/template-resolver.ts b/src/lib/messaging/channels/telegram/template-resolver.ts index 7031e7ddc91..ef0b5bf6845 100644 --- a/src/lib/messaging/channels/telegram/template-resolver.ts +++ b/src/lib/messaging/channels/telegram/template-resolver.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { RenderTemplateContext } from "../../compiler/engines/template"; +import type { MessagingSerializableValue } from "../../manifest"; import { allowedIds, type BuiltInRenderTemplateResolver, @@ -15,12 +16,20 @@ import { const DEFAULT_PROXY_HOST = "10.200.0.1"; const DEFAULT_PROXY_PORT = "3128"; +const DEFAULT_TELEGRAM_GROUP_POLICY = "open"; +const TELEGRAM_GROUP_POLICIES = new Set(["open", "allowlist", "disabled"]); export const resolveTelegramTemplateReference: BuiltInRenderTemplateResolver = ( reference, context, ) => { if (reference === "proxyUrl") return resolvedRenderTemplateReference(proxyUrl(context.env)); + if (reference === "telegramConfig.groupPolicy") { + return resolvedRenderTemplateReference(telegramGroupPolicy(context)); + } + if (reference === "telegramConfig.openclawGroups") { + return resolvedRenderTemplateReference(telegramOpenClawGroups(context)); + } if (reference === "telegramConfig.requireMention") { return resolvedRenderTemplateReference( parseBoolean(stateValue(context, "telegramConfig.requireMention")), @@ -47,3 +56,16 @@ function proxyUrl(env: RenderTemplateContext["env"]): string { const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; return `http://${host}:${port}`; } + +function telegramGroupPolicy(context: RenderTemplateContext): string { + const value = nonEmptyString(stateValue(context, "telegramConfig.groupPolicy")); + return value && TELEGRAM_GROUP_POLICIES.has(value) ? value : DEFAULT_TELEGRAM_GROUP_POLICY; +} + +function telegramOpenClawGroups( + context: RenderTemplateContext, +): Record | undefined { + if (telegramGroupPolicy(context) !== "open") return undefined; + const requireMention = parseBoolean(stateValue(context, "telegramConfig.requireMention")); + return requireMention === true ? { "*": { requireMention: true } } : undefined; +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index b3e72304a87..08b20dd93cb 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -343,6 +343,7 @@ describe("ManifestCompiler", () => { expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ "telegram:~/.hermes/.env", "telegram:~/.hermes/config.yaml", + "telegram:~/.hermes/config.yaml", "discord:~/.hermes/.env", "discord:~/.hermes/config.yaml", "discord:~/.hermes/config.yaml", @@ -550,6 +551,7 @@ describe("ManifestCompiler", () => { "telegram-token-paste", "telegram-allowlist-aliases", "telegram-config-prompt", + "telegram-openclaw-config-prompt", "telegram-get-me-reachability", "telegram-openclaw-bridge-health", "telegram-gateway-conflict-status", @@ -564,6 +566,7 @@ describe("ManifestCompiler", () => { "telegram", "telegram", "telegram", + "telegram", ]); expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); }); @@ -716,6 +719,226 @@ describe("ManifestCompiler", () => { ); }); + it("reads config default values when env keys are unset", async () => { + const customManifest = { + schemaVersion: 1, + id: "matrix", + displayName: "Matrix", + supportedAgents: ["openclaw"], + auth: { + mode: "none", + }, + inputs: [ + { + id: "messagingPort", + kind: "config", + required: true, + envKey: "MATRIX_MESSAGING_PORT", + formatPattern: "^[0-9]+$", + defaultValue: "3978", + prompt: { + label: "Messaging port", + }, + }, + { + id: "groupPolicy", + kind: "config", + required: true, + envKey: "MATRIX_GROUP_POLICY", + validValues: ["open", "allowlist", "block"], + defaultValue: "open", + prompt: { + label: "Group policy", + }, + }, + ], + credentials: [], + policyPresets: [], + render: [], + state: {}, + hooks: [], + } as const satisfies ChannelManifest; + + await withEnv( + { + MATRIX_MESSAGING_PORT: undefined, + MATRIX_GROUP_POLICY: undefined, + }, + async () => { + const plan = await new ManifestCompiler( + new ChannelManifestRegistry([customManifest]), + new MessagingHookRegistry([]), + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["matrix"], + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "matrix", + configured: true, + disabled: false, + }); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "messagingPort", + kind: "config", + value: "3978", + }), + ); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "groupPolicy", + kind: "config", + value: "open", + }), + ); + }, + ); + }); + + it("leaves config defaults for interactive enrollment hooks to collect", async () => { + const hookInputs: unknown[] = []; + const customManifest = { + schemaVersion: 1, + id: "matrix", + displayName: "Matrix", + supportedAgents: ["openclaw"], + auth: { + mode: "none", + }, + inputs: [ + { + id: "messagingPort", + kind: "config", + required: false, + envKey: "MATRIX_MESSAGING_PORT", + defaultValue: "3978", + prompt: { + label: "Messaging port", + }, + }, + ], + credentials: [], + policyPresets: [], + render: [], + state: {}, + hooks: [ + { + id: "matrix-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + inputs: ["messagingPort"], + outputs: [{ id: "messagingPort", kind: "config" }], + }, + ], + } as const satisfies ChannelManifest; + const hooks = new MessagingHookRegistry([ + { + id: "common.configPrompt", + handler: (context) => { + hookInputs.push(context.inputs); + return { + outputs: { + messagingPort: { + kind: "config", + value: "3978", + }, + }, + }; + }, + }, + ]); + + await withEnv( + { + MATRIX_MESSAGING_PORT: undefined, + }, + async () => { + const plan = await new ManifestCompiler( + new ChannelManifestRegistry([customManifest]), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["matrix"], + }); + + expect(hookInputs).toEqual([{}]); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "messagingPort", + value: "3978", + }), + ); + }, + ); + }); + + it("does not apply gated config defaults until the gate input is available", async () => { + const customManifest = { + schemaVersion: 1, + id: "matrix", + displayName: "Matrix", + supportedAgents: ["openclaw"], + auth: { + mode: "none", + }, + inputs: [ + { + id: "roomId", + kind: "config", + required: false, + envKey: "MATRIX_ROOM_ID", + }, + { + id: "threadMode", + kind: "config", + required: false, + envKey: "MATRIX_THREAD_MODE", + promptWhenInput: "roomId", + validValues: ["0", "1"], + defaultValue: "1", + }, + ], + credentials: [], + policyPresets: [], + render: [], + state: {}, + hooks: [], + } as const satisfies ChannelManifest; + + await withEnv( + { + MATRIX_ROOM_ID: undefined, + MATRIX_THREAD_MODE: undefined, + }, + async () => { + const plan = await new ManifestCompiler( + new ChannelManifestRegistry([customManifest]), + new MessagingHookRegistry([]), + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["matrix"], + }); + + const threadMode = plan.channels[0]?.inputs.find((input) => input.inputId === "threadMode"); + expect(threadMode).toMatchObject({ + inputId: "threadMode", + kind: "config", + }); + expect(threadMode).not.toHaveProperty("value"); + }, + ); + }); + it("keeps compiled plans serializable, deterministic, and secret-free", async () => { const context = { sandboxName: "demo", @@ -773,19 +996,25 @@ describe("ManifestCompiler", () => { expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); - expect(plan.agentRender.map((render) => render.channelId)).toEqual(["telegram", "telegram"]); + expect(plan.agentRender.map((render) => render.channelId)).toEqual([ + "telegram", + "telegram", + "telegram", + ]); expect(plan.buildSteps).toEqual([]); expect(plan.stateUpdates.map((entry) => entry.channelId)).toEqual([ "telegram", "telegram", "telegram", "telegram", + "telegram", ]); expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", "telegram-allowlist-aliases", "telegram-config-prompt", + "telegram-openclaw-config-prompt", "telegram-get-me-reachability", "telegram-openclaw-bridge-health", "telegram-gateway-conflict-status", diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 78d67355841..30f7758c230 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -225,7 +225,17 @@ async function resolveChannelInputs( readonly inputs: SandboxMessagingInputReference[]; readonly skipped: boolean; }> { - let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); + const initialInputValues: Record = {}; + let inputs = manifest.inputs.map((input) => { + const resolved = resolveChannelInput(manifest, input, context, initialInputValues, { + applyDefaults: !(options.runEnrollment && options.isInteractive), + }); + if (resolved.value !== undefined) { + initialInputValues[resolved.inputId] = resolved.value; + if (resolved.statePath) initialInputValues[resolved.statePath] = resolved.value; + } + return resolved; + }); inputs = applyCredentialAvailability(manifest, inputs, context); let hookInputs = buildCompilerHookInputs(manifest, inputs); const enrollmentHooks = options.runEnrollment @@ -295,6 +305,8 @@ function resolveChannelInput( manifest: ChannelManifest, input: ChannelInputSpec, context: ManifestCompilerContext, + availableInputs: Record, + options: { readonly applyDefaults: boolean }, ): SandboxMessagingInputReference { const base = inputReferenceBase(manifest, input); const envValue = readInputEnvValue(input); @@ -303,6 +315,10 @@ function resolveChannelInput( ? { ...base, credentialAvailable: true } : { ...base, value: envValue }; } + if (options.applyDefaults) { + const defaultValue = readInputDefaultValue(input, availableInputs); + if (defaultValue !== undefined) return { ...base, value: defaultValue }; + } return { ...base, @@ -326,29 +342,47 @@ function inputReferenceBase( } function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { - const normalize = (raw: string | null | undefined): string | undefined => { - if (raw && /[\r\n]/.test(raw)) { - throw new Error("Messaging input values must not contain line breaks."); + if (input.envKey) { + if (input.kind === "config") { + const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); + const normalizedResolved = normalizeInputValue(input, resolved.value); + if (normalizedResolved !== undefined) return normalizedResolved; } - const normalized = raw?.trim(); - if (!normalized || normalized.length === 0) return undefined; - if (input.validValues && !input.validValues.includes(normalized)) return undefined; - return normalized; - }; - - if (!input.envKey) return undefined; - if (input.kind === "config") { - const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); - const normalizedResolved = normalize(resolved.value); - if (normalizedResolved !== undefined) return normalizedResolved; + const normalizedEnv = normalizeInputValue(input, process.env[input.envKey]); + if (normalizedEnv !== undefined) return normalizedEnv; } - return normalize(process.env[input.envKey]); + return undefined; } function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { return input.kind === "config" ? input.statePath : undefined; } +function readInputDefaultValue( + input: ChannelInputSpec, + availableInputs: Record, +): MessagingSerializableValue | undefined { + if (input.kind !== "config") return undefined; + if (input.promptWhenInput && !hasInputValue(availableInputs, input.promptWhenInput)) { + return undefined; + } + return normalizeInputValue(input, input.defaultValue); +} + +function normalizeInputValue( + input: ChannelInputSpec, + raw: string | null | undefined, +): string | undefined { + if (raw && /[\r\n]/.test(raw)) { + throw new Error("Messaging input values must not contain line breaks."); + } + const normalized = raw?.trim(); + if (!normalized || normalized.length === 0) return undefined; + if (input.validValues && !input.validValues.includes(normalized)) return undefined; + if (input.formatPattern && !new RegExp(input.formatPattern).test(normalized)) return undefined; + return normalized; +} + function isCredentialAvailable( manifest: ChannelManifest, input: SandboxMessagingInputReference, @@ -465,6 +499,14 @@ function hasDeclaredHookInputs(inputs: MessagingHookInputMap, hook: ChannelHookS return (hook.inputs ?? []).every((inputKey) => Object.hasOwn(inputs, inputKey)); } +function hasInputValue( + inputs: Record, + inputId: string, +): boolean { + const value = inputs[inputId]; + return typeof value === "string" ? value.trim().length > 0 : value !== undefined; +} + function selectDeclaredHookInputs( hook: ChannelHookSpec, inputs: MessagingHookInputMap, diff --git a/src/lib/messaging/hooks/common/config-prompt.test.ts b/src/lib/messaging/hooks/common/config-prompt.test.ts index ddf2a764e44..af4567040e9 100644 --- a/src/lib/messaging/hooks/common/config-prompt.test.ts +++ b/src/lib/messaging/hooks/common/config-prompt.test.ts @@ -20,7 +20,8 @@ describe("common config-prompt hook implementation", () => { log: () => {}, prompt: async (question) => { questions.push(question); - return question.includes("Reply only") ? "n" : "123456789"; + if (question.includes("Reply only")) return "n"; + return "123456789"; }, }), }, @@ -54,6 +55,48 @@ describe("common config-prompt hook implementation", () => { expect(env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); }); + it("prompts the OpenClaw-only Telegram group policy hook", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return ""; + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find( + (entry) => entry.id === "telegram-openclaw-config-prompt", + ); + + if (!hook) throw new Error("missing Telegram OpenClaw config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: { + groupPolicy: { + kind: "config", + value: "open", + }, + }, + }); + expect(hook.agents).toEqual(["openclaw"]); + expect(questions).toEqual([ + " Telegram group policy [open/allowlist/disabled; default: open]: ", + ]); + expect(env.TELEGRAM_GROUP_POLICY).toBe("open"); + }); + it("gates dependent prompts on earlier manifest config input values", async () => { const questions: string[] = []; const registry = new MessagingHookRegistry([ @@ -83,6 +126,118 @@ describe("common config-prompt hook implementation", () => { expect(questions).toEqual([" Discord Server ID (for guild workspace access): "]); }); + it("uses manifest config defaults when an interactive answer is blank", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const handler = createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return " "; + }, + resolveField: () => ({ + id: "messagingPort", + envKey: "MATRIX_MESSAGING_PORT", + label: "Messaging port", + defaultValue: "3978", + format: /^[0-9]+$/, + }), + }); + + await expect( + handler({ + channelId: "matrix", + hookId: "matrix-config-prompt", + phase: "enroll", + outputDeclarations: [{ id: "messagingPort", kind: "config", required: false }], + }), + ).resolves.toEqual({ + outputs: { + messagingPort: { + kind: "config", + value: "3978", + }, + }, + }); + expect(questions).toEqual([" Messaging port [default: 3978]: "]); + expect(env.MATRIX_MESSAGING_PORT).toBe("3978"); + }); + + it("uses manifest config defaults when env is unset in non-interactive mode", async () => { + const env: NodeJS.ProcessEnv = {}; + const handler = createConfigPromptHook({ + env, + log: () => {}, + prompt: async () => { + throw new Error("non-interactive default should not prompt"); + }, + resolveField: () => ({ + id: "messagingPort", + envKey: "MATRIX_MESSAGING_PORT", + label: "Messaging port", + defaultValue: "3978", + format: /^[0-9]+$/, + }), + }); + + await expect( + handler({ + channelId: "matrix", + hookId: "matrix-config-prompt", + phase: "enroll", + isInteractive: false, + outputDeclarations: [{ id: "messagingPort", kind: "config", required: false }], + }), + ).resolves.toEqual({ + outputs: { + messagingPort: { + kind: "config", + value: "3978", + }, + }, + }); + expect(env.MATRIX_MESSAGING_PORT).toBe("3978"); + }); + + it("shows choice sets and defaults for generic multi-choice config prompts", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const handler = createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return ""; + }, + resolveField: () => ({ + id: "groupPolicy", + envKey: "MATRIX_GROUP_POLICY", + label: "Group policy", + validValues: ["open", "allowlist", "block"], + defaultValue: "open", + }), + }); + + await expect( + handler({ + channelId: "matrix", + hookId: "matrix-config-prompt", + phase: "enroll", + outputDeclarations: [{ id: "groupPolicy", kind: "config", required: false }], + }), + ).resolves.toEqual({ + outputs: { + groupPolicy: { + kind: "config", + value: "open", + }, + }, + }); + expect(questions).toEqual([" Group policy [open/allowlist/block; default: open]: "]); + expect(env.MATRIX_GROUP_POLICY).toBe("open"); + }); + it("prompts Slack user and channel allowlists from the manifest", async () => { const env: NodeJS.ProcessEnv = {}; const questions: string[] = []; @@ -135,6 +290,7 @@ describe("common config-prompt hook implementation", () => { handler: createConfigPromptHook({ env: { TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_GROUP_POLICY: "open", TELEGRAM_ALLOWED_IDS: "123456789", }, log: (message) => logs.push(message), diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts index 2d40232da8a..c15923addcd 100644 --- a/src/lib/messaging/hooks/common/config-prompt.ts +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { resolveMessagingChannelConfigEnvValue } from "../../../messaging-channel-config"; import { createBuiltInChannelManifestRegistry } from "../../channels"; import type { ChannelConfigInputSpec, @@ -8,7 +9,6 @@ import type { ChannelManifest, MessagingSerializableValue, } from "../../manifest"; -import { resolveMessagingChannelConfigEnvValue } from "../../../messaging-channel-config"; import type { MessagingHookHandler, MessagingHookInputMap, @@ -22,9 +22,13 @@ export interface ConfigPromptField { readonly id: string; readonly envKey: string; readonly label: string; + readonly defaultValue?: string; readonly help?: string; + readonly placeholder?: string; readonly emptyValueMessage?: string; readonly validValues?: readonly string[]; + readonly format?: RegExp; + readonly formatHint?: string; readonly promptWhenInput?: string; readonly statePath?: string; } @@ -66,6 +70,11 @@ export function createConfigPromptHook( } if (context.isInteractive === false) { + const defaultValue = readDefaultConfigValue(field); + if (defaultValue) { + recordConfigValue(field, defaultValue, outputs, availableInputs, options); + logDefaultConfigInput(context.channelId, field, defaultValue, options); + } continue; } @@ -117,9 +126,13 @@ export function resolveManifestConfigPromptField( id: input.id, envKey: input.envKey, label: input.prompt.label, + defaultValue: input.defaultValue, help: input.prompt.help, + placeholder: input.prompt.placeholder, emptyValueMessage: input.prompt.emptyValueMessage, validValues: input.validValues, + format: input.formatPattern ? new RegExp(input.formatPattern) : undefined, + formatHint: input.formatHint, promptWhenInput: input.promptWhenInput, statePath: input.statePath, }; @@ -163,10 +176,16 @@ async function promptConfigInputValue( ): Promise { const prompt = options.prompt ?? missingConfigPrompt; if (isMentionModeInput(field)) { - const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); + const defaultValue = readDefaultConfigValue(field) ?? "1"; + const suffix = defaultValue === "0" ? "[y/N]" : "[Y/n]"; + const answer = (await prompt(` Reply only when @mentioned? ${suffix}: `)).trim().toLowerCase(); + if (!answer) return defaultValue; return answer === "n" || answer === "no" ? "0" : "1"; } - return normalizeConfigValue(field, await prompt(` ${field.label}: `)); + const answer = await prompt(formatConfigPromptQuestion(field)); + return isBlankConfigValue(answer) + ? readDefaultConfigValue(field) + : normalizeConfigValue(field, answer); } async function missingConfigPrompt(): Promise { @@ -178,9 +197,30 @@ function normalizeConfigValue(field: ConfigPromptField, value: unknown): string const normalized = value.replace(/\r/g, "").trim(); if (!normalized) return null; if (field.validValues && !field.validValues.includes(normalized)) return null; + if (field.format && !field.format.test(normalized)) return null; return normalized; } +function readDefaultConfigValue(field: ConfigPromptField): string | null { + return normalizeConfigValue(field, field.defaultValue); +} + +function formatConfigPromptQuestion(field: ConfigPromptField): string { + const hints: string[] = []; + if (field.validValues && field.validValues.length > 0) { + hints.push(field.validValues.join("/")); + } else if (field.placeholder) { + hints.push(field.placeholder); + } + const defaultValue = readDefaultConfigValue(field); + if (defaultValue) hints.push(`default: ${defaultValue}`); + return ` ${field.label}${hints.length > 0 ? ` [${hints.join("; ")}]` : ""}: `; +} + +function isBlankConfigValue(value: unknown): boolean { + return typeof value === "string" && value.replace(/\r/g, "").trim().length === 0; +} + function hasInputValue( inputs: Record, inputId: string, @@ -215,6 +255,19 @@ function logSavedConfigInput( log(options, ` ✓ ${channelId} ${configInputNoun(field)} saved`); } +function logDefaultConfigInput( + channelId: string, + field: ConfigPromptField, + value: string, + options: ConfigPromptHookOptions, +): void { + if (isMentionModeInput(field)) { + log(options, ` ✓ ${channelId} reply mode defaulted: ${formatMentionMode(value)}`); + return; + } + log(options, ` ✓ ${channelId} ${configInputNoun(field)} defaulted: ${value}`); +} + function logSkippedConfigInput( channelId: string, field: ConfigPromptField, @@ -233,6 +286,7 @@ function configInputNoun(field: ConfigPromptField): string { function isMentionModeInput(field: ConfigPromptField): boolean { return ( + /mention/i.test(`${field.id} ${field.label}`) && field.validValues?.length === 2 && field.validValues.includes("0") && field.validValues.includes("1") diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 0117dff6383..3ee3f4e2f6c 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -52,6 +52,7 @@ const telegramManifest = { required: false, envKey: "TELEGRAM_REQUIRE_MENTION", validValues: ["0", "1"], + defaultValue: "1", statePath: "telegramConfig.requireMention", }, ], diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 1dcac500348..9395e085a97 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -97,6 +97,7 @@ export interface ChannelSecretInputSpec extends ChannelInputBaseSpec { /** Non-secret input metadata that may persist into channel state. */ export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { readonly kind: "config"; + readonly defaultValue?: string; readonly statePath?: MessagingStatePath; readonly promptWhenInput?: string; } diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 18cc4c815aa..0f4682b6ea1 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { SandboxMessagingPlan } from "./manifest"; +import { compactSandboxMessagingPlanForPersistence } from "./persistence"; import { getActiveChannelIdsFromPlan, getConfiguredChannelIdsFromPlan, @@ -12,7 +13,6 @@ import { getMessagingPlanStateValues, parseSandboxMessagingPlan, } from "./plan-validation"; -import { compactSandboxMessagingPlanForPersistence } from "./persistence"; function makePlan(overrides: Partial = {}): SandboxMessagingPlan { return { @@ -221,6 +221,15 @@ describe("plan channel derivation", () => { statePath: "telegramConfig.requireMention", value: "1", }, + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "allowlist", + }, ], }, { @@ -321,6 +330,12 @@ describe("plan channel derivation", () => { statePath: "telegramConfig.requireMention", env: "TELEGRAM_REQUIRE_MENTION", }, + { + channelId: "telegram", + kind: "rebuild-hydration", + statePath: "telegramConfig.groupPolicy", + env: "TELEGRAM_GROUP_POLICY", + }, { channelId: "wechat", kind: "rebuild-hydration", @@ -362,6 +377,7 @@ describe("plan channel derivation", () => { expect(getMessagingPlanStateValues(plan)).toMatchObject({ "telegramConfig.requireMention": "1", + "telegramConfig.groupPolicy": "allowlist", "wechatConfig.accountId": "wechat-account", "wechatConfig.baseUrl": "https://wechat.example", "allowedIds.slack": "U01ABC2DEF3", @@ -371,6 +387,7 @@ describe("plan channel derivation", () => { }); expect(getMessagingChannelConfigFromPlan(plan)).toEqual({ TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_GROUP_POLICY: "allowlist", WECHAT_ACCOUNT_ID: "wechat-account", WECHAT_BASE_URL: "https://wechat.example", SLACK_ALLOWED_USERS: "U01ABC2DEF3", diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index f0ca47699f5..29de917a366 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -6,9 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getCredential, prompt, saveCredential } from "../credentials/store"; import { createBuiltInChannelManifestRegistry, MessagingSetupApplier } from "../messaging"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../messaging/applier/types"; +import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; import { runWechatHostQrLogin } from "../messaging/channels/wechat/login"; import { setupMessagingChannels, setupSelectedMessagingChannels } from "./messaging-channel-setup"; -import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; vi.mock("../credentials/store", () => ({ getCredential: vi.fn(() => null), @@ -226,11 +226,42 @@ describe("setupSelectedMessagingChannels", () => { " Telegram Bot Token: ", " Reply only when @mentioned? [Y/n]: ", " Telegram User ID (for DM access): ", + " Telegram group policy [open/allowlist/disabled; default: open]: ", " Discord Bot Token: ", " Discord Server ID (for guild workspace access): ", ]); expect(process.env.TELEGRAM_REQUIRE_MENTION).toBe("0"); + expect(process.env.TELEGRAM_GROUP_POLICY).toBe("open"); + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + }); + + it("does not prompt for OpenClaw-only Telegram group policy during Hermes onboarding", async () => { + const questions: string[] = []; + vi.mocked(prompt).mockImplementation(async (question) => { + questions.push(question); + if (question.includes("Telegram Bot Token")) return "123456:telegram-token"; + if (question.includes("Reply only")) return "n"; + if (question.includes("Telegram User ID")) return "123456789"; + return ""; + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + + const plan = await setupSelectedMessagingChannels( + ["telegram"], + new Set(["telegram"]), + manifests("telegram"), + { agent: { name: "hermes" } }, + ); + + expect(plan?.agent).toBe("hermes"); + expect(questions).toEqual([ + " Telegram Bot Token: ", + " Reply only when @mentioned? [Y/n]: ", + " Telegram User ID (for DM access): ", + ]); + expect(process.env.TELEGRAM_REQUIRE_MENTION).toBe("0"); expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + expect(process.env.TELEGRAM_GROUP_POLICY).toBeUndefined(); }); it("prompts Discord guild-only config after the manifest server ID input is set", async () => { diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 3e0eafbbbae..33c33be99e6 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -5,8 +5,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import YAML from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-key"; import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; @@ -523,7 +523,10 @@ describe("agents/hermes/generate-config.ts", () => { telegram: ["123456789"], slack: ["U0123456789", "U09ABCDEFGH"], }), - NEMOCLAW_TELEGRAM_CONFIG_B64: encodeJson({ requireMention: true }), + NEMOCLAW_TELEGRAM_CONFIG_B64: encodeJson({ + requireMention: true, + groupPolicy: "disabled", + }), NEMOCLAW_SLACK_CONFIG_B64: encodeJson({ allowedChannels: ["C012AB3CD", "C987ZY6XW"], }), @@ -641,13 +644,13 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).not.toContain("WEIXIN_ACCOUNT_ID="); }); - it("omits Telegram behavior config when requireMention is not boolean", () => { + it("defaults Telegram behavior config when requireMention is non-canonical", () => { const { config, envFile } = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["telegram"]), NEMOCLAW_TELEGRAM_CONFIG_B64: encodeJson({ requireMention: "true" }), }); - expect(config.telegram).toBeUndefined(); + expect(config.telegram).toEqual({ require_mention: true }); expect(config.platforms.telegram).toEqual({ enabled: true }); expectRemotePlatformToolsets(config.platform_toolsets.telegram); expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n"); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 4a76d8f7650..d17603ddb09 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -503,13 +503,13 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.channels.telegram.groups).toBeUndefined(); }); - it("defaults Telegram groupPolicy to 'open' with no groups stanza when telegramConfig is empty (#3022)", () => { + it("defaults Telegram group replies to require mentions when telegramConfig is empty (#3022)", () => { const channels = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, }); expect(config.channels.telegram.accounts.default.groupPolicy).toBe("open"); - expect(config.channels.telegram.groups).toBeUndefined(); + expect(config.channels.telegram.groups).toEqual({ "*": { requireMention: true } }); }); it("emits OpenClaw-valid Discord guild allowlist config when guilds are provided", () => { diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts index 05e5eb3de2b..729abf7832a 100644 --- a/test/messaging-plan-test-helper.ts +++ b/test/messaging-plan-test-helper.ts @@ -119,6 +119,7 @@ function legacyMessagingConfigEnv(env: Record): Record>( env,