diff --git a/CHANGELOG.md b/CHANGELOG.md index 76377ecda..9d9e3fb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,7 @@ - Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758) - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. +- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow. - Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 5218c3212..4d8b8223b 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -245,31 +245,6 @@ public struct StateVersion: Codable, Sendable { } } -public struct SessionDefaults: Codable, Sendable { - public let defaultagentid: String - public let mainkey: String - public let mainsessionkey: String - public let scope: String? - - public init( - defaultagentid: String, - mainkey: String, - mainsessionkey: String, - scope: String? - ) { - self.defaultagentid = defaultagentid - self.mainkey = mainkey - self.mainsessionkey = mainsessionkey - self.scope = scope - } - private enum CodingKeys: String, CodingKey { - case defaultagentid = "defaultAgentId" - case mainkey = "mainKey" - case mainsessionkey = "mainSessionKey" - case scope - } -} - public struct Snapshot: Codable, Sendable { public let presence: [PresenceEntry] public let health: AnyCodable @@ -277,7 +252,7 @@ public struct Snapshot: Codable, Sendable { public let uptimems: Int public let configpath: String? public let statedir: String? - public let sessiondefaults: SessionDefaults? + public let sessiondefaults: [String: AnyCodable]? public init( presence: [PresenceEntry], @@ -286,7 +261,7 @@ public struct Snapshot: Codable, Sendable { uptimems: Int, configpath: String?, statedir: String?, - sessiondefaults: SessionDefaults? + sessiondefaults: [String: AnyCodable]? ) { self.presence = presence self.health = health diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3157bdea7..3e1f01ef2 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -69,7 +69,7 @@ Text + native (when enabled): - `/activation mention|always` (groups only) - `/send on|off|inherit` (owner-only) - `/reset` or `/new` -- `/think ` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`) +- `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) @@ -96,6 +96,7 @@ Notes: - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. - Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. +- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. ## Usage vs cost (what shows where) @@ -167,4 +168,4 @@ Notes: - Slack: `agent::slack:slash:` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`) - Telegram: `telegram:slash:` (targets the chat session via `CommandTargetSessionKey`) - **`/stop`** targets the active chat session so it can abort the current run. -- **Slack:** `channels.slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). +- **Slack:** `channels.slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 91b3da29c..1d9c746e4 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,5 +1,23 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("matrix-js-sdk", () => ({ + EventType: { + Direct: "m.direct", + RoomMessage: "m.room.message", + Reaction: "m.reaction", + }, + MsgType: { + Text: "m.text", + File: "m.file", + Image: "m.image", + Audio: "m.audio", + Video: "m.video", + }, + RelationType: { + Annotation: "m.annotation", + }, +})); + vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({}), })); diff --git a/src/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts new file mode 100644 index 000000000..31d4fdbf9 --- /dev/null +++ b/src/auto-reply/commands-args.ts @@ -0,0 +1,66 @@ +import type { CommandArgValues } from "./commands-registry.types.js"; + +export type CommandArgsFormatter = (values: CommandArgValues) => string | undefined; + +function normalizeArgValue(value: unknown): string | undefined { + if (value == null) return undefined; + const text = typeof value === "string" ? value.trim() : String(value).trim(); + return text ? text : undefined; +} + +const formatConfigArgs: CommandArgsFormatter = (values) => { + const action = normalizeArgValue(values.action)?.toLowerCase(); + const path = normalizeArgValue(values.path); + const value = normalizeArgValue(values.value); + if (!action) return undefined; + if (action === "show" || action === "get") { + return path ? `${action} ${path}` : action; + } + if (action === "unset") { + return path ? `${action} ${path}` : action; + } + if (action === "set") { + if (!path) return action; + if (!value) return `${action} ${path}`; + return `${action} ${path}=${value}`; + } + return action; +}; + +const formatDebugArgs: CommandArgsFormatter = (values) => { + const action = normalizeArgValue(values.action)?.toLowerCase(); + const path = normalizeArgValue(values.path); + const value = normalizeArgValue(values.value); + if (!action) return undefined; + if (action === "show" || action === "reset") { + return action; + } + if (action === "unset") { + return path ? `${action} ${path}` : action; + } + if (action === "set") { + if (!path) return action; + if (!value) return `${action} ${path}`; + return `${action} ${path}=${value}`; + } + return action; +}; + +const formatQueueArgs: CommandArgsFormatter = (values) => { + const mode = normalizeArgValue(values.mode); + const debounce = normalizeArgValue(values.debounce); + const cap = normalizeArgValue(values.cap); + const drop = normalizeArgValue(values.drop); + const parts: string[] = []; + if (mode) parts.push(mode); + if (debounce) parts.push(`debounce:${debounce}`); + if (cap) parts.push(`cap:${cap}`); + if (drop) parts.push(`drop:${drop}`); + return parts.length > 0 ? parts.join(" ") : undefined; +}; + +export const COMMAND_ARG_FORMATTERS: Record = { + config: formatConfigArgs, + debug: formatDebugArgs, + queue: formatQueueArgs, +}; diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts new file mode 100644 index 000000000..91075a1bd --- /dev/null +++ b/src/auto-reply/commands-registry.args.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCommandTextFromArgs, + parseCommandArgs, + resolveCommandArgMenu, + serializeCommandArgs, +} from "./commands-registry.js"; +import type { ChatCommandDefinition } from "./commands-registry.types.js"; + +describe("commands registry args", () => { + it("parses positional args and captureRemaining", () => { + const command: ChatCommandDefinition = { + key: "debug", + description: "debug", + textAliases: [], + scope: "both", + argsParsing: "positional", + args: [ + { name: "action", description: "action", type: "string" }, + { name: "path", description: "path", type: "string" }, + { name: "value", description: "value", type: "string", captureRemaining: true }, + ], + }; + + const args = parseCommandArgs(command, "set foo bar baz"); + expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" }); + }); + + it("serializes args via raw first, then values", () => { + const command: ChatCommandDefinition = { + key: "model", + description: "model", + textAliases: [], + scope: "both", + argsParsing: "positional", + args: [{ name: "model", description: "model", type: "string", captureRemaining: true }], + }; + + expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex"); + expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( + "gpt-5.2-codex", + ); + expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( + "/model gpt-5.2-codex", + ); + }); + + it("resolves auto arg menus when missing a choice arg", () => { + const command: ChatCommandDefinition = { + key: "cost", + description: "cost", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "positional", + args: [ + { + name: "mode", + description: "mode", + type: "string", + choices: ["on", "off"], + }, + ], + }; + + const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu?.arg.name).toBe("mode"); + expect(menu?.choices).toEqual(["on", "off"]); + }); + + it("does not show menus when arg already provided", () => { + const command: ChatCommandDefinition = { + key: "cost", + description: "cost", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "positional", + args: [ + { + name: "mode", + description: "mode", + type: "string", + choices: ["on", "off"], + }, + ], + }; + + const menu = resolveCommandArgMenu({ + command, + args: { values: { mode: "on" } }, + cfg: {} as never, + }); + expect(menu).toBeNull(); + }); +}); diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 817476853..bc007b188 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -1,10 +1,16 @@ import { listChannelDocks } from "../channels/dock.js"; +import { listThinkingLevels } from "./thinking.js"; +import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js"; type DefineChatCommandInput = { key: string; nativeName?: string; description: string; + args?: ChatCommandDefinition["args"]; + argsParsing?: ChatCommandDefinition["argsParsing"]; + formatArgs?: ChatCommandDefinition["formatArgs"]; + argsMenu?: ChatCommandDefinition["argsMenu"]; acceptsArgs?: boolean; textAlias?: string; textAliases?: string[]; @@ -17,11 +23,17 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti .filter(Boolean); const scope = command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text"); + const acceptsArgs = command.acceptsArgs ?? Boolean(command.args?.length); + const argsParsing = command.argsParsing ?? (command.args?.length ? "positional" : "none"); return { key: command.key, nativeName: command.nativeName, description: command.description, - acceptsArgs: command.acceptsArgs, + acceptsArgs, + args: command.args, + argsParsing, + formatArgs: command.formatArgs, + argsMenu: command.argsMenu, textAliases: aliases, scope, }; @@ -35,7 +47,6 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition { nativeName: `dock_${dock.id}`, description: `Switch to ${dock.id} for replies.`, textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`], - acceptsArgs: false, }); } @@ -138,21 +149,69 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { nativeName: "config", description: "Show or set config values.", textAlias: "/config", - acceptsArgs: true, + args: [ + { + name: "action", + description: "show | get | set | unset", + type: "string", + choices: ["show", "get", "set", "unset"], + }, + { + name: "path", + description: "Config path", + type: "string", + }, + { + name: "value", + description: "Value for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.config, }), defineChatCommand({ key: "debug", nativeName: "debug", description: "Set runtime debug overrides.", textAlias: "/debug", - acceptsArgs: true, + args: [ + { + name: "action", + description: "show | reset | set | unset", + type: "string", + choices: ["show", "reset", "set", "unset"], + }, + { + name: "path", + description: "Debug path", + type: "string", + }, + { + name: "value", + description: "Value for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.debug, }), defineChatCommand({ key: "cost", nativeName: "cost", description: "Toggle per-response usage line.", textAlias: "/cost", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "on or off", + type: "string", + choices: ["on", "off"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "stop", @@ -171,14 +230,30 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { nativeName: "activation", description: "Set group activation mode.", textAlias: "/activation", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "mention or always", + type: "string", + choices: ["mention", "always"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "send", nativeName: "send", description: "Set send policy.", textAlias: "/send", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "on, off, or inherit", + type: "string", + choices: ["on", "off", "inherit"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "reset", @@ -197,56 +272,133 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Compact the session context.", textAlias: "/compact", scope: "text", - acceptsArgs: true, + args: [ + { + name: "instructions", + description: "Extra compaction instructions", + type: "string", + captureRemaining: true, + }, + ], }), defineChatCommand({ key: "think", nativeName: "think", description: "Set thinking level.", textAlias: "/think", - acceptsArgs: true, + args: [ + { + name: "level", + description: "off, minimal, low, medium, high, xhigh", + type: "string", + choices: ({ provider, model }) => listThinkingLevels(provider, model), + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "verbose", nativeName: "verbose", description: "Toggle verbose mode.", textAlias: "/verbose", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "on or off", + type: "string", + choices: ["on", "off"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "reasoning", nativeName: "reasoning", description: "Toggle reasoning visibility.", textAlias: "/reasoning", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "on, off, or stream", + type: "string", + choices: ["on", "off", "stream"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "elevated", nativeName: "elevated", description: "Toggle elevated mode.", textAlias: "/elevated", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "on or off", + type: "string", + choices: ["on", "off"], + }, + ], + argsMenu: "auto", }), defineChatCommand({ key: "model", nativeName: "model", description: "Show or set the model.", textAlias: "/model", - acceptsArgs: true, + args: [ + { + name: "model", + description: "Model id (provider/model or id)", + type: "string", + }, + ], }), defineChatCommand({ key: "queue", nativeName: "queue", description: "Adjust queue settings.", textAlias: "/queue", - acceptsArgs: true, + args: [ + { + name: "mode", + description: "queue mode", + type: "string", + choices: ["steer", "interrupt", "followup", "collect", "steer-backlog"], + }, + { + name: "debounce", + description: "debounce duration (e.g. 500ms, 2s)", + type: "string", + }, + { + name: "cap", + description: "queue cap", + type: "number", + }, + { + name: "drop", + description: "drop policy", + type: "string", + choices: ["old", "new", "summarize"], + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.queue, }), defineChatCommand({ key: "bash", description: "Run host shell commands (host-only).", textAlias: "/bash", scope: "text", - acceptsArgs: true, + args: [ + { + name: "command", + description: "Shell command", + type: "string", + captureRemaining: true, + }, + ], }), ...listChannelDocks() .filter((dock) => dock.capabilities.nativeCommands) diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 46f51d64a..a17b681d1 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,7 +1,14 @@ import type { ClawdbotConfig } from "../config/types.js"; import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { ChatCommandDefinition, + CommandArgChoiceContext, + CommandArgDefinition, + CommandArgMenuSpec, + CommandArgValues, + CommandArgs, CommandDetection, CommandNormalizeOptions, NativeCommandSpec, @@ -11,6 +18,11 @@ import type { export { CHAT_COMMANDS } from "./commands-registry.data.js"; export type { ChatCommandDefinition, + CommandArgChoiceContext, + CommandArgDefinition, + CommandArgMenuSpec, + CommandArgValues, + CommandArgs, CommandDetection, CommandNormalizeOptions, CommandScope, @@ -70,6 +82,7 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] { name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), + args: command.args, }), ); } @@ -81,6 +94,7 @@ export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeComm name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), + args: command.args, })); } @@ -96,6 +110,137 @@ export function buildCommandText(commandName: string, args?: string): string { return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`; } +function parsePositionalArgs(definitions: CommandArgDefinition[], raw: string): CommandArgValues { + const values: CommandArgValues = {}; + const trimmed = raw.trim(); + if (!trimmed) return values; + const tokens = trimmed.split(/\s+/).filter(Boolean); + let index = 0; + for (const definition of definitions) { + if (index >= tokens.length) break; + if (definition.captureRemaining) { + values[definition.name] = tokens.slice(index).join(" "); + index = tokens.length; + break; + } + values[definition.name] = tokens[index]; + index += 1; + } + return values; +} + +function formatPositionalArgs( + definitions: CommandArgDefinition[], + values: CommandArgValues, +): string | undefined { + const parts: string[] = []; + for (const definition of definitions) { + const value = values[definition.name]; + if (value == null) continue; + const rendered = typeof value === "string" ? value.trim() : String(value); + if (!rendered) continue; + parts.push(rendered); + if (definition.captureRemaining) break; + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +export function parseCommandArgs( + command: ChatCommandDefinition, + raw?: string, +): CommandArgs | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + if (!command.args || command.argsParsing === "none") { + return { raw: trimmed }; + } + return { + raw: trimmed, + values: parsePositionalArgs(command.args, trimmed), + }; +} + +export function serializeCommandArgs( + command: ChatCommandDefinition, + args?: CommandArgs, +): string | undefined { + if (!args) return undefined; + const raw = args.raw?.trim(); + if (raw) return raw; + if (!args.values || !command.args) return undefined; + if (command.formatArgs) return command.formatArgs(args.values); + return formatPositionalArgs(command.args, args.values); +} + +export function buildCommandTextFromArgs( + command: ChatCommandDefinition, + args?: CommandArgs, +): string { + const commandName = command.nativeName ?? command.key; + return buildCommandText(commandName, serializeCommandArgs(command, args)); +} + +function resolveDefaultCommandContext(cfg?: ClawdbotConfig): { + provider: string; + model: string; +} { + const resolved = resolveConfiguredModelRef({ + cfg: cfg ?? ({} as ClawdbotConfig), + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + return { + provider: resolved.provider ?? DEFAULT_PROVIDER, + model: resolved.model ?? DEFAULT_MODEL, + }; +} + +export function resolveCommandArgChoices(params: { + command: ChatCommandDefinition; + arg: CommandArgDefinition; + cfg?: ClawdbotConfig; + provider?: string; + model?: string; +}): string[] { + const { command, arg, cfg } = params; + if (!arg.choices) return []; + const provided = arg.choices; + if (Array.isArray(provided)) return provided; + const defaults = resolveDefaultCommandContext(cfg); + const context: CommandArgChoiceContext = { + cfg, + provider: params.provider ?? defaults.provider, + model: params.model ?? defaults.model, + command, + arg, + }; + return provided(context); +} + +export function resolveCommandArgMenu(params: { + command: ChatCommandDefinition; + args?: CommandArgs; + cfg?: ClawdbotConfig; +}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null { + const { command, args, cfg } = params; + if (!command.args || !command.argsMenu) return null; + if (command.argsParsing === "none") return null; + const argSpec = command.argsMenu; + const argName = + argSpec === "auto" + ? command.args.find((arg) => resolveCommandArgChoices({ command, arg, cfg }).length > 0)?.name + : argSpec.arg; + if (!argName) return null; + if (args?.values && args.values[argName] != null) return null; + if (args?.raw && !args.values) return null; + const arg = command.args.find((entry) => entry.name === argName); + if (!arg) return null; + const choices = resolveCommandArgChoices({ command, arg, cfg }); + if (choices.length === 0) return null; + const title = argSpec !== "auto" ? argSpec.title : undefined; + return { arg, choices, title }; +} + export function normalizeCommandBody(raw: string, options?: CommandNormalizeOptions): string { const trimmed = raw.trim(); if (!trimmed.startsWith("/")) return trimmed; diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 0f2a1b75a..9abfa80c4 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -2,12 +2,51 @@ import type { ClawdbotConfig } from "../config/types.js"; export type CommandScope = "text" | "native" | "both"; +export type CommandArgType = "string" | "number" | "boolean"; + +export type CommandArgChoiceContext = { + cfg?: ClawdbotConfig; + provider?: string; + model?: string; + command: ChatCommandDefinition; + arg: CommandArgDefinition; +}; + +export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[]; + +export type CommandArgDefinition = { + name: string; + description: string; + type: CommandArgType; + required?: boolean; + choices?: string[] | CommandArgChoicesProvider; + captureRemaining?: boolean; +}; + +export type CommandArgMenuSpec = { + arg: string; + title?: string; +}; + +export type CommandArgValues = Record; + +export type CommandArgs = { + raw?: string; + values?: CommandArgValues; +}; + +export type CommandArgsParsing = "none" | "positional"; + export type ChatCommandDefinition = { key: string; nativeName?: string; description: string; textAliases: string[]; acceptsArgs?: boolean; + args?: CommandArgDefinition[]; + argsParsing?: CommandArgsParsing; + formatArgs?: (values: CommandArgValues) => string | undefined; + argsMenu?: CommandArgMenuSpec | "auto"; scope: CommandScope; }; @@ -15,6 +54,7 @@ export type NativeCommandSpec = { name: string; description: string; acceptsArgs: boolean; + args?: CommandArgDefinition[]; }; export type CommandNormalizeOptions = { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 66a4c2815..4ae58d181 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,5 +1,6 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; +import type { CommandArgs } from "./commands-registry.types.js"; /** Valid message channels for routing. */ export type OriginatingChannelType = ChannelId | InternalMessageChannel; @@ -15,6 +16,7 @@ export type MsgContext = { * Prefer for command detection; RawBody is treated as legacy alias. */ CommandBody?: string; + CommandArgs?: CommandArgs; From?: string; To?: string; SessionKey?: string; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 21cbb3ff0..931b9a1e6 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -18,7 +18,6 @@ function parseThreadId(threadId?: string | number | null) { const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } - export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index ee9eed7d4..3202974db 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -52,7 +52,6 @@ function parseThreadId(threadId?: string | number | null) { const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } - export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index e50c3eff8..204e2e5f6 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -362,9 +362,11 @@ export async function runConfigureWizard( basePath: undefined, }); const remoteUrl = nextConfig.gateway?.remote?.url?.trim(); - const wsUrl = nextConfig.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; + const wsUrl = + nextConfig.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; const token = nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; - const password = nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; + const password = + nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; await waitForGatewayReachable({ url: wsUrl, token, diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 790a79eed..e8c7702f0 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -81,7 +81,9 @@ describe("ensureOnboardingPluginInstalled", () => { const cfg: ClawdbotConfig = {}; vi.mocked(fs.existsSync).mockImplementation((value) => { const raw = String(value); - return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`); + return ( + raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) + ); }); const result = await ensureOnboardingPluginInstalled({ @@ -109,7 +111,9 @@ describe("ensureOnboardingPluginInstalled", () => { const cfg: ClawdbotConfig = {}; vi.mocked(fs.existsSync).mockImplementation((value) => { const raw = String(value); - return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`); + return ( + raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) + ); }); installPluginFromNpmSpec.mockResolvedValue({ ok: false, diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index dc8146d25..48164b3d2 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -9,11 +9,15 @@ vi.mock("@buape/carbon", () => ({ ContextMenuCommand: 2, Default: 0, }, + Button: class {}, Command: class {}, Client: class {}, MessageCreateListener: class {}, MessageReactionAddListener: class {}, MessageReactionRemoveListener: class {}, + Row: class { + constructor(_components: unknown[]) {} + }, })); vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 0f0a3b7d6..cb07e216b 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -1,9 +1,34 @@ -import { ChannelType, Command, type CommandInteraction, type CommandOptions } from "@buape/carbon"; -import { ApplicationCommandOptionType } from "discord-api-types/v10"; +import { + Button, + ChannelType, + Command, + Row, + type AutocompleteInteraction, + type ButtonInteraction, + type CommandInteraction, + type CommandOptions, + type ComponentData, +} from "@buape/carbon"; +import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { buildCommandText } from "../../auto-reply/commands-registry.js"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listChatCommands, + parseCommandArgs, + resolveCommandArgChoices, + resolveCommandArgMenu, + serializeCommandArgs, +} from "../../auto-reply/commands-registry.js"; +import type { + ChatCommandDefinition, + CommandArgDefinition, + CommandArgValues, + CommandArgs, + NativeCommandSpec, +} from "../../auto-reply/commands-registry.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig, loadConfig } from "../../config/config.js"; @@ -28,12 +53,261 @@ import { formatDiscordUserTag } from "./format.js"; type DiscordConfig = NonNullable["discord"]; -export function createDiscordNativeCommand(params: { - command: { - name: string; - description: string; - acceptsArgs: boolean; +function buildDiscordCommandOptions(params: { + command: ChatCommandDefinition; + cfg: ReturnType; +}): CommandOptions | undefined { + const { command, cfg } = params; + const args = command.args; + if (!args || args.length === 0) return undefined; + return args.map((arg) => { + const required = arg.required ?? false; + if (arg.type === "number") { + return { + name: arg.name, + description: arg.description, + type: ApplicationCommandOptionType.Number, + required, + }; + } + if (arg.type === "boolean") { + return { + name: arg.name, + description: arg.description, + type: ApplicationCommandOptionType.Boolean, + required, + }; + } + const resolvedChoices = resolveCommandArgChoices({ command, arg, cfg }); + const shouldAutocomplete = + resolvedChoices.length > 0 && + (typeof arg.choices === "function" || resolvedChoices.length > 25); + const autocomplete = shouldAutocomplete + ? async (interaction: AutocompleteInteraction) => { + const focused = interaction.options.getFocused(); + const focusValue = + typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : ""; + const choices = resolveCommandArgChoices({ command, arg, cfg }); + const filtered = focusValue + ? choices.filter((choice) => choice.toLowerCase().includes(focusValue)) + : choices; + await interaction.respond( + filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })), + ); + } + : undefined; + const choices = + resolvedChoices.length > 0 && !autocomplete + ? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice })) + : undefined; + return { + name: arg.name, + description: arg.description, + type: ApplicationCommandOptionType.String, + required, + choices, + autocomplete, + }; + }) satisfies CommandOptions; +} + +function readDiscordCommandArgs( + interaction: CommandInteraction, + definitions?: CommandArgDefinition[], +): CommandArgs | undefined { + if (!definitions || definitions.length === 0) return undefined; + const values: CommandArgValues = {}; + for (const definition of definitions) { + let value: unknown; + if (definition.type === "number") { + value = interaction.options.getNumber(definition.name); + } else if (definition.type === "boolean") { + value = interaction.options.getBoolean(definition.name); + } else { + value = interaction.options.getString(definition.name); + } + if (value != null) { + values[definition.name] = value; + } + } + return Object.keys(values).length > 0 ? { values } : undefined; +} + +function chunkItems(items: T[], size: number): T[][] { + if (size <= 0) return [items]; + const rows: T[][] = []; + for (let i = 0; i < items.length; i += size) { + rows.push(items.slice(i, i + size)); + } + return rows; +} + +const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg"; + +function createCommandArgsWithValue(params: { argName: string; value: string }): CommandArgs { + const values: CommandArgValues = { [params.argName]: params.value }; + return { values }; +} + +function encodeDiscordCommandArgValue(value: string): string { + return encodeURIComponent(value); +} + +function decodeDiscordCommandArgValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function buildDiscordCommandArgCustomId(params: { + command: string; + arg: string; + value: string; + userId: string; +}): string { + return [ + `${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`, + `arg=${encodeDiscordCommandArgValue(params.arg)}`, + `value=${encodeDiscordCommandArgValue(params.value)}`, + `user=${encodeDiscordCommandArgValue(params.userId)}`, + ].join(";"); +} + +function parseDiscordCommandArgData( + data: ComponentData, +): { command: string; arg: string; value: string; userId: string } | null { + if (!data || typeof data !== "object") return null; + const coerce = (value: unknown) => + typeof value === "string" || typeof value === "number" ? String(value) : ""; + const rawCommand = coerce(data.command); + const rawArg = coerce(data.arg); + const rawValue = coerce(data.value); + const rawUser = coerce(data.user); + if (!rawCommand || !rawArg || !rawValue || !rawUser) return null; + return { + command: decodeDiscordCommandArgValue(rawCommand), + arg: decodeDiscordCommandArgValue(rawArg), + value: decodeDiscordCommandArgValue(rawValue), + userId: decodeDiscordCommandArgValue(rawUser), }; +} + +class DiscordCommandArgButton extends Button { + label: string; + customId: string; + style = ButtonStyle.Secondary; + private cfg: ReturnType; + private discordConfig: DiscordConfig; + private accountId: string; + private sessionPrefix: string; + + constructor(params: { + label: string; + customId: string; + cfg: ReturnType; + discordConfig: DiscordConfig; + accountId: string; + sessionPrefix: string; + }) { + super(); + this.label = params.label; + this.customId = params.customId; + this.cfg = params.cfg; + this.discordConfig = params.discordConfig; + this.accountId = params.accountId; + this.sessionPrefix = params.sessionPrefix; + } + + async run(interaction: ButtonInteraction, data: ComponentData) { + const parsed = parseDiscordCommandArgData(data); + if (!parsed) { + await interaction.update({ + content: "Sorry, that selection is no longer available.", + components: [], + }); + return; + } + if (interaction.user?.id && interaction.user.id !== parsed.userId) { + await interaction.acknowledge(); + return; + } + const commandDefinition = + findCommandByNativeName(parsed.command) ?? + listChatCommands().find((entry) => entry.key === parsed.command); + if (!commandDefinition) { + await interaction.update({ + content: "Sorry, that command is no longer available.", + components: [], + }); + return; + } + await interaction.update({ + content: `✅ Selected ${parsed.value}.`, + components: [], + }); + const commandArgs = createCommandArgsWithValue({ + argName: parsed.arg, + value: parsed.value, + }); + const commandArgsWithRaw: CommandArgs = { + ...commandArgs, + raw: serializeCommandArgs(commandDefinition, commandArgs), + }; + const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw); + await dispatchDiscordCommandInteraction({ + interaction, + prompt, + command: commandDefinition, + commandArgs: commandArgsWithRaw, + cfg: this.cfg, + discordConfig: this.discordConfig, + accountId: this.accountId, + sessionPrefix: this.sessionPrefix, + preferFollowUp: true, + }); + } +} + +function buildDiscordCommandArgMenu(params: { + command: ChatCommandDefinition; + menu: { arg: CommandArgDefinition; choices: string[]; title?: string }; + interaction: CommandInteraction; + cfg: ReturnType; + discordConfig: DiscordConfig; + accountId: string; + sessionPrefix: string; +}): { content: string; components: Row