Merge pull request #936 from clawdbot/shadow/dynamic-command-args
Commands: add dynamic arg menus
This commit is contained in:
@@ -122,6 +122,7 @@
|
|||||||
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
|
- 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: 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.
|
- 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.
|
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
- 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”).
|
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||||
|
|||||||
@@ -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 struct Snapshot: Codable, Sendable {
|
||||||
public let presence: [PresenceEntry]
|
public let presence: [PresenceEntry]
|
||||||
public let health: AnyCodable
|
public let health: AnyCodable
|
||||||
@@ -277,7 +252,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
public let uptimems: Int
|
public let uptimems: Int
|
||||||
public let configpath: String?
|
public let configpath: String?
|
||||||
public let statedir: String?
|
public let statedir: String?
|
||||||
public let sessiondefaults: SessionDefaults?
|
public let sessiondefaults: [String: AnyCodable]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
presence: [PresenceEntry],
|
presence: [PresenceEntry],
|
||||||
@@ -286,7 +261,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
uptimems: Int,
|
uptimems: Int,
|
||||||
configpath: String?,
|
configpath: String?,
|
||||||
statedir: String?,
|
statedir: String?,
|
||||||
sessiondefaults: SessionDefaults?
|
sessiondefaults: [String: AnyCodable]?
|
||||||
) {
|
) {
|
||||||
self.presence = presence
|
self.presence = presence
|
||||||
self.health = health
|
self.health = health
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Text + native (when enabled):
|
|||||||
- `/activation mention|always` (groups only)
|
- `/activation mention|always` (groups only)
|
||||||
- `/send on|off|inherit` (owner-only)
|
- `/send on|off|inherit` (owner-only)
|
||||||
- `/reset` or `/new`
|
- `/reset` or `/new`
|
||||||
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
|
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||||
- `/verbose on|off` (alias: `/v`)
|
- `/verbose on|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||||
- `/elevated on|off` (alias: `/elev`)
|
- `/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.
|
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
||||||
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
||||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
- 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)
|
## Usage vs cost (what shows where)
|
||||||
|
|
||||||
@@ -167,4 +168,4 @@ Notes:
|
|||||||
- Slack: `agent:<agentId>:slack:slash:<userId>` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`)
|
- Slack: `agent:<agentId>:slack:slash:<userId>` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`)
|
||||||
- Telegram: `telegram:slash:<userId>` (targets the chat session via `CommandTargetSessionKey`)
|
- Telegram: `telegram:slash:<userId>` (targets the chat session via `CommandTargetSessionKey`)
|
||||||
- **`/stop`** targets the active chat session so it can abort the current run.
|
- **`/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.
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
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", () => ({
|
vi.mock("../../../../src/config/config.js", () => ({
|
||||||
loadConfig: () => ({}),
|
loadConfig: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
66
src/auto-reply/commands-args.ts
Normal file
66
src/auto-reply/commands-args.ts
Normal file
@@ -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<string, CommandArgsFormatter> = {
|
||||||
|
config: formatConfigArgs,
|
||||||
|
debug: formatDebugArgs,
|
||||||
|
queue: formatQueueArgs,
|
||||||
|
};
|
||||||
97
src/auto-reply/commands-registry.args.test.ts
Normal file
97
src/auto-reply/commands-registry.args.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { listChannelDocks } from "../channels/dock.js";
|
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";
|
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
|
||||||
|
|
||||||
type DefineChatCommandInput = {
|
type DefineChatCommandInput = {
|
||||||
key: string;
|
key: string;
|
||||||
nativeName?: string;
|
nativeName?: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
args?: ChatCommandDefinition["args"];
|
||||||
|
argsParsing?: ChatCommandDefinition["argsParsing"];
|
||||||
|
formatArgs?: ChatCommandDefinition["formatArgs"];
|
||||||
|
argsMenu?: ChatCommandDefinition["argsMenu"];
|
||||||
acceptsArgs?: boolean;
|
acceptsArgs?: boolean;
|
||||||
textAlias?: string;
|
textAlias?: string;
|
||||||
textAliases?: string[];
|
textAliases?: string[];
|
||||||
@@ -17,11 +23,17 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const scope =
|
const scope =
|
||||||
command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text");
|
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 {
|
return {
|
||||||
key: command.key,
|
key: command.key,
|
||||||
nativeName: command.nativeName,
|
nativeName: command.nativeName,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: command.acceptsArgs,
|
acceptsArgs,
|
||||||
|
args: command.args,
|
||||||
|
argsParsing,
|
||||||
|
formatArgs: command.formatArgs,
|
||||||
|
argsMenu: command.argsMenu,
|
||||||
textAliases: aliases,
|
textAliases: aliases,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -35,7 +47,6 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
|
|||||||
nativeName: `dock_${dock.id}`,
|
nativeName: `dock_${dock.id}`,
|
||||||
description: `Switch to ${dock.id} for replies.`,
|
description: `Switch to ${dock.id} for replies.`,
|
||||||
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
|
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
|
||||||
acceptsArgs: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,21 +149,69 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
nativeName: "config",
|
nativeName: "config",
|
||||||
description: "Show or set config values.",
|
description: "Show or set config values.",
|
||||||
textAlias: "/config",
|
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({
|
defineChatCommand({
|
||||||
key: "debug",
|
key: "debug",
|
||||||
nativeName: "debug",
|
nativeName: "debug",
|
||||||
description: "Set runtime debug overrides.",
|
description: "Set runtime debug overrides.",
|
||||||
textAlias: "/debug",
|
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({
|
defineChatCommand({
|
||||||
key: "cost",
|
key: "cost",
|
||||||
nativeName: "cost",
|
nativeName: "cost",
|
||||||
description: "Toggle per-response usage line.",
|
description: "Toggle per-response usage line.",
|
||||||
textAlias: "/cost",
|
textAlias: "/cost",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on or off",
|
||||||
|
type: "string",
|
||||||
|
choices: ["on", "off"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "stop",
|
key: "stop",
|
||||||
@@ -171,14 +230,30 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
nativeName: "activation",
|
nativeName: "activation",
|
||||||
description: "Set group activation mode.",
|
description: "Set group activation mode.",
|
||||||
textAlias: "/activation",
|
textAlias: "/activation",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "mention or always",
|
||||||
|
type: "string",
|
||||||
|
choices: ["mention", "always"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "send",
|
key: "send",
|
||||||
nativeName: "send",
|
nativeName: "send",
|
||||||
description: "Set send policy.",
|
description: "Set send policy.",
|
||||||
textAlias: "/send",
|
textAlias: "/send",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on, off, or inherit",
|
||||||
|
type: "string",
|
||||||
|
choices: ["on", "off", "inherit"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "reset",
|
key: "reset",
|
||||||
@@ -197,56 +272,133 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
description: "Compact the session context.",
|
description: "Compact the session context.",
|
||||||
textAlias: "/compact",
|
textAlias: "/compact",
|
||||||
scope: "text",
|
scope: "text",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "instructions",
|
||||||
|
description: "Extra compaction instructions",
|
||||||
|
type: "string",
|
||||||
|
captureRemaining: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "think",
|
key: "think",
|
||||||
nativeName: "think",
|
nativeName: "think",
|
||||||
description: "Set thinking level.",
|
description: "Set thinking level.",
|
||||||
textAlias: "/think",
|
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({
|
defineChatCommand({
|
||||||
key: "verbose",
|
key: "verbose",
|
||||||
nativeName: "verbose",
|
nativeName: "verbose",
|
||||||
description: "Toggle verbose mode.",
|
description: "Toggle verbose mode.",
|
||||||
textAlias: "/verbose",
|
textAlias: "/verbose",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on or off",
|
||||||
|
type: "string",
|
||||||
|
choices: ["on", "off"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "reasoning",
|
key: "reasoning",
|
||||||
nativeName: "reasoning",
|
nativeName: "reasoning",
|
||||||
description: "Toggle reasoning visibility.",
|
description: "Toggle reasoning visibility.",
|
||||||
textAlias: "/reasoning",
|
textAlias: "/reasoning",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on, off, or stream",
|
||||||
|
type: "string",
|
||||||
|
choices: ["on", "off", "stream"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "elevated",
|
key: "elevated",
|
||||||
nativeName: "elevated",
|
nativeName: "elevated",
|
||||||
description: "Toggle elevated mode.",
|
description: "Toggle elevated mode.",
|
||||||
textAlias: "/elevated",
|
textAlias: "/elevated",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on or off",
|
||||||
|
type: "string",
|
||||||
|
choices: ["on", "off"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "model",
|
key: "model",
|
||||||
nativeName: "model",
|
nativeName: "model",
|
||||||
description: "Show or set the model.",
|
description: "Show or set the model.",
|
||||||
textAlias: "/model",
|
textAlias: "/model",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "model",
|
||||||
|
description: "Model id (provider/model or id)",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "queue",
|
key: "queue",
|
||||||
nativeName: "queue",
|
nativeName: "queue",
|
||||||
description: "Adjust queue settings.",
|
description: "Adjust queue settings.",
|
||||||
textAlias: "/queue",
|
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({
|
defineChatCommand({
|
||||||
key: "bash",
|
key: "bash",
|
||||||
description: "Run host shell commands (host-only).",
|
description: "Run host shell commands (host-only).",
|
||||||
textAlias: "/bash",
|
textAlias: "/bash",
|
||||||
scope: "text",
|
scope: "text",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "command",
|
||||||
|
description: "Shell command",
|
||||||
|
type: "string",
|
||||||
|
captureRemaining: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
...listChannelDocks()
|
...listChannelDocks()
|
||||||
.filter((dock) => dock.capabilities.nativeCommands)
|
.filter((dock) => dock.capabilities.nativeCommands)
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { ClawdbotConfig } from "../config/types.js";
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.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 {
|
import type {
|
||||||
ChatCommandDefinition,
|
ChatCommandDefinition,
|
||||||
|
CommandArgChoiceContext,
|
||||||
|
CommandArgDefinition,
|
||||||
|
CommandArgMenuSpec,
|
||||||
|
CommandArgValues,
|
||||||
|
CommandArgs,
|
||||||
CommandDetection,
|
CommandDetection,
|
||||||
CommandNormalizeOptions,
|
CommandNormalizeOptions,
|
||||||
NativeCommandSpec,
|
NativeCommandSpec,
|
||||||
@@ -11,6 +18,11 @@ import type {
|
|||||||
export { CHAT_COMMANDS } from "./commands-registry.data.js";
|
export { CHAT_COMMANDS } from "./commands-registry.data.js";
|
||||||
export type {
|
export type {
|
||||||
ChatCommandDefinition,
|
ChatCommandDefinition,
|
||||||
|
CommandArgChoiceContext,
|
||||||
|
CommandArgDefinition,
|
||||||
|
CommandArgMenuSpec,
|
||||||
|
CommandArgValues,
|
||||||
|
CommandArgs,
|
||||||
CommandDetection,
|
CommandDetection,
|
||||||
CommandNormalizeOptions,
|
CommandNormalizeOptions,
|
||||||
CommandScope,
|
CommandScope,
|
||||||
@@ -70,6 +82,7 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
|||||||
name: command.nativeName ?? command.key,
|
name: command.nativeName ?? command.key,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: Boolean(command.acceptsArgs),
|
acceptsArgs: Boolean(command.acceptsArgs),
|
||||||
|
args: command.args,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,6 +94,7 @@ export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeComm
|
|||||||
name: command.nativeName ?? command.key,
|
name: command.nativeName ?? command.key,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: Boolean(command.acceptsArgs),
|
acceptsArgs: Boolean(command.acceptsArgs),
|
||||||
|
args: command.args,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +110,137 @@ export function buildCommandText(commandName: string, args?: string): string {
|
|||||||
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
|
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 {
|
export function normalizeCommandBody(raw: string, options?: CommandNormalizeOptions): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed.startsWith("/")) return trimmed;
|
if (!trimmed.startsWith("/")) return trimmed;
|
||||||
|
|||||||
@@ -2,12 +2,51 @@ import type { ClawdbotConfig } from "../config/types.js";
|
|||||||
|
|
||||||
export type CommandScope = "text" | "native" | "both";
|
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<string, unknown>;
|
||||||
|
|
||||||
|
export type CommandArgs = {
|
||||||
|
raw?: string;
|
||||||
|
values?: CommandArgValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandArgsParsing = "none" | "positional";
|
||||||
|
|
||||||
export type ChatCommandDefinition = {
|
export type ChatCommandDefinition = {
|
||||||
key: string;
|
key: string;
|
||||||
nativeName?: string;
|
nativeName?: string;
|
||||||
description: string;
|
description: string;
|
||||||
textAliases: string[];
|
textAliases: string[];
|
||||||
acceptsArgs?: boolean;
|
acceptsArgs?: boolean;
|
||||||
|
args?: CommandArgDefinition[];
|
||||||
|
argsParsing?: CommandArgsParsing;
|
||||||
|
formatArgs?: (values: CommandArgValues) => string | undefined;
|
||||||
|
argsMenu?: CommandArgMenuSpec | "auto";
|
||||||
scope: CommandScope;
|
scope: CommandScope;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,6 +54,7 @@ export type NativeCommandSpec = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
|
args?: CommandArgDefinition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandNormalizeOptions = {
|
export type CommandNormalizeOptions = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
||||||
|
import type { CommandArgs } from "./commands-registry.types.js";
|
||||||
|
|
||||||
/** Valid message channels for routing. */
|
/** Valid message channels for routing. */
|
||||||
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
|
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
|
||||||
@@ -15,6 +16,7 @@ export type MsgContext = {
|
|||||||
* Prefer for command detection; RawBody is treated as legacy alias.
|
* Prefer for command detection; RawBody is treated as legacy alias.
|
||||||
*/
|
*/
|
||||||
CommandBody?: string;
|
CommandBody?: string;
|
||||||
|
CommandArgs?: CommandArgs;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
SessionKey?: string;
|
SessionKey?: string;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ function parseThreadId(threadId?: string | number | null) {
|
|||||||
const parsed = Number.parseInt(trimmed, 10);
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: markdownToTelegramHtmlChunks,
|
chunker: markdownToTelegramHtmlChunks,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ function parseThreadId(threadId?: string | number | null) {
|
|||||||
const parsed = Number.parseInt(trimmed, 10);
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||||
id: "telegram",
|
id: "telegram",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -362,9 +362,11 @@ export async function runConfigureWizard(
|
|||||||
basePath: undefined,
|
basePath: undefined,
|
||||||
});
|
});
|
||||||
const remoteUrl = nextConfig.gateway?.remote?.url?.trim();
|
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 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({
|
await waitForGatewayReachable({
|
||||||
url: wsUrl,
|
url: wsUrl,
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
const cfg: ClawdbotConfig = {};
|
const cfg: ClawdbotConfig = {};
|
||||||
vi.mocked(fs.existsSync).mockImplementation((value) => {
|
vi.mocked(fs.existsSync).mockImplementation((value) => {
|
||||||
const raw = String(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({
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
@@ -109,7 +111,9 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
const cfg: ClawdbotConfig = {};
|
const cfg: ClawdbotConfig = {};
|
||||||
vi.mocked(fs.existsSync).mockImplementation((value) => {
|
vi.mocked(fs.existsSync).mockImplementation((value) => {
|
||||||
const raw = String(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({
|
installPluginFromNpmSpec.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ vi.mock("@buape/carbon", () => ({
|
|||||||
ContextMenuCommand: 2,
|
ContextMenuCommand: 2,
|
||||||
Default: 0,
|
Default: 0,
|
||||||
},
|
},
|
||||||
|
Button: class {},
|
||||||
Command: class {},
|
Command: class {},
|
||||||
Client: class {},
|
Client: class {},
|
||||||
MessageCreateListener: class {},
|
MessageCreateListener: class {},
|
||||||
MessageReactionAddListener: class {},
|
MessageReactionAddListener: class {},
|
||||||
MessageReactionRemoveListener: class {},
|
MessageReactionRemoveListener: class {},
|
||||||
|
Row: class {
|
||||||
|
constructor(_components: unknown[]) {}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import { ChannelType, Command, type CommandInteraction, type CommandOptions } from "@buape/carbon";
|
import {
|
||||||
import { ApplicationCommandOptionType } from "discord-api-types/v10";
|
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 { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.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 { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { ClawdbotConfig, loadConfig } from "../../config/config.js";
|
import type { ClawdbotConfig, loadConfig } from "../../config/config.js";
|
||||||
@@ -28,12 +53,261 @@ import { formatDiscordUserTag } from "./format.js";
|
|||||||
|
|
||||||
type DiscordConfig = NonNullable<ClawdbotConfig["channels"]>["discord"];
|
type DiscordConfig = NonNullable<ClawdbotConfig["channels"]>["discord"];
|
||||||
|
|
||||||
export function createDiscordNativeCommand(params: {
|
function buildDiscordCommandOptions(params: {
|
||||||
command: {
|
command: ChatCommandDefinition;
|
||||||
name: string;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
description: string;
|
}): CommandOptions | undefined {
|
||||||
acceptsArgs: boolean;
|
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<T>(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<typeof loadConfig>;
|
||||||
|
private discordConfig: DiscordConfig;
|
||||||
|
private accountId: string;
|
||||||
|
private sessionPrefix: string;
|
||||||
|
|
||||||
|
constructor(params: {
|
||||||
|
label: string;
|
||||||
|
customId: string;
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
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<typeof loadConfig>;
|
||||||
|
discordConfig: DiscordConfig;
|
||||||
|
accountId: string;
|
||||||
|
sessionPrefix: string;
|
||||||
|
}): { content: string; components: Row<Button>[] } {
|
||||||
|
const { command, menu, interaction } = params;
|
||||||
|
const commandLabel = command.nativeName ?? command.key;
|
||||||
|
const userId = interaction.user?.id ?? "";
|
||||||
|
const rows = chunkItems(menu.choices, 4).map((choices) => {
|
||||||
|
const buttons = choices.map(
|
||||||
|
(choice) =>
|
||||||
|
new DiscordCommandArgButton({
|
||||||
|
label: choice,
|
||||||
|
customId: buildDiscordCommandArgCustomId({
|
||||||
|
command: commandLabel,
|
||||||
|
arg: menu.arg.name,
|
||||||
|
value: choice,
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
|
cfg: params.cfg,
|
||||||
|
discordConfig: params.discordConfig,
|
||||||
|
accountId: params.accountId,
|
||||||
|
sessionPrefix: params.sessionPrefix,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return new Row(buttons);
|
||||||
|
});
|
||||||
|
const content =
|
||||||
|
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
|
||||||
|
return { content, components: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiscordNativeCommand(params: {
|
||||||
|
command: NativeCommandSpec;
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
discordConfig: DiscordConfig;
|
discordConfig: DiscordConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -41,12 +315,26 @@ export function createDiscordNativeCommand(params: {
|
|||||||
ephemeralDefault: boolean;
|
ephemeralDefault: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
|
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
|
||||||
return new (class extends Command {
|
const commandDefinition =
|
||||||
name = command.name;
|
findCommandByNativeName(command.name) ??
|
||||||
description = command.description;
|
({
|
||||||
defer = true;
|
key: command.name,
|
||||||
ephemeral = ephemeralDefault;
|
nativeName: command.name,
|
||||||
options = command.acceptsArgs
|
description: command.description,
|
||||||
|
textAliases: [],
|
||||||
|
acceptsArgs: command.acceptsArgs,
|
||||||
|
args: command.args,
|
||||||
|
argsParsing: "none",
|
||||||
|
scope: "native",
|
||||||
|
} satisfies ChatCommandDefinition);
|
||||||
|
const argDefinitions = commandDefinition.args ?? command.args;
|
||||||
|
const commandOptions = buildDiscordCommandOptions({
|
||||||
|
command: commandDefinition,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
const options = commandOptions
|
||||||
|
? (commandOptions satisfies CommandOptions)
|
||||||
|
: command.acceptsArgs
|
||||||
? ([
|
? ([
|
||||||
{
|
{
|
||||||
name: "input",
|
name: "input",
|
||||||
@@ -56,219 +344,301 @@ export function createDiscordNativeCommand(params: {
|
|||||||
},
|
},
|
||||||
] satisfies CommandOptions)
|
] satisfies CommandOptions)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
return new (class extends Command {
|
||||||
|
name = command.name;
|
||||||
|
description = command.description;
|
||||||
|
defer = true;
|
||||||
|
ephemeral = ephemeralDefault;
|
||||||
|
options = options;
|
||||||
|
|
||||||
async run(interaction: CommandInteraction) {
|
async run(interaction: CommandInteraction) {
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const commandArgs = argDefinitions?.length
|
||||||
const user = interaction.user;
|
? readDiscordCommandArgs(interaction, argDefinitions)
|
||||||
if (!user) return;
|
: command.acceptsArgs
|
||||||
const channel = interaction.channel;
|
? parseCommandArgs(commandDefinition, interaction.options.getString("input") ?? "")
|
||||||
const channelType = channel?.type;
|
: undefined;
|
||||||
const isDirectMessage = channelType === ChannelType.DM;
|
const commandArgsWithRaw = commandArgs
|
||||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
? ({
|
||||||
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
...commandArgs,
|
||||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
raw: serializeCommandArgs(commandDefinition, commandArgs) ?? commandArgs.raw,
|
||||||
const prompt = buildCommandText(
|
} satisfies CommandArgs)
|
||||||
this.name,
|
: undefined;
|
||||||
command.acceptsArgs ? interaction.options.getString("input") : undefined,
|
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
|
||||||
);
|
await dispatchDiscordCommandInteraction({
|
||||||
const guildInfo = resolveDiscordGuildEntry({
|
interaction,
|
||||||
guild: interaction.guild ?? undefined,
|
prompt,
|
||||||
guildEntries: discordConfig?.guilds,
|
command: commandDefinition,
|
||||||
});
|
commandArgs: commandArgsWithRaw,
|
||||||
const channelConfig = interaction.guild
|
|
||||||
? resolveDiscordChannelConfig({
|
|
||||||
guildInfo,
|
|
||||||
channelId: channel?.id ?? "",
|
|
||||||
channelName,
|
|
||||||
channelSlug,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
if (channelConfig?.enabled === false) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "This channel is disabled.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (interaction.guild && channelConfig?.allowed === false) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "This channel is not allowed.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (useAccessGroups && interaction.guild) {
|
|
||||||
const channelAllowlistConfigured =
|
|
||||||
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
|
|
||||||
const channelAllowed = channelConfig?.allowed !== false;
|
|
||||||
const allowByPolicy = isDiscordGroupAllowedByPolicy({
|
|
||||||
groupPolicy: discordConfig?.groupPolicy ?? "open",
|
|
||||||
channelAllowlistConfigured,
|
|
||||||
channelAllowed,
|
|
||||||
});
|
|
||||||
if (!allowByPolicy) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "This channel is not allowed.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dmEnabled = discordConfig?.dm?.enabled ?? true;
|
|
||||||
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
|
|
||||||
let commandAuthorized = true;
|
|
||||||
if (isDirectMessage) {
|
|
||||||
if (!dmEnabled || dmPolicy === "disabled") {
|
|
||||||
await interaction.reply({ content: "Discord DMs are disabled." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dmPolicy !== "open") {
|
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
|
||||||
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
|
|
||||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
|
|
||||||
const permitted = allowList
|
|
||||||
? allowListMatches(allowList, {
|
|
||||||
id: user.id,
|
|
||||||
name: user.username,
|
|
||||||
tag: formatDiscordUserTag(user),
|
|
||||||
})
|
|
||||||
: false;
|
|
||||||
if (!permitted) {
|
|
||||||
commandAuthorized = false;
|
|
||||||
if (dmPolicy === "pairing") {
|
|
||||||
const { code, created } = await upsertChannelPairingRequest({
|
|
||||||
channel: "discord",
|
|
||||||
id: user.id,
|
|
||||||
meta: {
|
|
||||||
tag: formatDiscordUserTag(user),
|
|
||||||
name: user.username ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (created) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: buildPairingReply({
|
|
||||||
channel: "discord",
|
|
||||||
idLine: `Your Discord user id: ${user.id}`,
|
|
||||||
code,
|
|
||||||
}),
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "You are not authorized to use this command.",
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commandAuthorized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isDirectMessage) {
|
|
||||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
|
||||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
|
||||||
const userOk = resolveDiscordUserAllowed({
|
|
||||||
allowList: channelUsers,
|
|
||||||
userId: user.id,
|
|
||||||
userName: user.username,
|
|
||||||
userTag: formatDiscordUserTag(user),
|
|
||||||
});
|
|
||||||
if (!userOk) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "You are not authorized to use this command.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
|
|
||||||
await interaction.reply({ content: "Discord group DMs are disabled." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGuild = Boolean(interaction.guild);
|
|
||||||
const channelId = channel?.id ?? "unknown";
|
|
||||||
const interactionId = interaction.rawData.id;
|
|
||||||
const route = resolveAgentRoute({
|
|
||||||
cfg,
|
cfg,
|
||||||
channel: "discord",
|
discordConfig,
|
||||||
accountId,
|
accountId,
|
||||||
guildId: interaction.guild?.id ?? undefined,
|
sessionPrefix,
|
||||||
peer: {
|
preferFollowUp: false,
|
||||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
|
||||||
id: isDirectMessage ? user.id : channelId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const ctxPayload = {
|
|
||||||
Body: prompt,
|
|
||||||
CommandBody: prompt,
|
|
||||||
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`,
|
|
||||||
To: `slash:${user.id}`,
|
|
||||||
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
|
|
||||||
CommandTargetSessionKey: route.sessionKey,
|
|
||||||
AccountId: route.accountId,
|
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
|
||||||
GroupSubject: isGuild ? interaction.guild?.name : undefined,
|
|
||||||
GroupSystemPrompt: isGuild
|
|
||||||
? (() => {
|
|
||||||
const channelTopic =
|
|
||||||
channel && "topic" in channel ? (channel.topic ?? undefined) : undefined;
|
|
||||||
const channelDescription = channelTopic?.trim();
|
|
||||||
const systemPromptParts = [
|
|
||||||
channelDescription ? `Channel topic: ${channelDescription}` : null,
|
|
||||||
channelConfig?.systemPrompt?.trim() || null,
|
|
||||||
].filter((entry): entry is string => Boolean(entry));
|
|
||||||
return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
|
||||||
})()
|
|
||||||
: undefined,
|
|
||||||
SenderName: user.globalName ?? user.username,
|
|
||||||
SenderId: user.id,
|
|
||||||
SenderUsername: user.username,
|
|
||||||
SenderTag: formatDiscordUserTag(user),
|
|
||||||
Provider: "discord" as const,
|
|
||||||
Surface: "discord" as const,
|
|
||||||
WasMentioned: true,
|
|
||||||
MessageSid: interactionId,
|
|
||||||
Timestamp: Date.now(),
|
|
||||||
CommandAuthorized: commandAuthorized,
|
|
||||||
CommandSource: "native" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
let didReply = false;
|
|
||||||
await dispatchReplyWithDispatcher({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcherOptions: {
|
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
|
||||||
deliver: async (payload) => {
|
|
||||||
await deliverDiscordInteractionReply({
|
|
||||||
interaction,
|
|
||||||
payload,
|
|
||||||
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
|
||||||
fallbackLimit: 2000,
|
|
||||||
}),
|
|
||||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
|
||||||
preferFollowUp: didReply,
|
|
||||||
});
|
|
||||||
didReply = true;
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
console.error(`discord slash ${info.kind} reply failed`, err);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
replyOptions: {
|
|
||||||
skillFilter: channelConfig?.skills,
|
|
||||||
disableBlockStreaming:
|
|
||||||
typeof discordConfig?.blockStreaming === "boolean"
|
|
||||||
? !discordConfig.blockStreaming
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dispatchDiscordCommandInteraction(params: {
|
||||||
|
interaction: CommandInteraction | ButtonInteraction;
|
||||||
|
prompt: string;
|
||||||
|
command: ChatCommandDefinition;
|
||||||
|
commandArgs?: CommandArgs;
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
discordConfig: DiscordConfig;
|
||||||
|
accountId: string;
|
||||||
|
sessionPrefix: string;
|
||||||
|
preferFollowUp: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
interaction,
|
||||||
|
prompt,
|
||||||
|
command,
|
||||||
|
commandArgs,
|
||||||
|
cfg,
|
||||||
|
discordConfig,
|
||||||
|
accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
preferFollowUp,
|
||||||
|
} = params;
|
||||||
|
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||||
|
const payload = {
|
||||||
|
content,
|
||||||
|
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
|
||||||
|
};
|
||||||
|
if (preferFollowUp) {
|
||||||
|
await interaction.followUp(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
|
const user = interaction.user;
|
||||||
|
if (!user) return;
|
||||||
|
const channel = interaction.channel;
|
||||||
|
const channelType = channel?.type;
|
||||||
|
const isDirectMessage = channelType === ChannelType.DM;
|
||||||
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||||
|
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
||||||
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||||
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
|
guild: interaction.guild ?? undefined,
|
||||||
|
guildEntries: discordConfig?.guilds,
|
||||||
|
});
|
||||||
|
const channelConfig = interaction.guild
|
||||||
|
? resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: channel?.id ?? "",
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (channelConfig?.enabled === false) {
|
||||||
|
await respond("This channel is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.guild && channelConfig?.allowed === false) {
|
||||||
|
await respond("This channel is not allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (useAccessGroups && interaction.guild) {
|
||||||
|
const channelAllowlistConfigured =
|
||||||
|
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
|
||||||
|
const channelAllowed = channelConfig?.allowed !== false;
|
||||||
|
const allowByPolicy = isDiscordGroupAllowedByPolicy({
|
||||||
|
groupPolicy: discordConfig?.groupPolicy ?? "open",
|
||||||
|
channelAllowlistConfigured,
|
||||||
|
channelAllowed,
|
||||||
|
});
|
||||||
|
if (!allowByPolicy) {
|
||||||
|
await respond("This channel is not allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dmEnabled = discordConfig?.dm?.enabled ?? true;
|
||||||
|
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
|
||||||
|
let commandAuthorized = true;
|
||||||
|
if (isDirectMessage) {
|
||||||
|
if (!dmEnabled || dmPolicy === "disabled") {
|
||||||
|
await respond("Discord DMs are disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
||||||
|
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
|
||||||
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
|
||||||
|
const permitted = allowList
|
||||||
|
? allowListMatches(allowList, {
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
tag: formatDiscordUserTag(user),
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
if (!permitted) {
|
||||||
|
commandAuthorized = false;
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await upsertChannelPairingRequest({
|
||||||
|
channel: "discord",
|
||||||
|
id: user.id,
|
||||||
|
meta: {
|
||||||
|
tag: formatDiscordUserTag(user),
|
||||||
|
name: user.username ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
await respond(
|
||||||
|
buildPairingReply({
|
||||||
|
channel: "discord",
|
||||||
|
idLine: `Your Discord user id: ${user.id}`,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
{ ephemeral: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await respond("You are not authorized to use this command.", { ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commandAuthorized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDirectMessage) {
|
||||||
|
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||||
|
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
||||||
|
const userOk = resolveDiscordUserAllowed({
|
||||||
|
allowList: channelUsers,
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.username,
|
||||||
|
userTag: formatDiscordUserTag(user),
|
||||||
|
});
|
||||||
|
if (!userOk) {
|
||||||
|
await respond("You are not authorized to use this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
|
||||||
|
await respond("Discord group DMs are disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = resolveCommandArgMenu({
|
||||||
|
command,
|
||||||
|
args: commandArgs,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
if (menu) {
|
||||||
|
const menuPayload = buildDiscordCommandArgMenu({
|
||||||
|
command,
|
||||||
|
menu,
|
||||||
|
interaction: interaction as CommandInteraction,
|
||||||
|
cfg,
|
||||||
|
discordConfig,
|
||||||
|
accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
});
|
||||||
|
if (preferFollowUp) {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: menuPayload.content,
|
||||||
|
components: menuPayload.components,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: menuPayload.content,
|
||||||
|
components: menuPayload.components,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGuild = Boolean(interaction.guild);
|
||||||
|
const channelId = channel?.id ?? "unknown";
|
||||||
|
const interactionId = interaction.rawData.id;
|
||||||
|
const route = resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "discord",
|
||||||
|
accountId,
|
||||||
|
guildId: interaction.guild?.id ?? undefined,
|
||||||
|
peer: {
|
||||||
|
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||||
|
id: isDirectMessage ? user.id : channelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctxPayload = {
|
||||||
|
Body: prompt,
|
||||||
|
CommandBody: prompt,
|
||||||
|
CommandArgs: commandArgs,
|
||||||
|
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`,
|
||||||
|
To: `slash:${user.id}`,
|
||||||
|
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
|
||||||
|
CommandTargetSessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
|
GroupSubject: isGuild ? interaction.guild?.name : undefined,
|
||||||
|
GroupSystemPrompt: isGuild
|
||||||
|
? (() => {
|
||||||
|
const channelTopic =
|
||||||
|
channel && "topic" in channel ? (channel.topic ?? undefined) : undefined;
|
||||||
|
const channelDescription = channelTopic?.trim();
|
||||||
|
const systemPromptParts = [
|
||||||
|
channelDescription ? `Channel topic: ${channelDescription}` : null,
|
||||||
|
channelConfig?.systemPrompt?.trim() || null,
|
||||||
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
|
return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||||
|
})()
|
||||||
|
: undefined,
|
||||||
|
SenderName: user.globalName ?? user.username,
|
||||||
|
SenderId: user.id,
|
||||||
|
SenderUsername: user.username,
|
||||||
|
SenderTag: formatDiscordUserTag(user),
|
||||||
|
Provider: "discord" as const,
|
||||||
|
Surface: "discord" as const,
|
||||||
|
WasMentioned: true,
|
||||||
|
MessageSid: interactionId,
|
||||||
|
Timestamp: Date.now(),
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
|
CommandSource: "native" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
let didReply = false;
|
||||||
|
await dispatchReplyWithDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverDiscordInteractionReply({
|
||||||
|
interaction,
|
||||||
|
payload,
|
||||||
|
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||||
|
fallbackLimit: 2000,
|
||||||
|
}),
|
||||||
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
|
preferFollowUp: preferFollowUp || didReply,
|
||||||
|
});
|
||||||
|
didReply = true;
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
console.error(`discord slash ${info.kind} reply failed`, err);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {
|
||||||
|
skillFilter: channelConfig?.skills,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof discordConfig?.blockStreaming === "boolean"
|
||||||
|
? !discordConfig.blockStreaming
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function deliverDiscordInteractionReply(params: {
|
async function deliverDiscordInteractionReply(params: {
|
||||||
interaction: CommandInteraction;
|
interaction: CommandInteraction | ButtonInteraction;
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
maxLinesPerMessage?: number;
|
maxLinesPerMessage?: number;
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ export type CodeSpanIndex = {
|
|||||||
isInside: (index: number) => boolean;
|
isInside: (index: number) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildCodeSpanIndex(
|
export function buildCodeSpanIndex(text: string, inlineState?: InlineCodeState): CodeSpanIndex {
|
||||||
text: string,
|
|
||||||
inlineState?: InlineCodeState,
|
|
||||||
): CodeSpanIndex {
|
|
||||||
const fenceSpans = parseFenceSpans(text);
|
const fenceSpans = parseFenceSpans(text);
|
||||||
const startState = inlineState
|
const startState = inlineState
|
||||||
? { open: inlineState.open, ticks: inlineState.ticks }
|
? { open: inlineState.open, ticks: inlineState.ticks }
|
||||||
|
|||||||
200
src/slack/monitor/slash.command-arg-menus.test.ts
Normal file
200
src/slack/monitor/slash.command-arg-menus.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||||
|
|
||||||
|
const dispatchMock = vi.fn();
|
||||||
|
const readAllowFromStoreMock = vi.fn();
|
||||||
|
const upsertPairingRequestMock = vi.fn();
|
||||||
|
const resolveAgentRouteMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||||
|
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||||
|
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||||
|
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../routing/resolve-route.js", () => ({
|
||||||
|
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/identity.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function encodeValue(parts: { command: string; arg: string; value: string; userId: string }) {
|
||||||
|
return [
|
||||||
|
"cmdarg",
|
||||||
|
encodeURIComponent(parts.command),
|
||||||
|
encodeURIComponent(parts.arg),
|
||||||
|
encodeURIComponent(parts.value),
|
||||||
|
encodeURIComponent(parts.userId),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const commands = new Map<string, (args: unknown) => Promise<void>>();
|
||||||
|
const actions = new Map<string, (args: unknown) => Promise<void>>();
|
||||||
|
|
||||||
|
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
const app = {
|
||||||
|
client: { chat: { postEphemeral } },
|
||||||
|
command: (name: string, handler: (args: unknown) => Promise<void>) => {
|
||||||
|
commands.set(name, handler);
|
||||||
|
},
|
||||||
|
action: (id: string, handler: (args: unknown) => Promise<void>) => {
|
||||||
|
actions.set(id, handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
cfg: { commands: { native: true } },
|
||||||
|
runtime: {},
|
||||||
|
botToken: "bot-token",
|
||||||
|
botUserId: "bot",
|
||||||
|
teamId: "T1",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
groupDmEnabled: false,
|
||||||
|
groupDmChannels: [],
|
||||||
|
defaultRequireMention: true,
|
||||||
|
groupPolicy: "open",
|
||||||
|
useAccessGroups: false,
|
||||||
|
channelsConfig: undefined,
|
||||||
|
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
|
||||||
|
textLimit: 4000,
|
||||||
|
app,
|
||||||
|
isChannelAllowed: () => true,
|
||||||
|
resolveChannelName: async () => ({ name: "dm", type: "im" }),
|
||||||
|
resolveUserName: async () => ({ name: "Ada" }),
|
||||||
|
} as unknown;
|
||||||
|
|
||||||
|
const account = { accountId: "acct", config: { commands: { native: true } } } as unknown;
|
||||||
|
|
||||||
|
return { commands, actions, postEphemeral, ctx, account };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
|
||||||
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
resolveAgentRouteMock.mockReset().mockReturnValue({
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "session:1",
|
||||||
|
accountId: "acct",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slack native command argument menus", () => {
|
||||||
|
it("shows a button menu when required args are omitted", async () => {
|
||||||
|
const { commands, ctx, account } = createHarness();
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = commands.get("/cost");
|
||||||
|
if (!handler) throw new Error("Missing /cost handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const ack = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
command: {
|
||||||
|
user_id: "U1",
|
||||||
|
user_name: "Ada",
|
||||||
|
channel_id: "C1",
|
||||||
|
channel_name: "directmessage",
|
||||||
|
text: "",
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
ack,
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(respond).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||||
|
expect(payload.blocks?.[0]?.type).toBe("section");
|
||||||
|
expect(payload.blocks?.[1]?.type).toBe("actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches the command when a menu button is clicked", async () => {
|
||||||
|
const { actions, ctx, account } = createHarness();
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = actions.get("clawdbot_cmdarg");
|
||||||
|
if (!handler) throw new Error("Missing arg-menu action handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: {
|
||||||
|
value: encodeValue({ command: "cost", arg: "mode", value: "on", userId: "U1" }),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
user: { id: "U1", name: "Ada" },
|
||||||
|
channel: { id: "C1", name: "directmessage" },
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
|
||||||
|
expect(call.ctx?.Body).toBe("/cost on");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects menu clicks from other users", async () => {
|
||||||
|
const { actions, ctx, account } = createHarness();
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = actions.get("clawdbot_cmdarg");
|
||||||
|
if (!handler) throw new Error("Missing arg-menu action handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: {
|
||||||
|
value: encodeValue({ command: "cost", arg: "mode", value: "on", userId: "U1" }),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
user: { id: "U2", name: "Eve" },
|
||||||
|
channel: { id: "C1", name: "directmessage" },
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).not.toHaveBeenCalled();
|
||||||
|
expect(respond).toHaveBeenCalledWith({
|
||||||
|
text: "That menu is for another user.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to postEphemeral with token when respond is unavailable", async () => {
|
||||||
|
const { actions, postEphemeral, ctx, account } = createHarness();
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = actions.get("clawdbot_cmdarg");
|
||||||
|
if (!handler) throw new Error("Missing arg-menu action handler");
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: { value: "garbage" },
|
||||||
|
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postEphemeral).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
token: "bot-token",
|
||||||
|
channel: "C1",
|
||||||
|
user: "U1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { SlackCommandMiddlewareArgs } from "@slack/bolt";
|
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
|
||||||
|
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandTextFromArgs,
|
||||||
|
findCommandByNativeName,
|
||||||
listNativeCommandSpecsForConfig,
|
listNativeCommandSpecsForConfig,
|
||||||
|
parseCommandArgs,
|
||||||
|
resolveCommandArgMenu,
|
||||||
} from "../../auto-reply/commands-registry.js";
|
} from "../../auto-reply/commands-registry.js";
|
||||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
|
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
|
||||||
@@ -28,6 +32,84 @@ import type { SlackMonitorContext } from "./context.js";
|
|||||||
import { isSlackRoomAllowedByPolicy } from "./policy.js";
|
import { isSlackRoomAllowedByPolicy } from "./policy.js";
|
||||||
import { deliverSlackSlashReplies } from "./replies.js";
|
import { deliverSlackSlashReplies } from "./replies.js";
|
||||||
|
|
||||||
|
type SlackBlock = { type: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
const SLACK_COMMAND_ARG_ACTION_ID = "clawdbot_cmdarg";
|
||||||
|
const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg";
|
||||||
|
|
||||||
|
function chunkItems<T>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSlackCommandArgValue(parts: {
|
||||||
|
command: string;
|
||||||
|
arg: string;
|
||||||
|
value: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
SLACK_COMMAND_ARG_VALUE_PREFIX,
|
||||||
|
encodeURIComponent(parts.command),
|
||||||
|
encodeURIComponent(parts.arg),
|
||||||
|
encodeURIComponent(parts.value),
|
||||||
|
encodeURIComponent(parts.userId),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSlackCommandArgValue(raw?: string | null): {
|
||||||
|
command: string;
|
||||||
|
arg: string;
|
||||||
|
value: string;
|
||||||
|
userId: string;
|
||||||
|
} | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split("|");
|
||||||
|
if (parts.length !== 5 || parts[0] !== SLACK_COMMAND_ARG_VALUE_PREFIX) return null;
|
||||||
|
const [, command, arg, value, userId] = parts;
|
||||||
|
if (!command || !arg || !value || !userId) return null;
|
||||||
|
return {
|
||||||
|
command: decodeURIComponent(command),
|
||||||
|
arg: decodeURIComponent(arg),
|
||||||
|
value: decodeURIComponent(value),
|
||||||
|
userId: decodeURIComponent(userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlackCommandArgMenuBlocks(params: {
|
||||||
|
title: string;
|
||||||
|
command: string;
|
||||||
|
arg: string;
|
||||||
|
choices: string[];
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const rows = chunkItems(params.choices, 5).map((choices) => ({
|
||||||
|
type: "actions",
|
||||||
|
elements: choices.map((choice) => ({
|
||||||
|
type: "button",
|
||||||
|
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
||||||
|
text: { type: "plain_text", text: choice },
|
||||||
|
value: encodeSlackCommandArgValue({
|
||||||
|
command: params.command,
|
||||||
|
arg: params.arg,
|
||||||
|
value: choice,
|
||||||
|
userId: params.userId,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: { type: "mrkdwn", text: params.title },
|
||||||
|
},
|
||||||
|
...rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSlackMonitorSlashCommands(params: {
|
export function registerSlackMonitorSlashCommands(params: {
|
||||||
ctx: SlackMonitorContext;
|
ctx: SlackMonitorContext;
|
||||||
account: ResolvedSlackAccount;
|
account: ResolvedSlackAccount;
|
||||||
@@ -36,6 +118,9 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
const cfg = ctx.cfg;
|
const cfg = ctx.cfg;
|
||||||
const runtime = ctx.runtime;
|
const runtime = ctx.runtime;
|
||||||
|
|
||||||
|
const supportsInteractiveArgMenus =
|
||||||
|
typeof (ctx.app as { action?: unknown }).action === "function";
|
||||||
|
|
||||||
const slashCommand = resolveSlackSlashCommandConfig(
|
const slashCommand = resolveSlackSlashCommandConfig(
|
||||||
ctx.slashCommand ?? account.config.slashCommand,
|
ctx.slashCommand ?? account.config.slashCommand,
|
||||||
);
|
);
|
||||||
@@ -45,8 +130,10 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
ack: SlackCommandMiddlewareArgs["ack"];
|
ack: SlackCommandMiddlewareArgs["ack"];
|
||||||
respond: SlackCommandMiddlewareArgs["respond"];
|
respond: SlackCommandMiddlewareArgs["respond"];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
commandArgs?: CommandArgs;
|
||||||
|
commandDefinition?: ChatCommandDefinition;
|
||||||
}) => {
|
}) => {
|
||||||
const { command, ack, respond, prompt } = p;
|
const { command, ack, respond, prompt, commandArgs, commandDefinition } = p;
|
||||||
try {
|
try {
|
||||||
if (!prompt.trim()) {
|
if (!prompt.trim()) {
|
||||||
await ack({
|
await ack({
|
||||||
@@ -183,6 +270,32 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (commandDefinition && supportsInteractiveArgMenus) {
|
||||||
|
const menu = resolveCommandArgMenu({
|
||||||
|
command: commandDefinition,
|
||||||
|
args: commandArgs,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
if (menu) {
|
||||||
|
const commandLabel = commandDefinition.nativeName ?? commandDefinition.key;
|
||||||
|
const title =
|
||||||
|
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
|
||||||
|
const blocks = buildSlackCommandArgMenuBlocks({
|
||||||
|
title,
|
||||||
|
command: commandLabel,
|
||||||
|
arg: menu.arg.name,
|
||||||
|
choices: menu.choices,
|
||||||
|
userId: command.user_id,
|
||||||
|
});
|
||||||
|
await respond({
|
||||||
|
text: title,
|
||||||
|
blocks,
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const channelName = channelInfo?.name;
|
const channelName = channelInfo?.name;
|
||||||
const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`;
|
const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`;
|
||||||
const isRoomish = isRoom || isGroupDm;
|
const isRoomish = isRoom || isGroupDm;
|
||||||
@@ -211,6 +324,7 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
|
|
||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
|
CommandArgs: commandArgs,
|
||||||
From: isDirectMessage
|
From: isDirectMessage
|
||||||
? `slack:${command.user_id}`
|
? `slack:${command.user_id}`
|
||||||
: isRoom
|
: isRoom
|
||||||
@@ -283,8 +397,26 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
ctx.app.command(
|
ctx.app.command(
|
||||||
`/${command.name}`,
|
`/${command.name}`,
|
||||||
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
||||||
const prompt = buildCommandText(command.name, cmd.text);
|
const commandDefinition = findCommandByNativeName(command.name);
|
||||||
await handleSlashCommand({ command: cmd, ack, respond, prompt });
|
const rawText = cmd.text?.trim() ?? "";
|
||||||
|
const commandArgs = commandDefinition
|
||||||
|
? parseCommandArgs(commandDefinition, rawText)
|
||||||
|
: rawText
|
||||||
|
? ({ raw: rawText } satisfies CommandArgs)
|
||||||
|
: undefined;
|
||||||
|
const prompt = commandDefinition
|
||||||
|
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||||
|
: rawText
|
||||||
|
? `/${command.name} ${rawText}`
|
||||||
|
: `/${command.name}`;
|
||||||
|
await handleSlashCommand({
|
||||||
|
command: cmd,
|
||||||
|
ack,
|
||||||
|
respond,
|
||||||
|
prompt,
|
||||||
|
commandArgs,
|
||||||
|
commandDefinition: commandDefinition ?? undefined,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -303,4 +435,71 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
} else {
|
} else {
|
||||||
logVerbose("slack: slash commands disabled");
|
logVerbose("slack: slash commands disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nativeCommands.length === 0 || !supportsInteractiveArgMenus) return;
|
||||||
|
|
||||||
|
(
|
||||||
|
ctx.app as unknown as { action: NonNullable<(typeof ctx.app & { action?: unknown })["action"]> }
|
||||||
|
).action(SLACK_COMMAND_ARG_ACTION_ID, async (args: SlackActionMiddlewareArgs) => {
|
||||||
|
const { ack, body, respond } = args;
|
||||||
|
const action = args.action as { value?: string };
|
||||||
|
await ack();
|
||||||
|
const respondFn =
|
||||||
|
respond ??
|
||||||
|
(async (payload: { text: string; blocks?: SlackBlock[]; response_type?: string }) => {
|
||||||
|
if (!body.channel?.id || !body.user?.id) return;
|
||||||
|
await ctx.app.client.chat.postEphemeral({
|
||||||
|
token: ctx.botToken,
|
||||||
|
channel: body.channel.id,
|
||||||
|
user: body.user.id,
|
||||||
|
text: payload.text,
|
||||||
|
blocks: payload.blocks,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const parsed = parseSlackCommandArgValue(action?.value);
|
||||||
|
if (!parsed) {
|
||||||
|
await respondFn({
|
||||||
|
text: "Sorry, that button is no longer valid.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.user?.id && parsed.userId !== body.user.id) {
|
||||||
|
await respondFn({
|
||||||
|
text: "That menu is for another user.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commandDefinition = findCommandByNativeName(parsed.command);
|
||||||
|
const commandArgs: CommandArgs = {
|
||||||
|
values: { [parsed.arg]: parsed.value },
|
||||||
|
};
|
||||||
|
const prompt = commandDefinition
|
||||||
|
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||||
|
: `/${parsed.command} ${parsed.value}`;
|
||||||
|
const user = body.user;
|
||||||
|
const userName =
|
||||||
|
user && "name" in user && user.name
|
||||||
|
? user.name
|
||||||
|
: user && "username" in user && user.username
|
||||||
|
? user.username
|
||||||
|
: (user?.id ?? "");
|
||||||
|
const triggerId = "trigger_id" in body ? body.trigger_id : undefined;
|
||||||
|
const commandPayload = {
|
||||||
|
user_id: user?.id ?? "",
|
||||||
|
user_name: userName,
|
||||||
|
channel_id: body.channel?.id ?? "",
|
||||||
|
channel_name: body.channel?.name ?? body.channel?.id ?? "",
|
||||||
|
trigger_id: triggerId ?? String(Date.now()),
|
||||||
|
} as SlackCommandMiddlewareArgs["command"];
|
||||||
|
await handleSlashCommand({
|
||||||
|
command: commandPayload,
|
||||||
|
ack: async () => {},
|
||||||
|
respond: respondFn as SlackCommandMiddlewareArgs["respond"],
|
||||||
|
prompt,
|
||||||
|
commandArgs,
|
||||||
|
commandDefinition: commandDefinition ?? undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandTextFromArgs,
|
||||||
|
findCommandByNativeName,
|
||||||
listNativeCommandSpecsForConfig,
|
listNativeCommandSpecsForConfig,
|
||||||
|
parseCommandArgs,
|
||||||
|
resolveCommandArgMenu,
|
||||||
} from "../auto-reply/commands-registry.js";
|
} from "../auto-reply/commands-registry.js";
|
||||||
|
import type { CommandArgs } from "../auto-reply/commands-registry.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { danger, logVerbose } from "../globals.js";
|
import { danger, logVerbose } from "../globals.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
import {
|
import {
|
||||||
buildSenderName,
|
buildSenderName,
|
||||||
buildTelegramGroupFrom,
|
buildTelegramGroupFrom,
|
||||||
@@ -159,7 +164,51 @@ export const registerTelegramNativeCommands = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = buildCommandText(command.name, ctx.match ?? "");
|
const commandDefinition = findCommandByNativeName(command.name);
|
||||||
|
const rawText = ctx.match?.trim() ?? "";
|
||||||
|
const commandArgs = commandDefinition
|
||||||
|
? parseCommandArgs(commandDefinition, rawText)
|
||||||
|
: rawText
|
||||||
|
? ({ raw: rawText } satisfies CommandArgs)
|
||||||
|
: undefined;
|
||||||
|
const prompt = commandDefinition
|
||||||
|
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||||
|
: rawText
|
||||||
|
? `/${command.name} ${rawText}`
|
||||||
|
: `/${command.name}`;
|
||||||
|
const menu = commandDefinition
|
||||||
|
? resolveCommandArgMenu({
|
||||||
|
command: commandDefinition,
|
||||||
|
args: commandArgs,
|
||||||
|
cfg,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (menu) {
|
||||||
|
const title =
|
||||||
|
menu.title ??
|
||||||
|
`Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`;
|
||||||
|
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
|
||||||
|
for (let i = 0; i < menu.choices.length; i += 2) {
|
||||||
|
const slice = menu.choices.slice(i, i + 2);
|
||||||
|
rows.push(
|
||||||
|
slice.map((choice) => {
|
||||||
|
const args: CommandArgs = {
|
||||||
|
values: { [menu.arg.name]: choice },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
text: choice,
|
||||||
|
callback_data: buildCommandTextFromArgs(commandDefinition, args),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const replyMarkup = buildInlineKeyboard(rows);
|
||||||
|
await bot.api.sendMessage(chatId, title, {
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -178,6 +227,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
|
CommandArgs: commandArgs,
|
||||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||||
To: `slash:${senderId || chatId}`,
|
To: `slash:${senderId || chatId}`,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
|
|||||||
Reference in New Issue
Block a user