diff --git a/docs/cli/message.md b/docs/cli/message.md index f7b7ff5a6..12b00c1da 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -46,7 +46,7 @@ Target formats (`--to`): - Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Required: `--to`, `--message` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - - Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts..capabilities`) + - Telegram only: `--buttons` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts..capabilities`) - Telegram only: `--thread-id` (forum topic id) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - WhatsApp only: `--gif-playback` @@ -206,5 +206,5 @@ clawdbot message react --provider slack \ Send Telegram inline buttons: ``` clawdbot message send --provider telegram --to @mychat --message "Choose:" \ - --buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' + --buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' ``` diff --git a/src/agents/tools/discord-schema.ts b/src/agents/tools/discord-schema.ts deleted file mode 100644 index 8d2b48c89..000000000 --- a/src/agents/tools/discord-schema.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -import { createReactionSchema } from "./reaction-schema.js"; - -export const DiscordToolSchema = Type.Union([ - createReactionSchema({ - ids: { - channelId: Type.String(), - messageId: Type.String(), - }, - includeRemove: true, - }), - Type.Object({ - action: Type.Literal("reactions"), - channelId: Type.String(), - messageId: Type.String(), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("sticker"), - to: Type.String(), - stickerIds: Type.Array(Type.String()), - content: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("poll"), - to: Type.String(), - question: Type.String(), - answers: Type.Array(Type.String()), - allowMultiselect: Type.Optional(Type.Boolean()), - durationHours: Type.Optional(Type.Number()), - content: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("permissions"), - channelId: Type.String(), - }), - Type.Union([ - Type.Object({ - action: Type.Literal("fetchMessage"), - messageLink: Type.String(), - guildId: Type.Optional(Type.String()), - channelId: Type.Optional(Type.String()), - messageId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("fetchMessage"), - guildId: Type.String(), - channelId: Type.String(), - messageId: Type.String(), - }), - ]), - Type.Object({ - action: Type.Literal("readMessages"), - channelId: Type.String(), - limit: Type.Optional(Type.Number()), - before: Type.Optional(Type.String()), - after: Type.Optional(Type.String()), - around: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("sendMessage"), - to: Type.String(), - content: Type.String(), - mediaUrl: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("editMessage"), - channelId: Type.String(), - messageId: Type.String(), - content: Type.String(), - }), - Type.Object({ - action: Type.Literal("deleteMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("threadCreate"), - channelId: Type.String(), - name: Type.String(), - messageId: Type.Optional(Type.String()), - autoArchiveMinutes: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("threadList"), - guildId: Type.String(), - channelId: Type.Optional(Type.String()), - includeArchived: Type.Optional(Type.Boolean()), - before: Type.Optional(Type.String()), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("threadReply"), - channelId: Type.String(), - content: Type.String(), - mediaUrl: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("pinMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("unpinMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("listPins"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("searchMessages"), - guildId: Type.String(), - content: Type.String(), - channelId: Type.Optional(Type.String()), - channelIds: Type.Optional(Type.Array(Type.String())), - authorId: Type.Optional(Type.String()), - authorIds: Type.Optional(Type.Array(Type.String())), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("memberInfo"), - guildId: Type.String(), - userId: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleInfo"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("emojiList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("emojiUpload"), - guildId: Type.String(), - name: Type.String(), - mediaUrl: Type.String(), - roleIds: Type.Optional(Type.Array(Type.String())), - }), - Type.Object({ - action: Type.Literal("stickerUpload"), - guildId: Type.String(), - name: Type.String(), - description: Type.String(), - tags: Type.String(), - mediaUrl: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleAdd"), - guildId: Type.String(), - userId: Type.String(), - roleId: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleRemove"), - guildId: Type.String(), - userId: Type.String(), - roleId: Type.String(), - }), - Type.Object({ - action: Type.Literal("channelInfo"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("channelList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("voiceStatus"), - guildId: Type.String(), - userId: Type.String(), - }), - Type.Object({ - action: Type.Literal("eventList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("eventCreate"), - guildId: Type.String(), - name: Type.String(), - startTime: Type.String(), - endTime: Type.Optional(Type.String()), - description: Type.Optional(Type.String()), - channelId: Type.Optional(Type.String()), - entityType: Type.Optional( - Type.Union([ - Type.Literal("voice"), - Type.Literal("stage"), - Type.Literal("external"), - ]), - ), - location: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("timeout"), - guildId: Type.String(), - userId: Type.String(), - durationMinutes: Type.Optional(Type.Number()), - until: Type.Optional(Type.String()), - reason: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("kick"), - guildId: Type.String(), - userId: Type.String(), - reason: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("ban"), - guildId: Type.String(), - userId: Type.String(), - reason: Type.Optional(Type.String()), - deleteMessageDays: Type.Optional(Type.Number()), - }), - // Channel management actions - Type.Object({ - action: Type.Literal("channelCreate"), - guildId: Type.String(), - name: Type.String(), - type: Type.Optional(Type.Number()), - parentId: Type.Optional(Type.String()), - topic: Type.Optional(Type.String()), - position: Type.Optional(Type.Number()), - nsfw: Type.Optional(Type.Boolean()), - }), - Type.Object({ - action: Type.Literal("channelEdit"), - channelId: Type.String(), - name: Type.Optional(Type.String()), - topic: Type.Optional(Type.String()), - position: Type.Optional(Type.Number()), - parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), - nsfw: Type.Optional(Type.Boolean()), - rateLimitPerUser: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("channelDelete"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("channelMove"), - guildId: Type.String(), - channelId: Type.String(), - parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), - position: Type.Optional(Type.Number()), - }), - // Category management actions (convenience aliases) - Type.Object({ - action: Type.Literal("categoryCreate"), - guildId: Type.String(), - name: Type.String(), - position: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("categoryEdit"), - categoryId: Type.String(), - name: Type.Optional(Type.String()), - position: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("categoryDelete"), - categoryId: Type.String(), - }), - // Permission overwrite actions - Type.Object({ - action: Type.Literal("channelPermissionSet"), - channelId: Type.String(), - targetId: Type.String(), - targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]), - allow: Type.Optional(Type.String()), - deny: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("channelPermissionRemove"), - channelId: Type.String(), - targetId: Type.String(), - }), -]); diff --git a/src/agents/tools/discord-tool.ts b/src/agents/tools/discord-tool.ts deleted file mode 100644 index ae96e4d81..000000000 --- a/src/agents/tools/discord-tool.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { loadConfig } from "../../config/config.js"; -import type { AnyAgentTool } from "./common.js"; -import { handleDiscordAction } from "./discord-actions.js"; -import { DiscordToolSchema } from "./discord-schema.js"; - -export function createDiscordTool(): AnyAgentTool { - return { - label: "Discord", - name: "discord", - description: "Manage Discord messages, reactions, and moderation.", - parameters: DiscordToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const cfg = loadConfig(); - return await handleDiscordAction(params, cfg); - }, - }; -} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d79d487a4..be1f9f56d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,74 +1,25 @@ import { Type } from "@sinclair/typebox"; -import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { runMessageAction } from "../../infra/outbound/message-action-runner.js"; import { - type MessagePollResult, - type MessageSendResult, - sendMessage, - sendPoll, -} from "../../infra/outbound/message.js"; -import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js"; -import { - dispatchProviderMessageAction, listProviderMessageActions, supportsProviderMessageButtons, } from "../../providers/plugins/message-actions.js"; -import type { ProviderMessageActionName } from "../../providers/plugins/types.js"; +import { + PROVIDER_MESSAGE_ACTION_NAMES, + type ProviderMessageActionName, +} from "../../providers/plugins/types.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../utils/message-provider.js"; import type { AnyAgentTool } from "./common.js"; -import { - jsonResult, - readNumberParam, - readStringArrayParam, - readStringParam, -} from "./common.js"; +import { jsonResult, readNumberParam, readStringParam } from "./common.js"; -const AllMessageActions = [ - "send", - "poll", - "react", - "reactions", - "read", - "edit", - "delete", - "pin", - "unpin", - "list-pins", - "permissions", - "thread-create", - "thread-list", - "thread-reply", - "search", - "sticker", - "member-info", - "role-info", - "emoji-list", - "emoji-upload", - "sticker-upload", - "role-add", - "role-remove", - "channel-info", - "channel-list", - "channel-create", - "channel-edit", - "channel-delete", - "channel-move", - "category-create", - "category-edit", - "category-delete", - "voice-status", - "event-list", - "event-create", - "timeout", - "kick", - "ban", -]; +const AllMessageActions = PROVIDER_MESSAGE_ACTION_NAMES; const MessageToolCommonSchema = { provider: Type.Optional(Type.String()), @@ -148,7 +99,7 @@ const MessageToolCommonSchema = { }; function buildMessageToolSchemaFromActions( - actions: string[], + actions: readonly string[], options: { includeButtons: boolean }, ) { const props: Record = { ...MessageToolCommonSchema }; @@ -227,14 +178,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const action = readStringParam(params, "action", { required: true, }) as ProviderMessageActionName; - - const providerSelection = await resolveMessageProviderSelection({ - cfg, - provider: readStringParam(params, "provider"), - }); - const provider = providerSelection.provider; const accountId = readStringParam(params, "accountId") ?? agentAccountId; - const dryRun = Boolean(params.dryRun); const gateway = { url: readStringParam(params, "gatewayUrl", { trim: false }), @@ -258,144 +202,17 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } : undefined; - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - let message = readStringParam(params, "message", { - required: true, - allowEmpty: true, - }); - - // Let send accept the same inline directives we use elsewhere. - // Provider plugins consume `replyTo` / `media` / `buttons` from params. - const parsed = parseReplyDirectives(message); - message = parsed.text; - params.message = message; - if (!params.replyTo && parsed.replyToId) - params.replyTo = parsed.replyToId; - if (!params.media) { - params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined; - } - - const mediaUrl = readStringParam(params, "media", { trim: false }); - const gifPlayback = - typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; - const bestEffort = - typeof params.bestEffort === "boolean" - ? params.bestEffort - : undefined; - - if (dryRun) { - const result: MessageSendResult = await sendMessage({ - to, - content: message, - mediaUrl: mediaUrl || undefined, - provider: provider || undefined, - accountId: accountId ?? undefined, - gifPlayback, - dryRun, - bestEffort, - gateway, - }); - return jsonResult(result); - } - - const handled = await dispatchProviderMessageAction({ - provider, - action, - cfg, - params, - accountId, - gateway, - toolContext, - dryRun, - }); - if (handled) return handled; - - const result: MessageSendResult = await sendMessage({ - to, - content: message, - mediaUrl: mediaUrl || undefined, - provider: provider || undefined, - accountId: accountId ?? undefined, - gifPlayback, - dryRun, - bestEffort, - gateway, - }); - return jsonResult(result); - } - - if (action === "poll") { - const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "pollQuestion", { - required: true, - }); - const options = - readStringArrayParam(params, "pollOption", { required: true }) ?? []; - const allowMultiselect = - typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; - const durationHours = readNumberParam(params, "pollDurationHours", { - integer: true, - }); - - const maxSelections = allowMultiselect - ? Math.max(2, options.length) - : 1; - - if (dryRun) { - const result: MessagePollResult = await sendPoll({ - to, - question, - options, - maxSelections, - durationHours: durationHours ?? undefined, - provider, - dryRun, - gateway, - }); - return jsonResult(result); - } - - const handled = await dispatchProviderMessageAction({ - provider, - action, - cfg, - params, - accountId, - gateway, - toolContext, - dryRun, - }); - if (handled) return handled; - - const result: MessagePollResult = await sendPoll({ - to, - question, - options, - maxSelections, - durationHours: durationHours ?? undefined, - provider, - dryRun, - gateway, - }); - return jsonResult(result); - } - - const handled = await dispatchProviderMessageAction({ - provider, - action, + const result = await runMessageAction({ cfg, + action, params, - accountId, + defaultAccountId: accountId ?? undefined, gateway, toolContext, - dryRun, }); - if (handled) return handled; - throw new Error( - `Message action ${action} not supported for provider ${provider}.`, - ); + if (result.toolResult) return result.toolResult; + return jsonResult(result.payload); }, }; } diff --git a/src/agents/tools/reaction-schema.ts b/src/agents/tools/reaction-schema.ts deleted file mode 100644 index f33031147..000000000 --- a/src/agents/tools/reaction-schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type TSchema, Type } from "@sinclair/typebox"; - -type ReactionSchemaOptions = { - action?: string; - ids: Record; - emoji?: TSchema; - includeRemove?: boolean; - extras?: Record; -}; - -export function createReactionSchema(options: ReactionSchemaOptions) { - const schema: Record = { - action: Type.Literal(options.action ?? "react"), - ...options.ids, - emoji: options.emoji ?? Type.String(), - }; - if (options.includeRemove) { - schema.remove = Type.Optional(Type.Boolean()); - } - if (options.extras) { - Object.assign(schema, options.extras); - } - return Type.Object(schema); -} diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts deleted file mode 100644 index 25ac504c6..000000000 --- a/src/agents/tools/slack-schema.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -import { createReactionSchema } from "./reaction-schema.js"; - -export const SlackToolSchema = Type.Union([ - createReactionSchema({ - ids: { - channelId: Type.String(), - messageId: Type.String(), - }, - includeRemove: true, - extras: { - accountId: Type.Optional(Type.String()), - }, - }), - Type.Object({ - action: Type.Literal("reactions"), - channelId: Type.String(), - messageId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("sendMessage"), - to: Type.String(), - content: Type.String(), - mediaUrl: Type.Optional(Type.String()), - threadTs: Type.Optional(Type.String()), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("editMessage"), - channelId: Type.String(), - messageId: Type.String(), - content: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("deleteMessage"), - channelId: Type.String(), - messageId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("readMessages"), - channelId: Type.String(), - limit: Type.Optional(Type.Number()), - before: Type.Optional(Type.String()), - after: Type.Optional(Type.String()), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("pinMessage"), - channelId: Type.String(), - messageId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("unpinMessage"), - channelId: Type.String(), - messageId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("listPins"), - channelId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("memberInfo"), - userId: Type.String(), - accountId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("emojiList"), - accountId: Type.Optional(Type.String()), - }), -]); diff --git a/src/agents/tools/slack-tool.test.ts b/src/agents/tools/slack-tool.test.ts deleted file mode 100644 index d0213a4c5..000000000 --- a/src/agents/tools/slack-tool.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const handleSlackActionMock = vi.fn(); - -vi.mock("./slack-actions.js", () => ({ - handleSlackAction: (params: unknown, cfg: unknown) => - handleSlackActionMock(params, cfg), -})); - -import { createSlackTool } from "./slack-tool.js"; - -describe("slack tool", () => { - beforeEach(() => { - handleSlackActionMock.mockReset(); - handleSlackActionMock.mockResolvedValue({ - content: [], - details: { ok: true }, - }); - }); - - it("injects agentAccountId when accountId is missing", async () => { - const tool = createSlackTool({ - agentAccountId: " Kev ", - config: { slack: { accounts: { kev: {} } } }, - }); - - await tool.execute("call-1", { - action: "sendMessage", - to: "channel:C1", - content: "hello", - }); - - expect(handleSlackActionMock).toHaveBeenCalledTimes(1); - const [params] = handleSlackActionMock.mock.calls[0] ?? []; - expect(params).toMatchObject({ accountId: "kev" }); - }); - - it("keeps explicit accountId when provided", async () => { - const tool = createSlackTool({ - agentAccountId: "kev", - config: {}, - }); - - await tool.execute("call-2", { - action: "sendMessage", - to: "channel:C1", - content: "hello", - accountId: "rex", - }); - - expect(handleSlackActionMock).toHaveBeenCalledTimes(1); - const [params] = handleSlackActionMock.mock.calls[0] ?? []; - expect(params).toMatchObject({ accountId: "rex" }); - }); - - it("does not inject accountId when agentAccountId is missing", async () => { - const tool = createSlackTool({ config: {} }); - - await tool.execute("call-3", { - action: "sendMessage", - to: "channel:C1", - content: "hello", - }); - - expect(handleSlackActionMock).toHaveBeenCalledTimes(1); - const [params] = handleSlackActionMock.mock.calls[0] ?? []; - expect(params).not.toHaveProperty("accountId"); - }); - - it("does not inject unknown agentAccountId when not configured", async () => { - const tool = createSlackTool({ - agentAccountId: "unknown", - config: { slack: { accounts: { kev: {} } } }, - }); - - await tool.execute("call-4", { - action: "sendMessage", - to: "channel:C1", - content: "hello", - }); - - const [params] = handleSlackActionMock.mock.calls[0] ?? []; - expect(params).not.toHaveProperty("accountId"); - }); -}); diff --git a/src/agents/tools/slack-tool.ts b/src/agents/tools/slack-tool.ts deleted file mode 100644 index afac983b1..000000000 --- a/src/agents/tools/slack-tool.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ClawdbotConfig } from "../../config/config.js"; -import { loadConfig } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; -import type { AnyAgentTool } from "./common.js"; -import { handleSlackAction } from "./slack-actions.js"; -import { SlackToolSchema } from "./slack-schema.js"; - -type SlackToolOptions = { - agentAccountId?: string; - config?: ClawdbotConfig; - /** Current channel ID for auto-threading. */ - currentChannelId?: string; - /** Current thread timestamp for auto-threading. */ - currentThreadTs?: string; - /** Reply-to mode for auto-threading. */ - replyToMode?: "off" | "first" | "all"; - /** Mutable ref to track if a reply was sent (for "first" mode). */ - hasRepliedRef?: { value: boolean }; -}; - -function resolveAgentAccountId(value?: string): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - return normalizeAccountId(trimmed); -} - -function resolveConfiguredAccountId( - cfg: ClawdbotConfig, - accountId: string, -): string | undefined { - if (accountId === "default") return accountId; - const accounts = cfg.slack?.accounts; - if (!accounts || typeof accounts !== "object") return undefined; - if (accountId in accounts) return accountId; - const match = Object.keys(accounts).find( - (key) => key.toLowerCase() === accountId.toLowerCase(), - ); - return match; -} - -function hasAccountId(params: Record): boolean { - const raw = params.accountId; - if (typeof raw !== "string") return false; - return raw.trim().length > 0; -} - -export function createSlackTool(options?: SlackToolOptions): AnyAgentTool { - const agentAccountId = resolveAgentAccountId(options?.agentAccountId); - return { - label: "Slack", - name: "slack", - description: "Manage Slack messages, reactions, and pins.", - parameters: SlackToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const cfg = options?.config ?? loadConfig(); - const resolvedAccountId = agentAccountId - ? resolveConfiguredAccountId(cfg, agentAccountId) - : undefined; - const resolvedParams = - resolvedAccountId && !hasAccountId(params) - ? { ...params, accountId: resolvedAccountId } - : params; - if (hasAccountId(resolvedParams)) { - const action = - typeof params.action === "string" ? params.action : "unknown"; - logVerbose( - `slack tool: action=${action} accountId=${String( - resolvedParams.accountId, - ).trim()}`, - ); - } - return await handleSlackAction(resolvedParams, cfg, { - currentChannelId: options?.currentChannelId, - currentThreadTs: options?.currentThreadTs, - replyToMode: options?.replyToMode, - hasRepliedRef: options?.hasRepliedRef, - }); - }, - }; -} diff --git a/src/agents/tools/telegram-schema.ts b/src/agents/tools/telegram-schema.ts deleted file mode 100644 index 828dd5172..000000000 --- a/src/agents/tools/telegram-schema.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -import { createReactionSchema } from "./reaction-schema.js"; - -// NOTE: chatId and messageId use Type.String() instead of Type.Union([Type.String(), Type.Number()]) -// because nested anyOf schemas cause JSON Schema validation failures with Claude API on Vertex AI. -// Telegram IDs are coerced to strings at runtime in telegram-actions.ts. -export const TelegramToolSchema = Type.Union([ - createReactionSchema({ - ids: { - chatId: Type.String(), - messageId: Type.String(), - }, - includeRemove: true, - }), - Type.Object({ - action: Type.Literal("sendMessage"), - to: Type.String({ description: "Chat ID, @username, or t.me/username" }), - content: Type.String({ description: "Message text to send" }), - mediaUrl: Type.Optional( - Type.String({ description: "URL of image/video/audio to attach" }), - ), - replyToMessageId: Type.Optional( - Type.Union([Type.String(), Type.Number()], { - description: "Message ID to reply to (for threading)", - }), - ), - messageThreadId: Type.Optional( - Type.Union([Type.String(), Type.Number()], { - description: "Forum topic thread ID (for forum supergroups)", - }), - ), - }), -]); diff --git a/src/agents/tools/telegram-tool.ts b/src/agents/tools/telegram-tool.ts deleted file mode 100644 index 38b5b6ce8..000000000 --- a/src/agents/tools/telegram-tool.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { loadConfig } from "../../config/config.js"; -import type { AnyAgentTool } from "./common.js"; -import { handleTelegramAction } from "./telegram-actions.js"; -import { TelegramToolSchema } from "./telegram-schema.js"; - -export function createTelegramTool(): AnyAgentTool { - return { - label: "Telegram", - name: "telegram", - description: "Send messages and manage reactions on Telegram.", - parameters: TelegramToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const cfg = loadConfig(); - return await handleTelegramAction(params, cfg); - }, - }; -} diff --git a/src/agents/tools/whatsapp-schema.ts b/src/agents/tools/whatsapp-schema.ts deleted file mode 100644 index 3c8498785..000000000 --- a/src/agents/tools/whatsapp-schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -import { createReactionSchema } from "./reaction-schema.js"; - -export const WhatsAppToolSchema = Type.Union([ - createReactionSchema({ - ids: { - chatJid: Type.String(), - messageId: Type.String(), - }, - includeRemove: true, - extras: { - participant: Type.Optional(Type.String()), - accountId: Type.Optional(Type.String()), - fromMe: Type.Optional(Type.Boolean()), - }, - }), -]); diff --git a/src/agents/tools/whatsapp-tool.ts b/src/agents/tools/whatsapp-tool.ts deleted file mode 100644 index 77ce97d0d..000000000 --- a/src/agents/tools/whatsapp-tool.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { loadConfig } from "../../config/config.js"; -import type { AnyAgentTool } from "./common.js"; -import { handleWhatsAppAction } from "./whatsapp-actions.js"; -import { WhatsAppToolSchema } from "./whatsapp-schema.js"; - -export function createWhatsAppTool(): AnyAgentTool { - return { - label: "WhatsApp", - name: "whatsapp", - description: "Manage WhatsApp reactions.", - parameters: WhatsAppToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const cfg = loadConfig(); - return await handleWhatsAppAction(params, cfg); - }, - }; -} diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index dbb6ff2df..5d90ad9b1 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -28,7 +28,7 @@ type ModelSelectionState = { allowedModelKeys: Set; allowedModelCatalog: ModelCatalog; resetModelOverride: boolean; - resolveDefaultThinkingLevel: () => Promise; + resolveDefaultThinkingLevel: () => Promise; needsModelCatalog: boolean; }; @@ -252,12 +252,16 @@ export async function createModelSelectionState(params: { modelCatalog = await loadModelCatalog({ config: cfg }); catalogForThinking = modelCatalog; } - defaultThinkingLevel = resolveThinkingDefault({ + const resolved = resolveThinkingDefault({ cfg, provider, model, catalog: catalogForThinking, }); + defaultThinkingLevel = + resolved ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? + "off"; return defaultThinkingLevel; }; diff --git a/src/cli/program.ts b/src/cli/program.ts index c83f5203a..6e9df3218 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -611,7 +611,10 @@ Examples: clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅" -${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}`, +${theme.muted("Docs:")} ${formatDocsLink( + "/cli/message", + "docs.clawd.bot/cli/message", + )}`, ) .action(() => { message.help({ error: true }); @@ -620,7 +623,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}` const withMessageBase = (command: Command) => command .option("--provider ", `Provider: ${messageProviderOptions}`) - .option("--account ", "Provider account id") + .option("--account ", "Provider account id (accountId)") .option("--json", "Output result as JSON", false) .option("--dry-run", "Print payload and skip sending", false) .option("--verbose", "Verbose logging", false); @@ -645,15 +648,20 @@ ${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}` try { await messageCommand( { - ...opts, + ...(() => { + const { account, ...rest } = opts; + return { + ...rest, + accountId: typeof account === "string" ? account : undefined, + }; + })(), action, - account: opts.account as string | undefined, }, deps, defaultRuntime, ); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }; @@ -670,7 +678,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}` "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) .option( - "--buttons-json ", + "--buttons ", "Telegram inline keyboard buttons as JSON (array of button rows)", ) .option("--reply-to ", "Reply-to message id") diff --git a/src/commands/message-format.ts b/src/commands/message-format.ts new file mode 100644 index 000000000..e3014d294 --- /dev/null +++ b/src/commands/message-format.ts @@ -0,0 +1,385 @@ +import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; +import { + formatGatewaySummary, + formatOutboundDeliverySummary, +} from "../infra/outbound/format.js"; +import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js"; +import { getProviderPlugin } from "../providers/plugins/index.js"; +import type { + ProviderId, + ProviderMessageActionName, +} from "../providers/plugins/types.js"; +import { renderTable } from "../terminal/table.js"; +import { isRich, theme } from "../terminal/theme.js"; + +const shortenText = (value: string, maxLen: number) => { + const chars = Array.from(value); + if (chars.length <= maxLen) return value; + return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`; +}; + +const resolveProviderLabel = (provider: ProviderId) => + getProviderPlugin(provider)?.meta.label ?? provider; + +function extractMessageId(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const direct = (payload as { messageId?: unknown }).messageId; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const result = (payload as { result?: unknown }).result; + if (result && typeof result === "object") { + const nested = (result as { messageId?: unknown }).messageId; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + } + return null; +} + +export type MessageCliJsonEnvelope = { + action: ProviderMessageActionName; + provider: ProviderId; + dryRun: boolean; + handledBy: "plugin" | "core" | "dry-run"; + payload: unknown; +}; + +export function buildMessageCliJson( + result: MessageActionRunResult, +): MessageCliJsonEnvelope { + return { + action: result.action, + provider: result.provider, + dryRun: result.dryRun, + handledBy: result.handledBy, + payload: result.payload, + }; +} + +type FormatOpts = { + width: number; +}; + +function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] { + if (!payload || typeof payload !== "object") { + return [String(payload)]; + } + const obj = payload as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return [theme.muted("(empty)")]; + + const rows = keys.slice(0, 20).map((k) => { + const v = obj[k]; + const value = + v == null + ? "null" + : Array.isArray(v) + ? `${v.length} items` + : typeof v === "object" + ? "object" + : typeof v === "string" + ? v + : typeof v === "number" + ? String(v) + : typeof v === "boolean" + ? v + ? "true" + : "false" + : typeof v === "bigint" + ? v.toString() + : typeof v === "symbol" + ? v.toString() + : typeof v === "function" + ? "function" + : "unknown"; + return { Key: k, Value: shortenText(value, 96) }; + }); + return [ + renderTable({ + width: opts.width, + columns: [ + { key: "Key", header: "Key", minWidth: 16 }, + { key: "Value", header: "Value", flex: true, minWidth: 24 }, + ], + rows, + }).trimEnd(), + ]; +} + +function renderMessageList( + messages: unknown[], + opts: FormatOpts, + emptyLabel: string, +): string[] { + const rows = messages.slice(0, 25).map((m) => { + const msg = m as Record; + const id = + (typeof msg.id === "string" && msg.id) || + (typeof msg.ts === "string" && msg.ts) || + (typeof msg.messageId === "string" && msg.messageId) || + ""; + const authorObj = msg.author as Record | undefined; + const author = + (typeof msg.authorTag === "string" && msg.authorTag) || + (typeof authorObj?.username === "string" && authorObj.username) || + (typeof msg.user === "string" && msg.user) || + ""; + const time = + (typeof msg.timestamp === "string" && msg.timestamp) || + (typeof msg.ts === "string" && msg.ts) || + ""; + const text = + (typeof msg.content === "string" && msg.content) || + (typeof msg.text === "string" && msg.text) || + ""; + return { + Time: shortenText(time, 28), + Author: shortenText(author, 22), + Text: shortenText(text.replace(/\s+/g, " ").trim(), 90), + Id: shortenText(id, 22), + }; + }); + + if (rows.length === 0) { + return [theme.muted(emptyLabel)]; + } + + return [ + renderTable({ + width: opts.width, + columns: [ + { key: "Time", header: "Time", minWidth: 14 }, + { key: "Author", header: "Author", minWidth: 10 }, + { key: "Text", header: "Text", flex: true, minWidth: 24 }, + { key: "Id", header: "Id", minWidth: 10 }, + ], + rows, + }).trimEnd(), + ]; +} + +function renderMessagesFromPayload( + payload: unknown, + opts: FormatOpts, +): string[] | null { + if (!payload || typeof payload !== "object") return null; + const messages = (payload as { messages?: unknown }).messages; + if (!Array.isArray(messages)) return null; + return renderMessageList(messages, opts, "No messages."); +} + +function renderPinsFromPayload( + payload: unknown, + opts: FormatOpts, +): string[] | null { + if (!payload || typeof payload !== "object") return null; + const pins = (payload as { pins?: unknown }).pins; + if (!Array.isArray(pins)) return null; + return renderMessageList(pins, opts, "No pins."); +} + +function extractDiscordSearchResultsMessages( + results: unknown, +): unknown[] | null { + if (!results || typeof results !== "object") return null; + const raw = (results as { messages?: unknown }).messages; + if (!Array.isArray(raw)) return null; + // Discord search returns messages as array-of-array; first element is the message. + const flattened: unknown[] = []; + for (const entry of raw) { + if (Array.isArray(entry) && entry.length > 0) { + flattened.push(entry[0]); + } else if (entry && typeof entry === "object") { + flattened.push(entry); + } + } + return flattened.length ? flattened : null; +} + +function renderReactions(payload: unknown, opts: FormatOpts): string[] | null { + if (!payload || typeof payload !== "object") return null; + const reactions = (payload as { reactions?: unknown }).reactions; + if (!Array.isArray(reactions)) return null; + + const rows = reactions.slice(0, 50).map((r) => { + const entry = r as Record; + const emojiObj = entry.emoji as Record | undefined; + const emoji = + (typeof emojiObj?.raw === "string" && emojiObj.raw) || + (typeof entry.name === "string" && entry.name) || + (typeof entry.emoji === "string" && (entry.emoji as string)) || + ""; + const count = typeof entry.count === "number" ? String(entry.count) : ""; + const userList = Array.isArray(entry.users) + ? (entry.users as unknown[]) + .slice(0, 8) + .map((u) => { + if (typeof u === "string") return u; + if (!u || typeof u !== "object") return ""; + const user = u as Record; + return ( + (typeof user.tag === "string" && user.tag) || + (typeof user.username === "string" && user.username) || + (typeof user.id === "string" && user.id) || + "" + ); + }) + .filter(Boolean) + : []; + return { + Emoji: emoji, + Count: count, + Users: shortenText(userList.join(", "), 72), + }; + }); + + if (rows.length === 0) return [theme.muted("No reactions.")]; + + return [ + renderTable({ + width: opts.width, + columns: [ + { key: "Emoji", header: "Emoji", minWidth: 8 }, + { key: "Count", header: "Count", align: "right", minWidth: 6 }, + { key: "Users", header: "Users", flex: true, minWidth: 20 }, + ], + rows, + }).trimEnd(), + ]; +} + +export function formatMessageCliText(result: MessageActionRunResult): string[] { + const rich = isRich(); + const ok = (text: string) => (rich ? theme.success(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + const heading = (text: string) => (rich ? theme.heading(text) : text); + + const width = Math.max(60, (process.stdout.columns ?? 120) - 1); + const opts: FormatOpts = { width }; + + if (result.handledBy === "dry-run") { + return [ + muted(`[dry-run] would run ${result.action} via ${result.provider}`), + ]; + } + + if (result.kind === "send") { + if (result.handledBy === "core" && result.sendResult) { + const send = result.sendResult; + if (send.via === "direct") { + const directResult = send.result as OutboundDeliveryResult | undefined; + return [ok(formatOutboundDeliverySummary(send.provider, directResult))]; + } + const gatewayResult = send.result as { messageId?: string } | undefined; + return [ + ok( + formatGatewaySummary({ + provider: send.provider, + messageId: gatewayResult?.messageId ?? null, + }), + ), + ]; + } + + const label = resolveProviderLabel(result.provider); + const msgId = extractMessageId(result.payload); + return [ok(`✅ Sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`)]; + } + + if (result.kind === "poll") { + if (result.handledBy === "core" && result.pollResult) { + const poll = result.pollResult; + const pollId = (poll.result as { pollId?: string } | undefined)?.pollId; + const msgId = poll.result?.messageId ?? null; + const lines = [ + ok( + formatGatewaySummary({ + action: "Poll sent", + provider: poll.provider, + messageId: msgId, + }), + ), + ]; + if (pollId) lines.push(ok(`Poll id: ${pollId}`)); + return lines; + } + + const label = resolveProviderLabel(result.provider); + const msgId = extractMessageId(result.payload); + return [ + ok(`✅ Poll sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`), + ]; + } + + // provider actions (non-send/poll) + const payload = result.payload; + const lines: string[] = []; + + if (result.action === "react") { + const added = (payload as { added?: unknown }).added; + const removed = (payload as { removed?: unknown }).removed; + if (typeof added === "string" && added.trim()) { + lines.push(ok(`✅ Reaction added: ${added.trim()}`)); + return lines; + } + if (typeof removed === "string" && removed.trim()) { + lines.push(ok(`✅ Reaction removed: ${removed.trim()}`)); + return lines; + } + if (Array.isArray(removed)) { + const list = removed + .map((x) => String(x).trim()) + .filter(Boolean) + .join(", "); + lines.push(ok(`✅ Reactions removed${list ? `: ${list}` : ""}`)); + return lines; + } + lines.push(ok("✅ Reaction updated.")); + return lines; + } + + const reactionsTable = renderReactions(payload, opts); + if (reactionsTable && result.action === "reactions") { + lines.push(heading("Reactions")); + lines.push(reactionsTable[0] ?? ""); + return lines; + } + + if (result.action === "read") { + const messagesTable = renderMessagesFromPayload(payload, opts); + if (messagesTable) { + lines.push(heading("Messages")); + lines.push(messagesTable[0] ?? ""); + return lines; + } + } + + if (result.action === "list-pins") { + const pinsTable = renderPinsFromPayload(payload, opts); + if (pinsTable) { + lines.push(heading("Pinned messages")); + lines.push(pinsTable[0] ?? ""); + return lines; + } + } + + if (result.action === "search") { + const results = (payload as { results?: unknown }).results; + const list = extractDiscordSearchResultsMessages(results); + if (list) { + lines.push(heading("Search results")); + lines.push(renderMessageList(list, opts, "No results.")[0] ?? ""); + return lines; + } + } + + // Generic success + compact details table. + lines.push( + ok(`✅ ${result.action} via ${resolveProviderLabel(result.provider)}.`), + ); + const summary = renderObjectSummary(payload, opts); + if (summary.length) { + lines.push(""); + lines.push(...summary); + lines.push(""); + lines.push(muted("Tip: use --json for full output.")); + } + return lines; +} diff --git a/src/commands/message.ts b/src/commands/message.ts index be91c5213..9970d4927 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -1,286 +1,32 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { CliDeps } from "../cli/deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; -import { success } from "../globals.js"; -import type { - OutboundDeliveryResult, - OutboundSendDeps, -} from "../infra/outbound/deliver.js"; -import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; +import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; +import { runMessageAction } from "../infra/outbound/message-action-runner.js"; import { - buildOutboundDeliveryJson, - formatGatewaySummary, - formatOutboundDeliverySummary, -} from "../infra/outbound/format.js"; -import { - type MessagePollResult, - type MessageSendResult, - sendMessage, - sendPoll, -} from "../infra/outbound/message.js"; -import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js"; -import { dispatchProviderMessageAction } from "../providers/plugins/message-actions.js"; -import type { ProviderMessageActionName } from "../providers/plugins/types.js"; + PROVIDER_MESSAGE_ACTION_NAMES, + type ProviderMessageActionName, +} from "../providers/plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../utils/message-provider.js"; - -type MessageAction = - | "send" - | "poll" - | "react" - | "reactions" - | "read" - | "edit" - | "delete" - | "pin" - | "unpin" - | "list-pins" - | "permissions" - | "thread-create" - | "thread-list" - | "thread-reply" - | "search" - | "sticker" - | "member-info" - | "role-info" - | "emoji-list" - | "emoji-upload" - | "sticker-upload" - | "role-add" - | "role-remove" - | "channel-info" - | "channel-list" - | "voice-status" - | "event-list" - | "event-create" - | "timeout" - | "kick" - | "ban"; - -type MessageCommandOpts = { - action?: string; - provider?: string; - to?: string; - message?: string; - media?: string; - buttonsJson?: string; - messageId?: string; - replyTo?: string; - threadId?: string; - account?: string; - emoji?: string; - remove?: boolean; - limit?: string; - before?: string; - after?: string; - around?: string; - pollQuestion?: string; - pollOption?: string[] | string; - pollDurationHours?: string; - pollMulti?: boolean; - channelId?: string; - channelIds?: string[] | string; - guildId?: string; - userId?: string; - authorId?: string; - authorIds?: string[] | string; - roleId?: string; - roleIds?: string[] | string; - emojiName?: string; - stickerId?: string[] | string; - stickerName?: string; - stickerDesc?: string; - stickerTags?: string; - threadName?: string; - autoArchiveMin?: string; - query?: string; - eventName?: string; - eventType?: string; - startTime?: string; - endTime?: string; - desc?: string; - location?: string; - durationMin?: string; - until?: string; - reason?: string; - deleteDays?: string; - includeArchived?: boolean; - participant?: string; - fromMe?: boolean; - dryRun?: boolean; - json?: boolean; - gifPlayback?: boolean; -}; - -type MessageSendOpts = { - to: string; - message: string; - provider: string; - json?: boolean; - dryRun?: boolean; - media?: string; - gifPlayback?: boolean; - account?: string; -}; - -function normalizeAction(value?: string): MessageAction { - const raw = value?.trim().toLowerCase() || "send"; - return raw as MessageAction; -} - -function parseIntOption(value: unknown, label: string): number | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value !== "string" || value.trim().length === 0) return undefined; - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) { - throw new Error(`${label} must be a number`); - } - return parsed; -} - -function requireString(value: unknown, label: string): string { - if (typeof value !== "string") { - throw new Error(`${label} required`); - } - const trimmed = value.trim(); - if (!trimmed) { - throw new Error(`${label} required`); - } - return trimmed; -} - -function optionalString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - -function toStringArray(value: unknown): string[] { - if (Array.isArray(value)) { - return value.map((entry) => String(entry).trim()).filter(Boolean); - } - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - return []; -} - -function extractToolPayload(result: AgentToolResult): unknown { - if (result.details !== undefined) return result.details; - const textBlock = Array.isArray(result.content) - ? result.content.find( - (block) => - block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", - ) - : undefined; - const text = (textBlock as { text?: string } | undefined)?.text; - if (text) { - try { - return JSON.parse(text); - } catch { - return text; - } - } - return result.content ?? result; -} - -function logSendDryRun(opts: MessageSendOpts, runtime: RuntimeEnv) { - runtime.log( - `[dry-run] would send via ${opts.provider} -> ${opts.to}: ${opts.message}${ - opts.media ? ` (media ${opts.media})` : "" - }`, - ); -} - -function logPollDryRun(result: MessagePollResult, runtime: RuntimeEnv) { - runtime.log( - `[dry-run] would send poll via ${result.provider} -> ${result.to}:\n Question: ${result.question}\n Options: ${result.options.join( - ", ", - )}\n Max selections: ${result.maxSelections}`, - ); -} - -function logSendResult( - result: MessageSendResult, - opts: MessageSendOpts, - runtime: RuntimeEnv, -) { - if (result.via === "direct") { - const directResult = result.result as OutboundDeliveryResult | undefined; - const summary = formatOutboundDeliverySummary( - result.provider, - directResult, - ); - runtime.log(success(summary)); - if (opts.json) { - runtime.log( - JSON.stringify( - buildOutboundDeliveryJson({ - provider: result.provider, - via: "direct", - to: opts.to, - result: directResult, - mediaUrl: opts.media ?? null, - }), - null, - 2, - ), - ); - } - return; - } - - const gatewayResult = result.result as { messageId?: string } | undefined; - runtime.log( - success( - formatGatewaySummary({ - provider: result.provider, - messageId: gatewayResult?.messageId ?? null, - }), - ), - ); - if (opts.json) { - runtime.log( - JSON.stringify( - buildOutboundResultEnvelope({ - delivery: buildOutboundDeliveryJson({ - provider: result.provider, - via: "gateway", - to: opts.to, - result: gatewayResult, - mediaUrl: opts.media ?? null, - }), - }), - null, - 2, - ), - ); - } -} +import { buildMessageCliJson, formatMessageCliText } from "./message-format.js"; export async function messageCommand( - opts: MessageCommandOpts, + opts: Record, deps: CliDeps, runtime: RuntimeEnv, ) { const cfg = loadConfig(); - const action = normalizeAction(opts.action); - const providerSelection = await resolveMessageProviderSelection({ - cfg, - provider: opts.provider, - }); - const provider = providerSelection.provider; - const accountId = optionalString(opts.account); - const actionParams = opts as Record; + const rawAction = + typeof opts.action === "string" ? opts.action.trim().toLowerCase() : ""; + const action = (rawAction || "send") as ProviderMessageActionName; + if (!(PROVIDER_MESSAGE_ACTION_NAMES as readonly string[]).includes(action)) { + throw new Error(`Unknown message action: ${action}`); + } + const outboundDeps: OutboundSendDeps = { sendWhatsApp: deps.sendMessageWhatsApp, sendTelegram: deps.sendMessageTelegram, @@ -292,215 +38,40 @@ export async function messageCommand( deps.sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }), }; - if (opts.dryRun && action !== "send" && action !== "poll") { - runtime.log(`[dry-run] would run ${action} via ${provider}`); - return; - } - - if (action === "send") { - const to = requireString(opts.to, "to"); - const message = requireString(opts.message, "message"); - const sendOpts: MessageSendOpts = { - to, - message, - provider, - json: opts.json, - dryRun: opts.dryRun, - media: optionalString(opts.media), - gifPlayback: opts.gifPlayback, - account: accountId, - }; - - if (opts.dryRun) { - logSendDryRun(sendOpts, runtime); - return; - } - - const handled = await dispatchProviderMessageAction({ - provider, - action: action as ProviderMessageActionName, + const run = async () => + await runMessageAction({ cfg, - params: actionParams, - accountId, + action, + params: opts, + deps: outboundDeps, gateway: { clientName: GATEWAY_CLIENT_NAMES.CLI, mode: GATEWAY_CLIENT_MODES.CLI, }, - dryRun: opts.dryRun, }); - if (handled) { - const payload = extractToolPayload(handled); - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(success(`Sent via ${provider}.`)); - } - return; - } - const result = await withProgress( - { - label: `Sending via ${provider}...`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await sendMessage({ - cfg, - to, - content: message, - provider, - mediaUrl: optionalString(opts.media), - gifPlayback: opts.gifPlayback, - accountId, - dryRun: opts.dryRun, - deps: outboundDeps, - gateway: { - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, - }, - }), - ); - logSendResult(result, sendOpts, runtime); - return; - } + const json = opts.json === true; + const dryRun = opts.dryRun === true; + const needsSpinner = + !json && !dryRun && (action === "send" || action === "poll"); - if (action === "poll") { - const to = requireString(opts.to, "to"); - const question = requireString(opts.pollQuestion, "poll-question"); - const options = toStringArray(opts.pollOption); - if (options.length < 2) { - throw new Error("poll-option requires at least two values"); - } - const durationHours = parseIntOption( - opts.pollDurationHours, - "poll-duration-hours", - ); - const allowMultiselect = Boolean(opts.pollMulti); - const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; - - if (opts.dryRun) { - const result = await sendPoll({ - cfg, - to, - question, - options, - maxSelections, - durationHours, - provider, - dryRun: true, - gateway: { - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, + const result = needsSpinner + ? await withProgress( + { + label: action === "poll" ? "Sending poll..." : "Sending...", + indeterminate: true, + enabled: true, }, - }); - logPollDryRun(result, runtime); - return; - } + run, + ) + : await run(); - const handled = await dispatchProviderMessageAction({ - provider, - action: action as ProviderMessageActionName, - cfg, - params: actionParams, - accountId, - gateway: { - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, - }, - dryRun: opts.dryRun, - }); - if (handled) { - const payload = extractToolPayload(handled); - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(success(`Poll sent via ${provider}.`)); - } - return; - } - - const result = await withProgress( - { - label: `Sending poll via ${provider}...`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await sendPoll({ - cfg, - to, - question, - options, - maxSelections, - durationHours, - provider, - dryRun: opts.dryRun, - gateway: { - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, - }, - }), - ); - - runtime.log( - success( - formatGatewaySummary({ - action: "Poll sent", - provider, - messageId: result.result?.messageId ?? null, - }), - ), - ); - const pollId = (result.result as { pollId?: string } | undefined)?.pollId; - if (pollId) { - runtime.log(success(`Poll id: ${pollId}`)); - } - if (opts.json) { - runtime.log( - JSON.stringify( - { - ...buildOutboundResultEnvelope({ - delivery: buildOutboundDeliveryJson({ - provider, - via: "gateway", - to, - result: result.result, - mediaUrl: null, - }), - }), - question: result.question, - options: result.options, - maxSelections: result.maxSelections, - durationHours: result.durationHours, - pollId, - }, - null, - 2, - ), - ); - } + if (json) { + runtime.log(JSON.stringify(buildMessageCliJson(result), null, 2)); return; } - const handled = await dispatchProviderMessageAction({ - provider, - action: action as ProviderMessageActionName, - cfg, - params: actionParams, - accountId, - gateway: { - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, - }, - dryRun: opts.dryRun, - }); - if (handled) { - runtime.log(JSON.stringify(extractToolPayload(handled), null, 2)); - return; + for (const line of formatMessageCliText(result)) { + runtime.log(line); } - - throw new Error( - `Action ${action} is not supported for provider ${provider}.`, - ); } diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts new file mode 100644 index 000000000..6f743a6ea --- /dev/null +++ b/src/infra/outbound/message-action-runner.ts @@ -0,0 +1,334 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../agents/tools/common.js"; +import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { dispatchProviderMessageAction } from "../../providers/plugins/message-actions.js"; +import type { + ProviderId, + ProviderMessageActionName, + ProviderThreadingToolContext, +} from "../../providers/plugins/types.js"; +import type { + GatewayClientMode, + GatewayClientName, +} from "../../utils/message-provider.js"; +import type { OutboundSendDeps } from "./deliver.js"; +import type { MessagePollResult, MessageSendResult } from "./message.js"; +import { sendMessage, sendPoll } from "./message.js"; +import { resolveMessageProviderSelection } from "./provider-selection.js"; + +export type MessageActionRunnerGateway = { + url?: string; + token?: string; + timeoutMs?: number; + clientName: GatewayClientName; + clientDisplayName?: string; + mode: GatewayClientMode; +}; + +export type RunMessageActionParams = { + cfg: ClawdbotConfig; + action: ProviderMessageActionName; + params: Record; + defaultAccountId?: string; + toolContext?: ProviderThreadingToolContext; + gateway?: MessageActionRunnerGateway; + deps?: OutboundSendDeps; + dryRun?: boolean; +}; + +export type MessageActionRunResult = + | { + kind: "send"; + provider: ProviderId; + action: "send"; + to: string; + handledBy: "plugin" | "core"; + payload: unknown; + toolResult?: AgentToolResult; + sendResult?: MessageSendResult; + dryRun: boolean; + } + | { + kind: "poll"; + provider: ProviderId; + action: "poll"; + to: string; + handledBy: "plugin" | "core"; + payload: unknown; + toolResult?: AgentToolResult; + pollResult?: MessagePollResult; + dryRun: boolean; + } + | { + kind: "action"; + provider: ProviderId; + action: Exclude; + handledBy: "plugin" | "dry-run"; + payload: unknown; + toolResult?: AgentToolResult; + dryRun: boolean; + }; + +function extractToolPayload(result: AgentToolResult): unknown { + if (result.details !== undefined) return result.details; + const textBlock = Array.isArray(result.content) + ? result.content.find( + (block) => + block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ) + : undefined; + const text = (textBlock as { text?: string } | undefined)?.text; + if (text) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return result.content ?? result; +} + +function readBooleanParam( + params: Record, + key: string, +): boolean | undefined { + const raw = params[key]; + if (typeof raw === "boolean") return raw; + if (typeof raw === "string") { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + } + return undefined; +} + +function parseButtonsParam(params: Record): void { + const raw = params.buttons; + if (typeof raw !== "string") return; + const trimmed = raw.trim(); + if (!trimmed) { + delete params.buttons; + return; + } + try { + params.buttons = JSON.parse(trimmed) as unknown; + } catch { + throw new Error("--buttons must be valid JSON"); + } +} + +async function resolveProvider( + cfg: ClawdbotConfig, + params: Record, +) { + const providerHint = readStringParam(params, "provider"); + const selection = await resolveMessageProviderSelection({ + cfg, + provider: providerHint, + }); + return selection.provider; +} + +export async function runMessageAction( + input: RunMessageActionParams, +): Promise { + const cfg = input.cfg; + const params = { ...input.params }; + parseButtonsParam(params); + + const action = input.action; + const provider = await resolveProvider(cfg, params); + const accountId = + readStringParam(params, "accountId") ?? input.defaultAccountId; + const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); + + const gateway = input.gateway + ? { + url: input.gateway.url, + token: input.gateway.token, + timeoutMs: input.gateway.timeoutMs, + clientName: input.gateway.clientName, + clientDisplayName: input.gateway.clientDisplayName, + mode: input.gateway.mode, + } + : undefined; + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + let message = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + + const parsed = parseReplyDirectives(message); + message = parsed.text; + params.message = message; + if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId; + if (!params.media) { + params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined; + } + + const mediaUrl = readStringParam(params, "media", { trim: false }); + const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; + const bestEffort = readBooleanParam(params, "bestEffort"); + + if (!dryRun) { + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId: accountId ?? undefined, + gateway, + toolContext: input.toolContext, + dryRun, + }); + if (handled) { + return { + kind: "send", + provider, + action, + to, + handledBy: "plugin", + payload: extractToolPayload(handled), + toolResult: handled, + dryRun, + }; + } + } + + const result: MessageSendResult = await sendMessage({ + cfg, + to, + content: message, + mediaUrl: mediaUrl || undefined, + provider: provider || undefined, + accountId: accountId ?? undefined, + gifPlayback, + dryRun, + bestEffort: bestEffort ?? undefined, + deps: input.deps, + gateway, + }); + + return { + kind: "send", + provider, + action, + to, + handledBy: "core", + payload: result, + sendResult: result, + dryRun, + }; + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const options = + readStringArrayParam(params, "pollOption", { required: true }) ?? []; + if (options.length < 2) { + throw new Error("pollOption requires at least two values"); + } + const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + }); + const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; + + if (!dryRun) { + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId: accountId ?? undefined, + gateway, + toolContext: input.toolContext, + dryRun, + }); + if (handled) { + return { + kind: "poll", + provider, + action, + to, + handledBy: "plugin", + payload: extractToolPayload(handled), + toolResult: handled, + dryRun, + }; + } + } + + const result: MessagePollResult = await sendPoll({ + cfg, + to, + question, + options, + maxSelections, + durationHours: durationHours ?? undefined, + provider, + dryRun, + gateway, + }); + + return { + kind: "poll", + provider, + action, + to, + handledBy: "core", + payload: result, + pollResult: result, + dryRun, + }; + } + + if (dryRun) { + return { + kind: "action", + provider, + action, + handledBy: "dry-run", + payload: { ok: true, dryRun: true, provider, action }, + dryRun: true, + }; + } + + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId: accountId ?? undefined, + gateway, + toolContext: input.toolContext, + dryRun, + }); + if (!handled) { + throw new Error( + `Message action ${action} not supported for provider ${provider}.`, + ); + } + return { + kind: "action", + provider, + action, + handledBy: "plugin", + payload: extractToolPayload(handled), + toolResult: handled, + dryRun, + }; +} diff --git a/src/providers/plugins/actions/telegram.ts b/src/providers/plugins/actions/telegram.ts index acc48d54f..a0136fa11 100644 --- a/src/providers/plugins/actions/telegram.ts +++ b/src/providers/plugins/actions/telegram.ts @@ -63,19 +63,7 @@ export const telegramMessageActions: ProviderMessageActionAdapter = { const mediaUrl = readStringParam(params, "media", { trim: false }); const replyTo = readStringParam(params, "replyTo"); const threadId = readStringParam(params, "threadId"); - let buttons = params.buttons; - if (!buttons) { - const buttonsJson = readStringParam(params, "buttonsJson", { - trim: false, - }); - if (buttonsJson) { - try { - buttons = JSON.parse(buttonsJson); - } catch { - throw new Error("buttons-json must be valid JSON"); - } - } - } + const buttons = params.buttons; return await handleTelegramAction( { action: "sendMessage", diff --git a/src/providers/plugins/message-action-names.ts b/src/providers/plugins/message-action-names.ts new file mode 100644 index 000000000..17d57cf8a --- /dev/null +++ b/src/providers/plugins/message-action-names.ts @@ -0,0 +1,43 @@ +export const PROVIDER_MESSAGE_ACTION_NAMES = [ + "send", + "poll", + "react", + "reactions", + "read", + "edit", + "delete", + "pin", + "unpin", + "list-pins", + "permissions", + "thread-create", + "thread-list", + "thread-reply", + "search", + "sticker", + "member-info", + "role-info", + "emoji-list", + "emoji-upload", + "sticker-upload", + "role-add", + "role-remove", + "channel-info", + "channel-list", + "channel-create", + "channel-edit", + "channel-delete", + "channel-move", + "category-create", + "category-edit", + "category-delete", + "voice-status", + "event-list", + "event-create", + "timeout", + "kick", + "ban", +] as const; + +export type ProviderMessageActionName = + (typeof PROVIDER_MESSAGE_ACTION_NAMES)[number]; diff --git a/src/providers/plugins/types.ts b/src/providers/plugins/types.ts index d339c0e7e..5e6e7b1bc 100644 --- a/src/providers/plugins/types.ts +++ b/src/providers/plugins/types.ts @@ -13,8 +13,11 @@ import type { GatewayClientName, } from "../../utils/message-provider.js"; import type { ChatProviderId } from "../registry.js"; +import type { ProviderMessageActionName as ProviderMessageActionNameFromList } from "./message-action-names.js"; import type { ProviderOnboardingAdapter } from "./onboarding-types.js"; +export { PROVIDER_MESSAGE_ACTION_NAMES } from "./message-action-names.js"; + export type ProviderId = ChatProviderId; export type ProviderOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; @@ -478,45 +481,7 @@ export type ProviderMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; }; -export type ProviderMessageActionName = - | "send" - | "poll" - | "react" - | "reactions" - | "read" - | "edit" - | "delete" - | "pin" - | "unpin" - | "list-pins" - | "permissions" - | "thread-create" - | "thread-list" - | "thread-reply" - | "search" - | "sticker" - | "member-info" - | "role-info" - | "emoji-list" - | "emoji-upload" - | "sticker-upload" - | "role-add" - | "role-remove" - | "channel-info" - | "channel-list" - | "channel-create" - | "channel-edit" - | "channel-delete" - | "channel-move" - | "category-create" - | "category-edit" - | "category-delete" - | "voice-status" - | "event-list" - | "event-create" - | "timeout" - | "kick" - | "ban"; +export type ProviderMessageActionName = ProviderMessageActionNameFromList; export type ProviderMessageActionContext = { provider: ProviderId;