From 26d5cca97c739e6265059a1d666715e644e6a32b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 21:49:44 +0000 Subject: [PATCH] feat: auto native commands defaults --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 4 ++-- docs/providers/discord.md | 2 +- docs/providers/slack.md | 6 +++--- docs/providers/telegram.md | 2 +- docs/tools/slash-commands.md | 9 +++++---- src/config/commands.ts | 35 +++++++++++++++++++++++++++++++++++ src/config/schema.ts | 6 ++++++ src/config/types.ts | 17 +++++++++++++++-- src/config/zod-schema.ts | 19 +++++++++++++++++-- src/discord/monitor.ts | 15 +++++++++++++-- src/slack/monitor.ts | 11 +++++++++-- src/telegram/bot.ts | 15 +++++++++++++-- 13 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 src/config/commands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9851e8d02..59b246352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent. - Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker. - CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary. +- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated. ### Fixes - Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index edd3d9827..9d3899e56 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -756,8 +756,8 @@ Controls how chat commands are enabled across connectors. Notes: - Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases). - `commands.text: false` disables parsing chat messages for commands. -- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands. -- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app. +- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported providers stay text-only. +- Set `commands.native: true|false` to force all, or override per provider with `discord.commands.native`, `telegram.commands.native`, `slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app. - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). - `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.restart: true` enables `/restart` and the gateway tool restart action. diff --git a/docs/providers/discord.md b/docs/providers/discord.md index d224e039d..f5b2a5ce7 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -47,7 +47,7 @@ Minimal config: - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`. 8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`. 9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -10. Optional native commands: set `commands.native: true` to register native commands in Discord; set `commands.native: false` to clear previously registered native commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. +10. Optional native commands: `commands.native` defaults to `"auto"` (on for Discord/Telegram, off for Slack). Override with `discord.commands.native: true|false|"auto"`; `false` clears previously registered commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. - Full command list + config: [Slash commands](/tools/slash-commands) 11. Optional guild context history: set `discord.historyLimit` (default 20, falls back to `messages.groupChat.historyLimit`) to include the last N guild messages as context when replying to a mention. Set `0` to disable. 12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). diff --git a/docs/providers/slack.md b/docs/providers/slack.md index 4271cb51d..dfba65c26 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -33,7 +33,7 @@ Minimal config: - `channel_rename` - `pin_added`, `pin_removed` 5) Invite the bot to channels you want it to read. -6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable `commands.native`, add slash commands for the built-in chat commands (same names as `/help`). +6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off). 7) App Home → enable the **Messages Tab** so users can DM the bot. Use the manifest below so scopes and events stay in sync. @@ -138,7 +138,7 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i } ``` -If you enable `commands.native`, add one `slash_commands` entry per command you want to expose (matching the `/help` list). +If you enable native commands, add one `slash_commands` entry per command you want to expose (matching the `/help` list). Override with `slack.commands.native`. ## Scopes (current vs optional) Slack's Conversations API is type-scoped: you only need the scopes for the @@ -257,7 +257,7 @@ For fine-grained control, use these tags in agent responses: - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `agent::slack:channel:` sessions. - Slash commands use `agent::slack:slash:` sessions (prefix configurable via `slack.slashCommand.sessionPrefix`). -- Native command registration is controlled by `commands.native`; text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. +- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. - Full command list + config: [Slash commands](/tools/slash-commands) ## DM security (pairing) diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a6449e866..9daf1b8c8 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -315,5 +315,5 @@ Provider options: Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `messages.groupChat.mentionPatterns` (global fallback). -- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). +- `commands.native` (defaults to `"auto"` → on for Telegram/Discord, off for Slack), `commands.text`, `commands.useAccessGroups` (command behavior). Override with `telegram.commands.native`. - `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`, `messages.removeAckAfterReply`. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 2e19193f5..f634deaa2 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -24,7 +24,7 @@ They run immediately, are stripped before the model sees the message, and the re ```json5 { commands: { - native: false, + native: "auto", text: true, config: false, debug: false, @@ -36,9 +36,10 @@ They run immediately, are stripped before the model sees the message, and the re - `commands.text` (default `true`) enables parsing `/...` in chat messages. - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/MS Teams), text commands still work even if you set this to `false`. -- `commands.native` (default `false`) registers native commands on Discord/Slack/Telegram. - - `false` clears previously registered commands on Discord/Telegram at startup. - - Slack commands are managed in the Slack app and are not removed automatically. +- `commands.native` (default `"auto"`) registers native commands. + - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. + - Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`). + - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). - `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). - `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands. diff --git a/src/config/commands.ts b/src/config/commands.ts new file mode 100644 index 000000000..a2d5d86fe --- /dev/null +++ b/src/config/commands.ts @@ -0,0 +1,35 @@ +import type { NativeCommandsSetting } from "./types.js"; +import { normalizeProviderId } from "../providers/registry.js"; +import type { ProviderId } from "../providers/plugins/types.js"; + +function resolveAutoDefault(providerId?: ProviderId): boolean { + const id = normalizeProviderId(providerId); + if (!id) return false; + if (id === "discord" || id === "telegram") return true; + if (id === "slack") return false; + return false; +} + +export function resolveNativeCommandsEnabled(params: { + providerId: ProviderId; + providerSetting?: NativeCommandsSetting; + globalSetting?: NativeCommandsSetting; +}): boolean { + const { providerId, providerSetting, globalSetting } = params; + const setting = + providerSetting === undefined ? globalSetting : providerSetting; + if (setting === true) return true; + if (setting === false) return false; + // auto or undefined -> heuristic + return resolveAutoDefault(providerId); +} + +export function isNativeCommandsExplicitlyDisabled(params: { + providerSetting?: NativeCommandsSetting; + globalSetting?: NativeCommandsSetting; +}): boolean { + const { providerSetting, globalSetting } = params; + if (providerSetting === false) return true; + if (providerSetting === undefined) return globalSetting === false; + return false; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 3b7ac9006..2d43ae840 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -283,6 +283,12 @@ const FIELD_HELP: Record = { "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "discord.commands.native": + 'Override native commands for Discord (bool or "auto").', + "telegram.commands.native": + 'Override native commands for Telegram (bool or "auto").', + "slack.commands.native": + 'Override native commands for Slack (bool or "auto").', "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", "messages.ackReaction": diff --git a/src/config/types.ts b/src/config/types.ts index 45b0db40b..e5d00545d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -359,6 +359,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Override native command registration for Telegram (bool or "auto"). */ + commands?: ProviderCommandsConfig; /** * Controls how Telegram direct chats (DMs) are handled: * - "pairing" (default): unknown senders get a pairing code; owner must approve @@ -510,6 +512,8 @@ export type DiscordAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Override native command registration for Discord (bool or "auto"). */ + commands?: ProviderCommandsConfig; /** If false, do not start this Discord account. Default: true. */ enabled?: boolean; token?: string; @@ -621,6 +625,8 @@ export type SlackAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Override native command registration for Slack (bool or "auto"). */ + commands?: ProviderCommandsConfig; /** If false, do not start this Slack account. Default: true. */ enabled?: boolean; botToken?: string; @@ -1209,9 +1215,11 @@ export type MessagesConfig = { removeAckAfterReply?: boolean; }; +export type NativeCommandsSetting = boolean | "auto"; + export type CommandsConfig = { - /** Enable native command registration when supported (default: false). */ - native?: boolean; + /** Enable native command registration when supported (default: "auto"). */ + native?: NativeCommandsSetting; /** Enable text command parsing (default: true). */ text?: boolean; /** Allow /config command (default: false). */ @@ -1224,6 +1232,11 @@ export type CommandsConfig = { useAccessGroups?: boolean; }; +export type ProviderCommandsConfig = { + /** Override native command registration for this provider (bool or "auto"). */ + native?: NativeCommandsSetting; +}; + export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index abd2b164d..ce928794a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -269,6 +269,7 @@ const TelegramAccountSchemaBase = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), tokenFile: z.string().optional(), @@ -367,6 +368,7 @@ const DiscordAccountSchema = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, token: z.string().optional(), allowBots: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), @@ -440,6 +442,7 @@ const SlackAccountSchema = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, botToken: z.string().optional(), appToken: z.string().optional(), allowBots: z.boolean().optional(), @@ -709,16 +712,28 @@ const MessagesSchema = z }) .optional(); +const NativeCommandsSettingSchema = z.union([ + z.boolean(), + z.literal("auto"), +]); + +const ProviderCommandsSchema = z + .object({ + native: NativeCommandsSettingSchema.optional(), + }) + .optional(); + const CommandsSchema = z .object({ - native: z.boolean().optional(), + native: NativeCommandsSettingSchema.optional().default("auto"), text: z.boolean().optional(), config: z.boolean().optional(), debug: z.boolean().optional(), restart: z.boolean().optional(), useAccessGroups: z.boolean().optional(), }) - .optional(); + .optional() + .default({ native: "auto" }); const HeartbeatSchema = z .object({ diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 468ca04b9..4c475b1ec 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -51,6 +51,10 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ClawdbotConfig, ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + isNativeCommandsExplicitlyDisabled, + resolveNativeCommandsEnabled, +} from "../config/commands.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { formatDurationSeconds } from "../infra/format-duration.js"; @@ -403,8 +407,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmPolicy = dmConfig?.policy ?? "pairing"; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; - const nativeEnabled = cfg.commands?.native === true; - const nativeDisabledExplicit = cfg.commands?.native === false; + const nativeEnabled = resolveNativeCommandsEnabled({ + providerId: "discord", + providerSetting: discordCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); + const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ + providerSetting: discordCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const sessionPrefix = "discord:slash"; const ephemeralDefault = true; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index af8101a71..250d2ecdb 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -44,6 +44,7 @@ import type { SlackSlashCommandConfig, } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { resolveNativeCommandsEnabled } from "../config/commands.js"; import { resolveSessionKey, resolveStorePath, @@ -1944,8 +1945,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } }; - const nativeCommands = - cfg.commands?.native === true ? listNativeCommandSpecsForConfig(cfg) : []; + const nativeEnabled = resolveNativeCommandsEnabled({ + providerId: "slack", + providerSetting: account.config.commands?.native, + globalSetting: cfg.commands?.native, + }); + const nativeCommands = nativeEnabled + ? listNativeCommandSpecsForConfig(cfg) + : []; if (nativeCommands.length > 0) { for (const command of nativeCommands) { app.command( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index da2a17a7b..fa1ec03b8 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -35,6 +35,10 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ClawdbotConfig, ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + isNativeCommandsExplicitlyDisabled, + resolveNativeCommandsEnabled, +} from "../config/commands.js"; import { resolveProviderGroupPolicy, resolveProviderGroupRequireMention, @@ -291,8 +295,15 @@ export function createTelegramBot(opts: TelegramBotOptions) { }; const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "first"; const streamMode = resolveTelegramStreamMode(telegramCfg); - const nativeEnabled = cfg.commands?.native === true; - const nativeDisabledExplicit = cfg.commands?.native === false; + const nativeEnabled = resolveNativeCommandsEnabled({ + providerId: "telegram", + providerSetting: telegramCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); + const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ + providerSetting: telegramCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes =