diff --git a/AGENTS.md b/AGENTS.md index 42088930c..f120b43ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,17 +93,17 @@ - Voice wake forwarding tips: - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`. -- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. +- For manual `clawdbot message --action send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. ## Exclamation Mark Escaping Workaround -The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax: +The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message --action send` with messages containing exclamation marks, use heredoc syntax: ```bash # WRONG - will send "Hello\\!" with backslash -clawdbot message send --to "+1234" --message 'Hello!' +clawdbot message --action send --to "+1234" --message 'Hello!' # CORRECT - use heredoc to avoid escaping -clawdbot message send --to "+1234" --message "$(cat <<'EOF' +clawdbot message --action send --to "+1234" --message "$(cat <<'EOF' Hello! EOF )" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af15bc19..f4a9034ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Commands: accept /models as an alias for /model. - Debugging: add raw model stream logging flags and document gateway watch mode. - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). -- CLI: replace `send`/`poll` with `message send`/`message poll`, and add the `message` agent tool. +- CLI: replace `message send`/`message poll` with `message --action ...`, and fold Discord/Slack/Telegram/WhatsApp tools into `message` (provider required unless only one configured). - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 diff --git a/README.md b/README.md index 18ea6ca16..84cd78435 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ clawdbot onboard --install-daemon clawdbot gateway --port 18789 --verbose # Send a message -clawdbot message send --to +1234567890 --message "Hello from Clawdbot" +clawdbot message --to +1234567890 --message "Hello from Clawdbot" # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord) clawdbot agent --message "Ship checklist" --thinking high diff --git a/docs/automation/poll.md b/docs/automation/poll.md index 4aac3fa92..071b4a071 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -15,18 +15,22 @@ read_when: ```bash # WhatsApp -clawdbot message poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" -clawdbot message poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 +clawdbot message --action poll --to +15555550123 \ + --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" +clawdbot message --action poll --to 123456789@g.us \ + --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi # Discord -clawdbot message poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord -clawdbot message poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48 +clawdbot message --action poll --provider discord --to channel:123456789 \ + --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" +clawdbot message --action poll --provider discord --to channel:123456789 \ + --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 ``` Options: - `--provider`: `whatsapp` (default) or `discord` -- `--max-selections`: how many choices a voter can select (default: 1) -- `--duration-hours`: Discord-only (defaults to 24 when omitted) +- `--poll-multi`: allow selecting multiple options +- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted) ## Gateway RPC @@ -45,10 +49,7 @@ Params: - WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. - Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. -## Agent tool (Discord) -The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`. - -Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect). - ## Agent tool (Message) -Use the `message` tool with `poll` action (`to`, `question`, `options`, optional `maxSelections`, `durationHours`, `provider`). +Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`). + +Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select. diff --git a/docs/cli/index.md b/docs/cli/index.md index 99fb75d3c..28a91d6f3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -56,8 +56,6 @@ clawdbot [--dev] [--profile ] info check message - send - poll agent agents list @@ -285,37 +283,10 @@ Options: ## Messaging + agent -### `message send` -Send a message through a provider. +### `message` +Unified outbound messaging + provider actions. -Required: -- `--to ` -- `--message ` - -Options: -- `--media ` -- `--gif-playback` -- `--provider ` -- `--account ` (WhatsApp) -- `--dry-run` -- `--json` -- `--verbose` - -### `message poll` -Create a poll (WhatsApp or Discord). - -Required: -- `--to ` -- `--question ` -- `--option ` (repeat 2-12 times) - -Options: -- `--max-selections ` -- `--duration-hours ` (Discord) -- `--provider ` -- `--dry-run` -- `--json` -- `--verbose` +See: [/cli/message](/cli/message) ### `agent` Run one agent turn via the Gateway (or `--local` embedded). diff --git a/docs/cli/message.md b/docs/cli/message.md new file mode 100644 index 000000000..7ddc33fda --- /dev/null +++ b/docs/cli/message.md @@ -0,0 +1,210 @@ +--- +summary: "CLI reference for `clawdbot message` (send + provider actions)" +read_when: + - Adding or modifying message CLI actions + - Changing outbound provider behavior +--- + +# `clawdbot message` + +Single outbound command for sending messages and provider actions +(Discord/Slack/Telegram/WhatsApp/Signal/iMessage). + +## Usage + +``` +clawdbot message --action [--provider ] [flags] +``` + +Defaults: +- `--action send` + +Provider selection: +- `--provider` required if more than one provider is configured. +- If exactly one provider is configured, it becomes the default. +- Values: `whatsapp|telegram|discord|slack|signal|imessage` + +Target formats (`--to`): +- WhatsApp: E.164 or group JID +- Telegram: chat id or `@username` +- Discord/Slack: `channel:` or `user:` (raw id ok) +- Signal: E.164, `group:`, or `signal:+E.164` +- iMessage: handle or `chat_id:` + +## Common flags + +- `--to ` +- `--message ` +- `--media ` +- `--message-id ` +- `--reply-to ` +- `--thread-id ` (Telegram forum thread) +- `--account ` (multi-account providers) +- `--dry-run` +- `--json` +- `--verbose` + +## Actions + +### `send` +Providers: whatsapp, telegram, discord, slack, signal, imessage +Required: `--to`, `--message` +Optional: `--media`, `--reply-to`, `--thread-id`, `--account`, `--gif-playback` + +### `react` +Providers: discord, slack, telegram, whatsapp +Required: `--to`, `--message-id` +Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--account` + +### `reactions` +Providers: discord, slack +Required: `--to`, `--message-id` +Optional: `--limit` + +### `read` +Providers: discord, slack +Required: `--to` +Optional: `--limit`, `--before`, `--after`, `--around` + +### `edit` +Providers: discord, slack +Required: `--to`, `--message-id`, `--message` + +### `delete` +Providers: discord, slack +Required: `--to`, `--message-id` + +### `pin` +Providers: discord, slack +Required: `--to`, `--message-id` + +### `unpin` +Providers: discord, slack +Required: `--to`, `--message-id` + +### `list-pins` +Providers: discord, slack +Required: `--to` + +### `poll` +Providers: whatsapp, discord +Required: `--to`, `--poll-question`, `--poll-option` (repeat) +Optional: `--poll-multi`, `--poll-duration-hours`, `--message` + +### `sticker` +Providers: discord +Required: `--to`, `--sticker-id` (repeat) +Optional: `--message` + +### `permissions` +Providers: discord +Required: `--to` (channel id) + +### `thread-create` +Providers: discord +Required: `--to` (channel id), `--thread-name` +Optional: `--message-id`, `--auto-archive-min` + +### `thread-list` +Providers: discord +Required: `--guild-id` +Optional: `--channel-id`, `--include-archived`, `--before`, `--limit` + +### `thread-reply` +Providers: discord +Required: `--to` (thread id), `--message` +Optional: `--media`, `--reply-to` + +### `search` +Providers: discord +Required: `--guild-id`, `--query` +Optional: `--channel-id`, `--channel-ids`, `--author-id`, `--author-ids`, `--limit` + +### `member-info` +Providers: discord, slack +Required: `--user-id` +Discord only: also `--guild-id` + +### `role-info` +Providers: discord +Required: `--guild-id` + +### `emoji-list` +Providers: discord, slack +Discord only: `--guild-id` + +### `emoji-upload` +Providers: discord +Required: `--guild-id`, `--emoji-name`, `--media` +Optional: `--role-ids` (repeat) + +### `sticker-upload` +Providers: discord +Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media` + +### `role-add` +Providers: discord +Required: `--guild-id`, `--user-id`, `--role-id` + +### `role-remove` +Providers: discord +Required: `--guild-id`, `--user-id`, `--role-id` + +### `channel-info` +Providers: discord +Required: `--channel-id` + +### `channel-list` +Providers: discord +Required: `--guild-id` + +### `voice-status` +Providers: discord +Required: `--guild-id`, `--user-id` + +### `event-list` +Providers: discord +Required: `--guild-id` + +### `event-create` +Providers: discord +Required: `--guild-id`, `--event-name`, `--start-time` +Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type` + +### `timeout` +Providers: discord +Required: `--guild-id`, `--user-id` +Optional: `--duration-min`, `--until`, `--reason` + +### `kick` +Providers: discord +Required: `--guild-id`, `--user-id` +Optional: `--reason` + +### `ban` +Providers: discord +Required: `--guild-id`, `--user-id` +Optional: `--reason`, `--delete-days` + +## Examples + +Send a Discord reply: +``` +clawdbot message --action send --provider discord \ + --to channel:123 --message "hi" --reply-to 456 +``` + +Create a Discord poll: +``` +clawdbot message --action poll --provider discord \ + --to channel:123 \ + --poll-question "Snack?" \ + --poll-option Pizza --poll-option Sushi \ + --poll-multi --poll-duration-hours 48 +``` + +React in Slack: +``` +clawdbot message --action react --provider slack \ + --to C123 --message-id 456 --emoji "✅" +``` diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 9b2e3dcf2..712ed6246 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -254,7 +254,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above. ## CLI helpers - `clawdbot gateway health|status` — request health/status over the Gateway WS. -- `clawdbot message send --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). +- `clawdbot message --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot agent --message "hi" --to ` — run an agent turn (waits for final by default). - `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging. - `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd). diff --git a/docs/index.md b/docs/index.md index 35332ecf1..e01f53e61 100644 --- a/docs/index.md +++ b/docs/index.md @@ -134,7 +134,7 @@ clawdbot gateway --port 19001 Send a test message (requires a running Gateway): ```bash -clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT" +clawdbot message --to +15555550123 --message "Hello from CLAWDBOT" ``` ## Configuration (optional) diff --git a/docs/nodes/images.md b/docs/nodes/images.md index 84c1a3008..8235cc992 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -8,12 +8,12 @@ read_when: CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies. ## Goals -- Send media with optional captions via `clawdbot message send --media`. +- Send media with optional captions via `clawdbot message --media`. - Allow auto-replies from the web inbox to include media alongside text. - Keep per-type limits sane and predictable. ## CLI Surface -- `clawdbot message send --media [--message ]` +- `clawdbot message --media [--message ]` - `--media` optional; caption can be empty for media-only sends. - `--dry-run` prints the resolved payload; `--json` emits `{ provider, to, messageId, mediaUrl, caption }`. @@ -30,7 +30,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media ## Auto-Reply Pipeline - `getReplyFromConfig` returns `{ text?, mediaUrl?, mediaUrls? }`. -- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message send`. +- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message`. - Multiple media entries are sent sequentially if provided. ## Inbound Media to Commands (Pi) diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index 42cf31cf2..b9f14f1ad 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -223,7 +223,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti ## Delivery targets (CLI/cron) - Use a chat id (`123456789`) or a username (`@name`) as the target. -- Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`. +- Example: `clawdbot message --provider telegram --to 123456789 --message "hi"`. ## Troubleshooting diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index 4cec0dc62..ecb9ee190 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -158,7 +158,7 @@ Behavior: - Caption only on first media item. - Media fetch supports HTTP(S) and local paths. - Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping. - - CLI: `clawdbot message send --media --gif-playback` + - CLI: `clawdbot message --media --gif-playback` - Gateway: `send` params include `gifPlayback: true` ## Media limits + optimization diff --git a/docs/start/faq.md b/docs/start/faq.md index 06be764b1..aecd25afb 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -560,7 +560,7 @@ Outbound attachments from the agent must include a `MEDIA:` line (o CLI sending: ```bash -clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png +clawdbot message --to +15555550123 --message "Here you go" --media /path/to/file.png ``` Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images). diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index f81d70a20..8bc36d54f 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -152,7 +152,7 @@ In a new terminal: ```bash clawdbot health -clawdbot message send --to +15555550123 --message "Hello from Clawdbot" +clawdbot message --to +15555550123 --message "Hello from Clawdbot" ``` If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. diff --git a/docs/tools/index.md b/docs/tools/index.md index 1835a74ac..aa663a0ea 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -149,15 +149,28 @@ Notes: - Uses the image model directly (independent of the main chat model). ### `message` -Send messages and polls across providers. +Send messages and provider actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage. Core actions: - `send` (text + optional media) - `poll` (WhatsApp/Discord polls) +- `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` Notes: -- `send` routes WhatsApp via the Gateway and other providers directly. -- `poll` always routes via the Gateway. +- `send` routes WhatsApp via the Gateway; other providers go direct. +- `poll` uses the Gateway for WhatsApp and direct Discord API for Discord. ### `cron` Manage Gateway cron jobs and wakeups. @@ -209,70 +222,6 @@ Notes: - Result is restricted to per-agent allowlists (`routing.agents..subagents.allowAgents`). - When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. -### `discord` -Send Discord reactions, stickers, or polls. - -Core actions: -- `react` (`channelId`, `messageId`, `emoji`) -- `reactions` (`channelId`, `messageId`, optional `limit`) -- `sticker` (`to`, `stickerIds`, optional `content`) -- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`) -- `permissions` (`channelId`) -- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`) -- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`) -- `editMessage` (`channelId`, `messageId`, `content`) -- `deleteMessage` (`channelId`, `messageId`) -- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`) -- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`) -- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`) -- `pinMessage`/`unpinMessage` (`channelId`, `messageId`) -- `listPins` (`channelId`) -- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`) -- `memberInfo` (`guildId`, `userId`) -- `roleInfo` (`guildId`) -- `emojiList` (`guildId`) -- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`) -- `channelInfo` (`channelId`) -- `channelList` (`guildId`) -- `voiceStatus` (`guildId`, `userId`) -- `eventList` (`guildId`) -- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`) -- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`) -- `kick` (`guildId`, `userId`, optional `reason`) -- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`) - -Notes: -- `to` accepts `channel:` or `user:`. -- Polls require 2–10 answers and default to 24 hours. -- `reactions` returns per-emoji user lists (limited to 100 per reaction). -- Reaction removal semantics: see [/tools/reactions](/tools/reactions). -- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`. -- `searchMessages` follows the Discord preview feature constraints (limit max 25, channel/author filters accept arrays). -- The tool is only exposed when the current provider is Discord. - -### `whatsapp` -Send WhatsApp reactions. - -Core actions: -- `react` (`chatJid`, `messageId`, `emoji`, optional `remove`, `participant`, `fromMe`, `accountId`) - -Notes: -- Reaction removal semantics: see [/tools/reactions](/tools/reactions). -- `whatsapp.actions.*` gates WhatsApp tool actions. -- The tool is only exposed when the current provider is WhatsApp. - -### `telegram` -Send Telegram messages or reactions. - -Core actions: -- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`) -- `react` (`chatId`, `messageId`, `emoji`, optional `remove`) - -Notes: -- Reaction removal semantics: see [/tools/reactions](/tools/reactions). -- `telegram.actions.*` gates Telegram tool actions. -- The tool is only exposed when the current provider is Telegram. - ## Parameters (common) Gateway-backed tools (`canvas`, `nodes`, `cron`): diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 40f647f16..f63b6e787 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -4,7 +4,6 @@ import { createBrowserTool } from "./tools/browser-tool.js"; import { createCanvasTool } from "./tools/canvas-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; -import { createDiscordTool } from "./tools/discord-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; @@ -13,9 +12,6 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; -import { createSlackTool } from "./tools/slack-tool.js"; -import { createTelegramTool } from "./tools/telegram-tool.js"; -import { createWhatsAppTool } from "./tools/whatsapp-tool.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; @@ -35,14 +31,10 @@ export function createClawdbotTools(options?: { createCanvasTool(), createNodesTool(), createCronTool(), - createDiscordTool(), - createMessageTool(), - createSlackTool({ + createMessageTool({ agentAccountId: options?.agentAccountId, config: options?.config, }), - createTelegramTool(), - createWhatsAppTool(), createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index de754771c..f6250d1b3 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -141,36 +141,13 @@ describe("createClawdbotCodingTools", () => { expect(offenders).toEqual([]); }); - it("scopes discord tool to discord provider", () => { - const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); - expect(other.some((tool) => tool.name === "discord")).toBe(false); - - const discord = createClawdbotCodingTools({ messageProvider: "discord" }); - expect(discord.some((tool) => tool.name === "discord")).toBe(true); - }); - - it("scopes slack tool to slack provider", () => { - const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); - expect(other.some((tool) => tool.name === "slack")).toBe(false); - - const slack = createClawdbotCodingTools({ messageProvider: "slack" }); - expect(slack.some((tool) => tool.name === "slack")).toBe(true); - }); - - it("scopes telegram tool to telegram provider", () => { - const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); - expect(other.some((tool) => tool.name === "telegram")).toBe(false); - - const telegram = createClawdbotCodingTools({ messageProvider: "telegram" }); - expect(telegram.some((tool) => tool.name === "telegram")).toBe(true); - }); - - it("scopes whatsapp tool to whatsapp provider", () => { - const other = createClawdbotCodingTools({ messageProvider: "slack" }); - expect(other.some((tool) => tool.name === "whatsapp")).toBe(false); - - const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" }); - expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true); + it("does not expose provider-specific message tools", () => { + const tools = createClawdbotCodingTools({ messageProvider: "discord" }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("discord")).toBe(false); + expect(names.has("slack")).toBe(false); + expect(names.has("telegram")).toBe(false); + expect(names.has("whatsapp")).toBe(false); }); it("filters session tools for sub-agent sessions by default", () => { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index b42133824..11e8c491b 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -613,37 +613,6 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { }; } -function normalizeMessageProvider( - messageProvider?: string, -): string | undefined { - const trimmed = messageProvider?.trim().toLowerCase(); - return trimmed ? trimmed : undefined; -} - -function shouldIncludeDiscordTool(messageProvider?: string): boolean { - const normalized = normalizeMessageProvider(messageProvider); - if (!normalized) return false; - return normalized === "discord" || normalized.startsWith("discord:"); -} - -function shouldIncludeSlackTool(messageProvider?: string): boolean { - const normalized = normalizeMessageProvider(messageProvider); - if (!normalized) return false; - return normalized === "slack" || normalized.startsWith("slack:"); -} - -function shouldIncludeTelegramTool(messageProvider?: string): boolean { - const normalized = normalizeMessageProvider(messageProvider); - if (!normalized) return false; - return normalized === "telegram" || normalized.startsWith("telegram:"); -} - -function shouldIncludeWhatsAppTool(messageProvider?: string): boolean { - const normalized = normalizeMessageProvider(messageProvider); - if (!normalized) return false; - return normalized === "whatsapp" || normalized.startsWith("whatsapp:"); -} - export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; messageProvider?: string; @@ -724,20 +693,9 @@ export function createClawdbotCodingTools(options?: { config: options?.config, }), ]; - const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider); - const allowSlack = shouldIncludeSlackTool(options?.messageProvider); - const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider); - const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider); - const filtered = tools.filter((tool) => { - if (tool.name === "discord") return allowDiscord; - if (tool.name === "slack") return allowSlack; - if (tool.name === "telegram") return allowTelegram; - if (tool.name === "whatsapp") return allowWhatsApp; - return true; - }); const toolsFiltered = effectiveToolsPolicy - ? filterToolsByPolicy(filtered, effectiveToolsPolicy) - : filtered; + ? filterToolsByPolicy(tools, effectiveToolsPolicy) + : tools; const sandboxed = sandbox ? filterToolsByPolicy(toolsFiltered, sandbox.tools) : toolsFiltered; diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 6db12608e..71ce6da81 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -154,8 +154,37 @@ "emoji": "✉️", "title": "Message", "actions": { - "send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] }, - "poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] } + "send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] }, + "poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] }, + "react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] }, + "reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] }, + "read": { "label": "read", "detailKeys": ["provider", "to", "limit"] }, + "edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] }, + "delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] }, + "pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] }, + "unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] }, + "list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] }, + "permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] }, + "thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] }, + "thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] }, + "thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] }, + "search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] }, + "sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] }, + "member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] }, + "role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] }, + "emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] }, + "emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] }, + "sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] }, + "role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] }, + "role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] }, + "channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] }, + "channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] }, + "voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] }, + "event-list": { "label": "events", "detailKeys": ["provider", "guildId"] }, + "event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] }, + "timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] }, + "kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] }, + "ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] } } }, "agents_list": { @@ -190,77 +219,6 @@ "start": { "label": "start" }, "wait": { "label": "wait" } } - }, - "discord": { - "emoji": "💬", - "title": "Discord", - "actions": { - "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] }, - "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, - "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] }, - "poll": { "label": "poll", "detailKeys": ["question", "to"] }, - "permissions": { "label": "permissions", "detailKeys": ["channelId"] }, - "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, - "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, - "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, - "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, - "threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] }, - "threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] }, - "threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] }, - "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, - "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, - "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, - "searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] }, - "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] }, - "roleInfo": { "label": "roles", "detailKeys": ["guildId"] }, - "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] }, - "emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] }, - "stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] }, - "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] }, - "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] }, - "channelInfo": { "label": "channel", "detailKeys": ["channelId"] }, - "channelList": { "label": "channels", "detailKeys": ["guildId"] }, - "voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] }, - "eventList": { "label": "events", "detailKeys": ["guildId"] }, - "eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] }, - "timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] }, - "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] }, - "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] } - } - }, - "slack": { - "emoji": "💬", - "title": "Slack", - "actions": { - "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] }, - "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, - "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, - "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, - "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, - "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, - "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, - "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, - "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, - "memberInfo": { "label": "member", "detailKeys": ["userId"] }, - "emojiList": { "label": "emoji list" } - } - }, - "telegram": { - "emoji": "✈️", - "title": "Telegram", - "actions": { - "react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] } - } - }, - "whatsapp": { - "emoji": "💬", - "title": "WhatsApp", - "actions": { - "react": { - "label": "react", - "detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"] - } - } } } } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 29261cb1b..a4d54b2e9 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,11 +1,15 @@ import { Type } from "@sinclair/typebox"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; import { type MessagePollResult, type MessageSendResult, sendMessage, sendPoll, } from "../../infra/outbound/message.js"; +import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, @@ -13,36 +17,131 @@ import { readStringArrayParam, readStringParam, } from "./common.js"; +import { handleDiscordAction } from "./discord-actions.js"; +import { handleSlackAction } from "./slack-actions.js"; +import { handleTelegramAction } from "./telegram-actions.js"; +import { handleWhatsAppAction } from "./whatsapp-actions.js"; + +const MessageActionSchema = Type.Union([ + Type.Literal("send"), + Type.Literal("poll"), + Type.Literal("react"), + Type.Literal("reactions"), + Type.Literal("read"), + Type.Literal("edit"), + Type.Literal("delete"), + Type.Literal("pin"), + Type.Literal("unpin"), + Type.Literal("list-pins"), + Type.Literal("permissions"), + Type.Literal("thread-create"), + Type.Literal("thread-list"), + Type.Literal("thread-reply"), + Type.Literal("search"), + Type.Literal("sticker"), + Type.Literal("member-info"), + Type.Literal("role-info"), + Type.Literal("emoji-list"), + Type.Literal("emoji-upload"), + Type.Literal("sticker-upload"), + Type.Literal("role-add"), + Type.Literal("role-remove"), + Type.Literal("channel-info"), + Type.Literal("channel-list"), + Type.Literal("voice-status"), + Type.Literal("event-list"), + Type.Literal("event-create"), + Type.Literal("timeout"), + Type.Literal("kick"), + Type.Literal("ban"), +]); const MessageToolSchema = Type.Object({ - action: Type.Union([Type.Literal("send"), Type.Literal("poll")]), - to: Type.Optional(Type.String()), - content: Type.Optional(Type.String()), - mediaUrl: Type.Optional(Type.String()), - gifPlayback: Type.Optional(Type.Boolean()), + action: MessageActionSchema, provider: Type.Optional(Type.String()), + to: Type.Optional(Type.String()), + message: Type.Optional(Type.String()), + media: Type.Optional(Type.String()), + messageId: Type.Optional(Type.String()), + replyTo: Type.Optional(Type.String()), + threadId: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()), bestEffort: Type.Optional(Type.Boolean()), - question: Type.Optional(Type.String()), - options: Type.Optional(Type.Array(Type.String())), - maxSelections: Type.Optional(Type.Number()), - durationHours: Type.Optional(Type.Number()), + gifPlayback: Type.Optional(Type.Boolean()), + emoji: Type.Optional(Type.String()), + remove: Type.Optional(Type.Boolean()), + limit: Type.Optional(Type.Number()), + before: Type.Optional(Type.String()), + after: Type.Optional(Type.String()), + around: Type.Optional(Type.String()), + pollQuestion: Type.Optional(Type.String()), + pollOption: Type.Optional(Type.Array(Type.String())), + pollDurationHours: Type.Optional(Type.Number()), + pollMulti: Type.Optional(Type.Boolean()), + channelId: Type.Optional(Type.String()), + channelIds: Type.Optional(Type.Array(Type.String())), + guildId: Type.Optional(Type.String()), + userId: Type.Optional(Type.String()), + authorId: Type.Optional(Type.String()), + authorIds: Type.Optional(Type.Array(Type.String())), + roleId: Type.Optional(Type.String()), + roleIds: Type.Optional(Type.Array(Type.String())), + emojiName: Type.Optional(Type.String()), + stickerId: Type.Optional(Type.Array(Type.String())), + stickerName: Type.Optional(Type.String()), + stickerDesc: Type.Optional(Type.String()), + stickerTags: Type.Optional(Type.String()), + threadName: Type.Optional(Type.String()), + autoArchiveMin: Type.Optional(Type.Number()), + query: Type.Optional(Type.String()), + eventName: Type.Optional(Type.String()), + eventType: Type.Optional(Type.String()), + startTime: Type.Optional(Type.String()), + endTime: Type.Optional(Type.String()), + desc: Type.Optional(Type.String()), + location: Type.Optional(Type.String()), + durationMin: Type.Optional(Type.Number()), + until: Type.Optional(Type.String()), + reason: Type.Optional(Type.String()), + deleteDays: Type.Optional(Type.Number()), + includeArchived: Type.Optional(Type.Boolean()), + participant: Type.Optional(Type.String()), + fromMe: Type.Optional(Type.Boolean()), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), }); -export function createMessageTool(): AnyAgentTool { +type MessageToolOptions = { + agentAccountId?: string; + config?: ClawdbotConfig; +}; + +function resolveAgentAccountId(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return normalizeAccountId(trimmed); +} + +export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { + const agentAccountId = resolveAgentAccountId(options?.agentAccountId); return { label: "Message", name: "message", description: - "Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.", + "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).", parameters: MessageToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; + const cfg = options?.config ?? loadConfig(); const action = readStringParam(params, "action", { required: true }); + const providerSelection = await resolveMessageProviderSelection({ + cfg, + provider: readStringParam(params, "provider"), + }); + const provider = providerSelection.provider; + const accountId = readStringParam(params, "accountId") ?? agentAccountId; const gateway = { url: readStringParam(params, "gatewayUrl", { trim: false }), token: readStringParam(params, "gatewayToken", { trim: false }), @@ -54,13 +153,13 @@ export function createMessageTool(): AnyAgentTool { if (action === "send") { const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content", { + const message = readStringParam(params, "message", { required: true, allowEmpty: true, }); - const mediaUrl = readStringParam(params, "mediaUrl", { trim: false }); - const provider = readStringParam(params, "provider"); - const accountId = readStringParam(params, "accountId"); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); const gifPlayback = typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; const bestEffort = @@ -68,12 +167,66 @@ export function createMessageTool(): AnyAgentTool { ? 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); + } + + if (provider === "discord") { + return await handleDiscordAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: mediaUrl ?? undefined, + accountId: accountId ?? undefined, + threadTs: threadId ?? replyTo ?? undefined, + }, + cfg, + ); + } + if (provider === "telegram") { + return await handleTelegramAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: mediaUrl ?? undefined, + replyToMessageId: replyTo ?? undefined, + messageThreadId: threadId ?? undefined, + }, + cfg, + ); + } + const result: MessageSendResult = await sendMessage({ to, - content, + content: message, mediaUrl: mediaUrl || undefined, provider: provider || undefined, - accountId: accountId || undefined, + accountId: accountId ?? undefined, gifPlayback, dryRun, bestEffort, @@ -84,32 +237,679 @@ export function createMessageTool(): AnyAgentTool { if (action === "poll") { const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "question", { + const question = readStringParam(params, "pollQuestion", { required: true, }); const options = - readStringArrayParam(params, "options", { required: true }) ?? []; - const maxSelections = readNumberParam(params, "maxSelections", { + readStringArrayParam(params, "pollOption", { required: true }) ?? []; + const allowMultiselect = + typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; + const durationHours = readNumberParam(params, "pollDurationHours", { integer: true, }); - const durationHours = readNumberParam(params, "durationHours", { - integer: true, - }); - const provider = readStringParam(params, "provider"); + if (dryRun) { + const maxSelections = allowMultiselect + ? Math.max(2, options.length) + : 1; + const result: MessagePollResult = await sendPoll({ + to, + question, + options, + maxSelections, + durationHours: durationHours ?? undefined, + provider, + dryRun, + gateway, + }); + return jsonResult(result); + } + + if (provider === "discord") { + return await handleDiscordAction( + { + action: "poll", + to, + question, + answers: options, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + ); + } + + const maxSelections = allowMultiselect + ? Math.max(2, options.length) + : 1; const result: MessagePollResult = await sendPoll({ to, question, options, maxSelections, - durationHours, - provider: provider || undefined, + durationHours: durationHours ?? undefined, + provider, dryRun, gateway, }); return jsonResult(result); } + const resolveChannelId = (label: string) => + readStringParam(params, label) ?? + readStringParam(params, "to", { required: true }); + + const resolveChatId = (label: string) => + readStringParam(params, label) ?? + readStringParam(params, "to", { required: true }); + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = + typeof params.remove === "boolean" ? params.remove : undefined; + if (provider === "discord") { + return await handleDiscordAction( + { + action: "react", + channelId: resolveChannelId("channelId"), + messageId, + emoji, + remove, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "react", + channelId: resolveChannelId("channelId"), + messageId, + emoji, + remove, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + if (provider === "telegram") { + return await handleTelegramAction( + { + action: "react", + chatId: resolveChatId("chatId"), + messageId, + emoji, + remove, + }, + cfg, + ); + } + if (provider === "whatsapp") { + return await handleWhatsAppAction( + { + action: "react", + chatJid: resolveChatId("chatJid"), + messageId, + emoji, + remove, + participant: readStringParam(params, "participant"), + accountId: accountId ?? undefined, + fromMe: + typeof params.fromMe === "boolean" ? params.fromMe : undefined, + }, + cfg, + ); + } + throw new Error(`React is not supported for provider ${provider}.`); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const limit = readNumberParam(params, "limit", { integer: true }); + if (provider === "discord") { + return await handleDiscordAction( + { + action: "reactions", + channelId: resolveChannelId("channelId"), + messageId, + limit, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "reactions", + channelId: resolveChannelId("channelId"), + messageId, + limit, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error( + `Reactions are not supported for provider ${provider}.`, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + const before = readStringParam(params, "before"); + const after = readStringParam(params, "after"); + const around = readStringParam(params, "around"); + if (provider === "discord") { + return await handleDiscordAction( + { + action: "readMessages", + channelId: resolveChannelId("channelId"), + limit, + before, + after, + around, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "readMessages", + channelId: resolveChannelId("channelId"), + limit, + before, + after, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Read is not supported for provider ${provider}.`); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const message = readStringParam(params, "message", { required: true }); + if (provider === "discord") { + return await handleDiscordAction( + { + action: "editMessage", + channelId: resolveChannelId("channelId"), + messageId, + content: message, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "editMessage", + channelId: resolveChannelId("channelId"), + messageId, + content: message, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Edit is not supported for provider ${provider}.`); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + if (provider === "discord") { + return await handleDiscordAction( + { + action: "deleteMessage", + channelId: resolveChannelId("channelId"), + messageId, + }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { + action: "deleteMessage", + channelId: resolveChannelId("channelId"), + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Delete is not supported for provider ${provider}.`); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(params, "messageId", { required: true }); + const channelId = resolveChannelId("channelId"); + if (provider === "discord") { + const discordAction = + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins"; + return await handleDiscordAction( + { + action: discordAction, + channelId, + messageId, + }, + cfg, + ); + } + if (provider === "slack") { + const slackAction = + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins"; + return await handleSlackAction( + { + action: slackAction, + channelId, + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Pins are not supported for provider ${provider}.`); + } + + if (action === "permissions") { + if (provider !== "discord") { + throw new Error( + `Permissions are only supported for Discord (provider=${provider}).`, + ); + } + return await handleDiscordAction( + { + action: "permissions", + channelId: resolveChannelId("channelId"), + }, + cfg, + ); + } + + if (action === "thread-create") { + if (provider !== "discord") { + throw new Error( + `Thread create is only supported for Discord (provider=${provider}).`, + ); + } + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + return await handleDiscordAction( + { + action: "threadCreate", + channelId: resolveChannelId("channelId"), + name, + messageId, + autoArchiveMinutes, + }, + cfg, + ); + } + + if (action === "thread-list") { + if (provider !== "discord") { + throw new Error( + `Thread list is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const channelId = readStringParam(params, "channelId"); + const includeArchived = + typeof params.includeArchived === "boolean" + ? params.includeArchived + : undefined; + const before = readStringParam(params, "before"); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + if (provider !== "discord") { + throw new Error( + `Thread reply is only supported for Discord (provider=${provider}).`, + ); + } + const content = readStringParam(params, "message", { required: true }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + return await handleDiscordAction( + { + action: "threadReply", + channelId: resolveChannelId("channelId"), + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + if (provider !== "discord") { + throw new Error( + `Search is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const query = readStringParam(params, "query", { required: true }); + const channelId = readStringParam(params, "channelId"); + const channelIds = readStringArrayParam(params, "channelIds"); + const authorId = readStringParam(params, "authorId"); + const authorIds = readStringArrayParam(params, "authorIds"); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "searchMessages", + guildId, + content: query, + channelId, + channelIds, + authorId, + authorIds, + limit, + }, + cfg, + ); + } + + if (action === "sticker") { + if (provider !== "discord") { + throw new Error( + `Sticker send is only supported for Discord (provider=${provider}).`, + ); + } + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + const content = readStringParam(params, "message"); + return await handleDiscordAction( + { + action: "sticker", + to: readStringParam(params, "to", { required: true }), + stickerIds, + content, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(params, "userId", { required: true }); + if (provider === "discord") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", guildId, userId }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { action: "memberInfo", userId, accountId: accountId ?? undefined }, + cfg, + ); + } + throw new Error( + `Member info is not supported for provider ${provider}.`, + ); + } + + if (action === "role-info") { + if (provider !== "discord") { + throw new Error( + `Role info is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + return await handleDiscordAction({ action: "roleInfo", guildId }, cfg); + } + + if (action === "emoji-list") { + if (provider === "discord") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "emojiList", guildId }, + cfg, + ); + } + if (provider === "slack") { + return await handleSlackAction( + { action: "emojiList", accountId: accountId ?? undefined }, + cfg, + ); + } + throw new Error( + `Emoji list is not supported for provider ${provider}.`, + ); + } + + if (action === "emoji-upload") { + if (provider !== "discord") { + throw new Error( + `Emoji upload is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const name = readStringParam(params, "emojiName", { required: true }); + const mediaUrl = readStringParam(params, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(params, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + if (provider !== "discord") { + throw new Error( + `Sticker upload is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const name = readStringParam(params, "stickerName", { required: true }); + const description = readStringParam(params, "stickerDesc", { + required: true, + }); + const tags = readStringParam(params, "stickerTags", { required: true }); + const mediaUrl = readStringParam(params, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + if (provider !== "discord") { + throw new Error( + `Role changes are only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const userId = readStringParam(params, "userId", { required: true }); + const roleId = readStringParam(params, "roleId", { required: true }); + const discordAction = action === "role-add" ? "roleAdd" : "roleRemove"; + return await handleDiscordAction( + { action: discordAction, guildId, userId, roleId }, + cfg, + ); + } + + if (action === "channel-info") { + if (provider !== "discord") { + throw new Error( + `Channel info is only supported for Discord (provider=${provider}).`, + ); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", channelId }, + cfg, + ); + } + + if (action === "channel-list") { + if (provider !== "discord") { + throw new Error( + `Channel list is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + return await handleDiscordAction( + { action: "channelList", guildId }, + cfg, + ); + } + + if (action === "voice-status") { + if (provider !== "discord") { + throw new Error( + `Voice status is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const userId = readStringParam(params, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + if (provider !== "discord") { + throw new Error( + `Event list is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + return await handleDiscordAction({ action: "eventList", guildId }, cfg); + } + + if (action === "event-create") { + if (provider !== "discord") { + throw new Error( + `Event create is only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const name = readStringParam(params, "eventName", { required: true }); + const startTime = readStringParam(params, "startTime", { + required: true, + }); + const endTime = readStringParam(params, "endTime"); + const description = readStringParam(params, "desc"); + const channelId = readStringParam(params, "channelId"); + const location = readStringParam(params, "location"); + const entityType = readStringParam(params, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (action === "timeout" || action === "kick" || action === "ban") { + if (provider !== "discord") { + throw new Error( + `Moderation actions are only supported for Discord (provider=${provider}).`, + ); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const userId = readStringParam(params, "userId", { required: true }); + const durationMinutes = readNumberParam(params, "durationMin", { + integer: true, + }); + const until = readStringParam(params, "until"); + const reason = readStringParam(params, "reason"); + const deleteMessageDays = readNumberParam(params, "deleteDays", { + integer: true, + }); + const discordAction = action as "timeout" | "kick" | "ban"; + return await handleDiscordAction( + { + action: discordAction, + guildId, + userId, + durationMinutes, + until, + reason, + deleteMessageDays, + }, + cfg, + ); + } + throw new Error(`Unknown action: ${action}`); }, }; diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 19a55121a..ae3d4c712 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -91,9 +91,11 @@ export async function handleSlackAction( const to = readStringParam(params, "to", { required: true }); const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); + const threadTs = readStringParam(params, "threadTs"); const result = await sendSlackMessage(to, content, { accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, + threadTs: threadTs ?? undefined, }); return jsonResult({ ok: true, result }); } diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts index a1afaf8bc..25ac504c6 100644 --- a/src/agents/tools/slack-schema.ts +++ b/src/agents/tools/slack-schema.ts @@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([ to: Type.String(), content: Type.String(), mediaUrl: Type.Optional(Type.String()), + threadTs: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), }), Type.Object({ diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 6a215f582..3d44f0fbd 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const messageSendCommand = vi.fn(); +const messageCommand = vi.fn(); const statusCommand = vi.fn(); const configureCommand = vi.fn(); const setupCommand = vi.fn(); @@ -19,8 +19,7 @@ const runtime = { }; vi.mock("../commands/message.js", () => ({ - messageSendCommand, - messagePollCommand: vi.fn(), + messageCommand, })); vi.mock("../commands/status.js", () => ({ statusCommand })); vi.mock("../commands/configure.js", () => ({ configureCommand })); @@ -46,15 +45,12 @@ describe("cli program", () => { vi.clearAllMocks(); }); - it("runs message send with required options", async () => { + it("runs message with required options", async () => { const program = buildProgram(); - await program.parseAsync( - ["message", "send", "--to", "+1", "--message", "hi"], - { - from: "user", - }, - ); - expect(messageSendCommand).toHaveBeenCalled(); + await program.parseAsync(["message", "--to", "+1", "--message", "hi"], { + from: "user", + }); + expect(messageCommand).toHaveBeenCalled(); }); it("runs status command", async () => { diff --git a/src/cli/program.ts b/src/cli/program.ts index d17f34bc2..1033ef145 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -8,7 +8,7 @@ import { import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; -import { messagePollCommand, messageSendCommand } from "../commands/message.js"; +import { messageCommand } from "../commands/message.js"; import { onboardCommand } from "../commands/onboard.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; @@ -408,41 +408,100 @@ export function buildProgram() { } }); - const message = program + program .command("message") - .description("Send messages and polls across providers") - .action(() => { - message.outputHelp(); - defaultRuntime.error( - danger('Missing subcommand. Try: "clawdbot message send"'), - ); - defaultRuntime.exit(1); - }); - - message - .command("send") - .description( - "Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)", + .description("Send messages and provider actions") + .option( + "-a, --action ", + "Action: 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", + "send", ) - .requiredOption( - "-t, --to ", - "Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id", + .option( + "-t, --to ", + "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id", ) - .requiredOption("-m, --message ", "Message body") + .option("-m, --message ", "Message body") .option( "--media ", "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) + .option("--message-id ", "Message id (edit/delete/react/pin)") + .option("--reply-to ", "Reply-to message id") + .option("--thread-id ", "Thread id (Telegram forum thread)") + .option("--account ", "Provider account id") + .option( + "--provider ", + "Provider: whatsapp|telegram|discord|slack|signal|imessage", + ) + .option("--emoji ", "Emoji for reactions") + .option("--remove", "Remove reaction", false) + .option("--limit ", "Result limit for read/reactions/search") + .option("--before ", "Read/search before id") + .option("--after ", "Read/search after id") + .option("--around ", "Read around id (Discord)") + .option("--poll-question ", "Poll question") + .option( + "--poll-option ", + "Poll option (repeat 2-12 times)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option("--poll-multi", "Allow multiple selections", false) + .option("--poll-duration-hours ", "Poll duration (Discord)") + .option("--channel-id ", "Channel id") + .option( + "--channel-ids ", + "Channel id (repeat)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option("--guild-id ", "Guild id") + .option("--user-id ", "User id") + .option("--author-id ", "Author id") + .option( + "--author-ids ", + "Author id (repeat)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option("--role-id ", "Role id") + .option( + "--role-ids ", + "Role id (repeat)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option("--emoji-name ", "Emoji name") + .option( + "--sticker-id ", + "Sticker id (repeat)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option("--sticker-name ", "Sticker name") + .option("--sticker-desc ", "Sticker description") + .option("--sticker-tags ", "Sticker tags") + .option("--thread-name ", "Thread name") + .option("--auto-archive-min ", "Thread auto-archive minutes") + .option("--query ", "Search query") + .option("--event-name ", "Event name") + .option("--event-type ", "Event type") + .option("--start-time ", "Event start time") + .option("--end-time ", "Event end time") + .option("--desc ", "Event description") + .option("--location ", "Event location") + .option("--duration-min ", "Timeout duration minutes") + .option("--until ", "Timeout until") + .option("--reason ", "Moderation reason") + .option("--delete-days ", "Ban delete message days") + .option("--include-archived", "Include archived threads", false) + .option("--participant ", "WhatsApp reaction participant") + .option("--from-me", "WhatsApp reaction fromMe", false) .option( "--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false, ) - .option( - "--provider ", - "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", - ) - .option("--account ", "WhatsApp account id (accountId)") .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -450,16 +509,16 @@ export function buildProgram() { "after", ` Examples: - clawdbot message send --to +15555550123 --message "Hi" - clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg - clawdbot message send --to +15555550123 --message "Hi" --dry-run # print payload only - clawdbot message send --to +15555550123 --message "Hi" --json # machine-readable result`, + clawdbot message --to +15555550123 --message "Hi" + clawdbot message --action send --to +15555550123 --message "Hi" --media photo.jpg + clawdbot message --action poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi + clawdbot message --action react --provider discord --to 123 --message-id 456 --emoji "✅"`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { - await messageSendCommand( + await messageCommand( { ...opts, account: opts.account as string | undefined, @@ -473,55 +532,6 @@ Examples: } }); - message - .command("poll") - .description("Create a poll via WhatsApp or Discord") - .requiredOption( - "-t, --to ", - "Recipient: WhatsApp JID/number or Discord channel/user", - ) - .requiredOption("-q, --question ", "Poll question") - .requiredOption( - "-o, --option ", - "Poll option (use multiple times, 2-12 required)", - (value: string, previous: string[]) => previous.concat([value]), - [] as string[], - ) - .option( - "-s, --max-selections ", - "How many options can be selected (default: 1)", - ) - .option( - "--duration-hours ", - "Poll duration in hours (Discord only, default: 24)", - ) - .option( - "--provider ", - "Delivery provider: whatsapp|discord (default: whatsapp)", - ) - .option("--dry-run", "Print payload and skip sending", false) - .option("--json", "Output result as JSON", false) - .option("--verbose", "Verbose logging", false) - .addHelpText( - "after", - ` -Examples: - clawdbot message poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" - clawdbot message poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 - clawdbot message poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord - clawdbot message poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`, - ) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const deps = createDefaultDeps(); - try { - await messagePollCommand(opts, deps, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - program .command("agent") .description("Run an agent turn via the Gateway (use --local for embedded)") diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 66dea2931..a93a3d4ca 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; import type { RuntimeEnv } from "../runtime.js"; -import { messagePollCommand, messageSendCommand } from "./message.js"; +import { messageCommand } from "./message.js"; let testConfig: Record = {}; vi.mock("../config/config.js", async (importOriginal) => { @@ -19,13 +19,44 @@ vi.mock("../gateway/call.js", () => ({ randomIdempotencyKey: () => "idem-1", })); +const webAuthExists = vi.fn(async () => false); +vi.mock("../web/session.js", () => ({ + webAuthExists: (...args: unknown[]) => webAuthExists(...args), +})); + +const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); +vi.mock("../agents/tools/discord-actions.js", () => ({ + handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), +})); + +const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); +vi.mock("../agents/tools/slack-actions.js", () => ({ + handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), +})); + +const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } })); +vi.mock("../agents/tools/telegram-actions.js", () => ({ + handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), +})); + +const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } })); +vi.mock("../agents/tools/whatsapp-actions.js", () => ({ + handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args), +})); + const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN; const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; beforeEach(() => { - process.env.TELEGRAM_BOT_TOKEN = "token-abc"; - process.env.DISCORD_BOT_TOKEN = "token-discord"; + process.env.TELEGRAM_BOT_TOKEN = ""; + process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; + callGatewayMock.mockReset(); + webAuthExists.mockReset().mockResolvedValue(false); + handleDiscordAction.mockReset(); + handleSlackAction.mockReset(); + handleTelegramAction.mockReset(); + handleWhatsAppAction.mockReset(); }); afterAll(() => { @@ -51,26 +82,44 @@ const makeDeps = (overrides: Partial = {}): CliDeps => ({ ...overrides, }); -describe("messageSendCommand", () => { - it("skips send on dry-run", async () => { +describe("messageCommand", () => { + it("defaults provider when only one configured", async () => { + process.env.TELEGRAM_BOT_TOKEN = "token-abc"; const deps = makeDeps(); - await messageSendCommand( + await messageCommand( { - to: "+1", + to: "123", message: "hi", - dryRun: true, }, deps, runtime, ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + expect(handleTelegramAction).toHaveBeenCalled(); }); - it("sends via gateway", async () => { + it("requires provider when multiple configured", async () => { + process.env.TELEGRAM_BOT_TOKEN = "token-abc"; + process.env.DISCORD_BOT_TOKEN = "token-discord"; + const deps = makeDeps(); + await expect( + messageCommand( + { + to: "123", + message: "hi", + }, + deps, + runtime, + ), + ).rejects.toThrow(/Provider is required/); + }); + + it("sends via gateway for WhatsApp", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); const deps = makeDeps(); - await messageSendCommand( + await messageCommand( { + action: "send", + provider: "whatsapp", to: "+1", message: "hi", }, @@ -78,261 +127,27 @@ describe("messageSendCommand", () => { runtime, ); expect(callGatewayMock).toHaveBeenCalled(); - expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1")); }); - it("does not override remote gateway URL", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "g2" }); - testConfig = { - gateway: { mode: "remote", remote: { url: "wss://remote.example" } }, - }; + it("routes discord polls through message action", async () => { const deps = makeDeps(); - await messageSendCommand( + await messageCommand( { - to: "+1", - message: "hi", - }, - deps, - runtime, - ); - const args = callGatewayMock.mock.calls.at(-1)?.[0] as - | Record - | undefined; - expect(args?.url).toBeUndefined(); - }); - - it("passes gifPlayback to gateway send", async () => { - callGatewayMock.mockClear(); - callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); - const deps = makeDeps(); - await messageSendCommand( - { - to: "+1", - message: "hi", - gifPlayback: true, - }, - deps, - runtime, - ); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "send", - params: expect.objectContaining({ gifPlayback: true }), - }), - ); - }); - - it("routes to telegram provider", async () => { - const deps = makeDeps({ - sendMessageTelegram: vi - .fn() - .mockResolvedValue({ messageId: "t1", chatId: "123" }), - }); - testConfig = { telegram: { botToken: "token-abc" } }; - await messageSendCommand( - { to: "123", message: "hi", provider: "telegram" }, - deps, - runtime, - ); - expect(deps.sendMessageTelegram).toHaveBeenCalledWith( - "123", - "hi", - expect.objectContaining({ accountId: undefined, verbose: false }), - ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - }); - - it("uses config token for telegram when env is missing", async () => { - process.env.TELEGRAM_BOT_TOKEN = ""; - testConfig = { telegram: { botToken: "cfg-token" } }; - const deps = makeDeps({ - sendMessageTelegram: vi - .fn() - .mockResolvedValue({ messageId: "t1", chatId: "123" }), - }); - await messageSendCommand( - { to: "123", message: "hi", provider: "telegram" }, - deps, - runtime, - ); - expect(deps.sendMessageTelegram).toHaveBeenCalledWith( - "123", - "hi", - expect.objectContaining({ accountId: undefined, verbose: false }), - ); - }); - - it("routes to discord provider", async () => { - const deps = makeDeps({ - sendMessageDiscord: vi - .fn() - .mockResolvedValue({ messageId: "d1", channelId: "chan" }), - }); - await messageSendCommand( - { to: "channel:chan", message: "hi", provider: "discord" }, - deps, - runtime, - ); - expect(deps.sendMessageDiscord).toHaveBeenCalledWith( - "channel:chan", - "hi", - expect.objectContaining({ verbose: false }), - ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - }); - - it("routes to signal provider", async () => { - const deps = makeDeps({ - sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }), - }); - await messageSendCommand( - { to: "+15551234567", message: "hi", provider: "signal" }, - deps, - runtime, - ); - expect(deps.sendMessageSignal).toHaveBeenCalledWith( - "+15551234567", - "hi", - expect.objectContaining({ maxBytes: undefined }), - ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - }); - - it("routes to slack provider", async () => { - const deps = makeDeps({ - sendMessageSlack: vi - .fn() - .mockResolvedValue({ messageId: "s1", channelId: "C123" }), - }); - await messageSendCommand( - { to: "channel:C123", message: "hi", provider: "slack" }, - deps, - runtime, - ); - expect(deps.sendMessageSlack).toHaveBeenCalledWith( - "channel:C123", - "hi", - expect.objectContaining({ accountId: undefined }), - ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - }); - - it("routes to imessage provider", async () => { - const deps = makeDeps({ - sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }), - }); - await messageSendCommand( - { to: "chat_id:42", message: "hi", provider: "imessage" }, - deps, - runtime, - ); - expect(deps.sendMessageIMessage).toHaveBeenCalledWith( - "chat_id:42", - "hi", - expect.objectContaining({ maxBytes: undefined }), - ); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - }); - - it("emits json output", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" }); - const deps = makeDeps(); - await messageSendCommand( - { - to: "+1", - message: "hi", - json: true, - }, - deps, - runtime, - ); - expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining('"provider": "whatsapp"'), - ); - }); -}); - -describe("messagePollCommand", () => { - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSlack: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - - beforeEach(() => { - callGatewayMock.mockReset(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - testConfig = {}; - }); - - it("routes through gateway", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); - await messagePollCommand( - { - to: "+1", - question: "hi?", - option: ["y", "n"], - }, - deps, - runtime, - ); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ method: "poll" }), - ); - }); - - it("does not override remote gateway URL", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); - testConfig = { - gateway: { mode: "remote", remote: { url: "wss://remote.example" } }, - }; - await messagePollCommand( - { - to: "+1", - question: "hi?", - option: ["y", "n"], - }, - deps, - runtime, - ); - const args = callGatewayMock.mock.calls.at(-1)?.[0] as - | Record - | undefined; - expect(args?.url).toBeUndefined(); - }); - - it("emits json output with gateway metadata", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" }); - await messagePollCommand( - { - to: "channel:C1", - question: "hi?", - option: ["y", "n"], + action: "poll", provider: "discord", - json: true, + to: "channel:123", + pollQuestion: "Snack?", + pollOption: ["Pizza", "Sushi"], }, deps, runtime, ); - const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined; - expect(lastLog).toBeDefined(); - const payload = JSON.parse(lastLog ?? "{}") as Record; - expect(payload).toMatchObject({ - provider: "discord", - via: "gateway", - to: "channel:C1", - messageId: "p1", - channelId: "C1", - mediaUrl: null, - question: "hi?", - options: ["y", "n"], - maxSelections: 1, - durationHours: null, - }); + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + to: "channel:123", + }), + expect.any(Object), + ); }); }); diff --git a/src/commands/message.ts b/src/commands/message.ts index e6279cc22..1ea310e8c 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -1,8 +1,16 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { handleDiscordAction } from "../agents/tools/discord-actions.js"; +import { handleSlackAction } from "../agents/tools/slack-actions.js"; +import { handleTelegramAction } from "../agents/tools/telegram-actions.js"; +import { handleWhatsAppAction } from "../agents/tools/whatsapp-actions.js"; 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 } from "../infra/outbound/deliver.js"; +import type { + OutboundDeliveryResult, + OutboundSendDeps, +} from "../infra/outbound/deliver.js"; import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; import { buildOutboundDeliveryJson, @@ -15,13 +23,100 @@ import { sendMessage, sendPoll, } from "../infra/outbound/message.js"; +import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js"; import type { RuntimeEnv } from "../runtime.js"; -import { normalizeMessageProvider } 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; + 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; + provider: string; json?: boolean; dryRun?: boolean; media?: string; @@ -29,19 +124,14 @@ type MessageSendOpts = { account?: string; }; -type MessagePollOpts = { - to: string; - question: string; - option: string[]; - maxSelections?: string; - durationHours?: string; - provider?: string; - json?: boolean; - dryRun?: boolean; -}; +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)) { @@ -50,13 +140,59 @@ function parseIntOption(value: unknown, label: string): number | undefined { return parsed; } -function logSendDryRun( - opts: MessageSendOpts, - provider: string, - runtime: RuntimeEnv, -) { +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 ${provider} -> ${opts.to}: ${opts.message}${ + `[dry-run] would send via ${opts.provider} -> ${opts.to}: ${opts.message}${ opts.media ? ` (media ${opts.media})` : "" }`, ); @@ -128,125 +264,850 @@ function logSendResult( } } -export async function messageSendCommand( - opts: MessageSendOpts, +export async function messageCommand( + opts: MessageCommandOpts, deps: CliDeps, runtime: RuntimeEnv, ) { - const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; - if (opts.dryRun) { - logSendDryRun(opts, provider, runtime); + const cfg = loadConfig(); + const action = normalizeAction(opts.action); + const providerSelection = await resolveMessageProviderSelection({ + cfg, + provider: opts.provider, + }); + const provider = providerSelection.provider; + const outboundDeps: OutboundSendDeps = { + sendWhatsApp: deps.sendMessageWhatsApp, + sendTelegram: deps.sendMessageTelegram, + sendDiscord: deps.sendMessageDiscord, + sendSlack: deps.sendMessageSlack, + sendSignal: deps.sendMessageSignal, + sendIMessage: deps.sendMessageIMessage, + }; + + if (opts.dryRun && action !== "send" && action !== "poll") { + runtime.log(`[dry-run] would run ${action} via ${provider}`); return; } - const result = await withProgress( - { - label: `Sending via ${provider}...`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await sendMessage({ - cfg: loadConfig(), - to: opts.to, - content: opts.message, - provider, - mediaUrl: opts.media, - gifPlayback: opts.gifPlayback, - accountId: opts.account, - dryRun: opts.dryRun, - deps: deps - ? { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, - } - : undefined, - gateway: { clientName: "cli", mode: "cli" }, - }), - ); - - logSendResult(result, opts, runtime); -} - -export async function messagePollCommand( - opts: MessagePollOpts, - _deps: CliDeps, - runtime: RuntimeEnv, -) { - const provider = (opts.provider ?? "whatsapp").toLowerCase(); - const maxSelections = parseIntOption(opts.maxSelections, "max-selections"); - const durationHours = parseIntOption(opts.durationHours, "duration-hours"); - - if (opts.dryRun) { - const result = await sendPoll({ - cfg: loadConfig(), - to: opts.to, - question: opts.question, - options: opts.option, - maxSelections, - durationHours, + if (action === "send") { + const to = requireString(opts.to, "to"); + const message = requireString(opts.message, "message"); + const sendOpts: MessageSendOpts = { + to, + message, provider, - dryRun: true, - gateway: { clientName: "cli", mode: "cli" }, - }); - logPollDryRun(result, runtime); + json: opts.json, + dryRun: opts.dryRun, + media: optionalString(opts.media), + gifPlayback: opts.gifPlayback, + account: optionalString(opts.account), + }; + + if (opts.dryRun) { + logSendDryRun(sendOpts, runtime); + return; + } + + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: optionalString(opts.media), + replyTo: optionalString(opts.replyTo), + }, + cfg, + ); + const payload = extractToolPayload(result); + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } else { + runtime.log(success(`Sent via ${provider}.`)); + } + return; + } + + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: optionalString(opts.media), + threadTs: + optionalString(opts.threadId) ?? optionalString(opts.replyTo), + accountId: optionalString(opts.account), + }, + cfg, + ); + const payload = extractToolPayload(result); + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } else { + runtime.log(success(`Sent via ${provider}.`)); + } + return; + } + + if (provider === "telegram") { + const result = await handleTelegramAction( + { + action: "sendMessage", + to, + content: message, + mediaUrl: optionalString(opts.media), + replyToMessageId: optionalString(opts.replyTo), + messageThreadId: optionalString(opts.threadId), + }, + cfg, + ); + const payload = extractToolPayload(result); + 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: optionalString(opts.account), + dryRun: opts.dryRun, + deps: outboundDeps, + gateway: { clientName: "cli", mode: "cli" }, + }), + ); + logSendResult(result, sendOpts, runtime); return; } - const result = await withProgress( - { - label: `Sending poll via ${provider}...`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await sendPoll({ - cfg: loadConfig(), - to: opts.to, - question: opts.question, - options: opts.option, + 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: opts.dryRun, + dryRun: true, gateway: { clientName: "cli", mode: "cli" }, - }), - ); + }); + logPollDryRun(result, runtime); + return; + } - runtime.log( - success( - formatGatewaySummary({ - action: "Poll sent", - provider, - messageId: result.result?.messageId ?? null, - }), - ), - ); - if (opts.json) { - runtime.log( - JSON.stringify( + if (provider === "discord") { + const result = await handleDiscordAction( { - ...buildOutboundResultEnvelope({ - delivery: buildOutboundDeliveryJson({ - provider, - via: "gateway", - to: opts.to, - result: result.result, - mediaUrl: null, - }), - }), - question: result.question, - options: result.options, - maxSelections: result.maxSelections, - durationHours: result.durationHours, + action: "poll", + to, + question, + answers: options, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: optionalString(opts.message), }, - null, - 2, + cfg, + ); + const payload = extractToolPayload(result); + 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: "cli", mode: "cli" }, + }), + ); + + runtime.log( + success( + formatGatewaySummary({ + action: "Poll sent", + provider, + messageId: result.result?.messageId ?? null, + }), ), ); + 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, + }, + null, + 2, + ), + ); + } + return; } + + if (action === "react") { + const messageId = requireString(opts.messageId, "message-id"); + const emoji = optionalString(opts.emoji) ?? ""; + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "react", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + emoji, + remove: opts.remove, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "react", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + emoji, + remove: opts.remove, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "telegram") { + const result = await handleTelegramAction( + { + action: "react", + chatId: requireString(opts.to, "to"), + messageId, + emoji, + remove: opts.remove, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "whatsapp") { + const result = await handleWhatsAppAction( + { + action: "react", + chatJid: requireString(opts.to, "to"), + messageId, + emoji, + remove: opts.remove, + participant: optionalString(opts.participant), + accountId: optionalString(opts.account), + fromMe: opts.fromMe, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`React is not supported for provider ${provider}.`); + } + + if (action === "reactions") { + const messageId = requireString(opts.messageId, "message-id"); + const limit = parseIntOption(opts.limit, "limit"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "reactions", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + limit, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "reactions", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + limit, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Reactions are not supported for provider ${provider}.`); + } + + if (action === "read") { + const limit = parseIntOption(opts.limit, "limit"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "readMessages", + channelId: requireString(opts.channelId ?? opts.to, "to"), + limit, + before: optionalString(opts.before), + after: optionalString(opts.after), + around: optionalString(opts.around), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "readMessages", + channelId: requireString(opts.channelId ?? opts.to, "to"), + limit, + before: optionalString(opts.before), + after: optionalString(opts.after), + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Read is not supported for provider ${provider}.`); + } + + if (action === "edit") { + const messageId = requireString(opts.messageId, "message-id"); + const message = requireString(opts.message, "message"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "editMessage", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + content: message, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "editMessage", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + content: message, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Edit is not supported for provider ${provider}.`); + } + + if (action === "delete") { + const messageId = requireString(opts.messageId, "message-id"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "deleteMessage", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "deleteMessage", + channelId: requireString(opts.channelId ?? opts.to, "to"), + messageId, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Delete is not supported for provider ${provider}.`); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const channelId = requireString(opts.channelId ?? opts.to, "to"); + const messageId = + action === "list-pins" + ? undefined + : requireString(opts.messageId, "message-id"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins", + channelId, + messageId, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins", + channelId, + messageId, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Pins are not supported for provider ${provider}.`); + } + + if (action === "permissions") { + if (provider !== "discord") { + throw new Error( + `Permissions are only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "permissions", + channelId: requireString(opts.channelId ?? opts.to, "to"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "thread-create") { + if (provider !== "discord") { + throw new Error( + `Thread create is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "threadCreate", + channelId: requireString(opts.channelId ?? opts.to, "to"), + name: requireString(opts.threadName, "thread-name"), + messageId: optionalString(opts.messageId), + autoArchiveMinutes: parseIntOption( + opts.autoArchiveMin, + "auto-archive-min", + ), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "thread-list") { + if (provider !== "discord") { + throw new Error( + `Thread list is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "threadList", + guildId: requireString(opts.guildId, "guild-id"), + channelId: optionalString(opts.channelId), + includeArchived: opts.includeArchived, + before: optionalString(opts.before), + limit: parseIntOption(opts.limit, "limit"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "thread-reply") { + if (provider !== "discord") { + throw new Error( + `Thread reply is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "threadReply", + channelId: requireString(opts.channelId ?? opts.to, "to"), + content: requireString(opts.message, "message"), + mediaUrl: optionalString(opts.media), + replyTo: optionalString(opts.replyTo), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "search") { + if (provider !== "discord") { + throw new Error( + `Search is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "searchMessages", + guildId: requireString(opts.guildId, "guild-id"), + content: requireString(opts.query, "query"), + channelId: optionalString(opts.channelId), + channelIds: toStringArray(opts.channelIds), + authorId: optionalString(opts.authorId), + authorIds: toStringArray(opts.authorIds), + limit: parseIntOption(opts.limit, "limit"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "sticker") { + if (provider !== "discord") { + throw new Error( + `Sticker send is only supported for Discord (provider=${provider}).`, + ); + } + const stickerIds = toStringArray(opts.stickerId); + if (stickerIds.length === 0) { + throw new Error("sticker-id required"); + } + const result = await handleDiscordAction( + { + action: "sticker", + to: requireString(opts.to, "to"), + stickerIds, + content: optionalString(opts.message), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "member-info") { + const userId = requireString(opts.userId, "user-id"); + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "memberInfo", + guildId: requireString(opts.guildId, "guild-id"), + userId, + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { + action: "memberInfo", + userId, + accountId: optionalString(opts.account), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Member info is not supported for provider ${provider}.`); + } + + if (action === "role-info") { + if (provider !== "discord") { + throw new Error( + `Role info is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { action: "roleInfo", guildId: requireString(opts.guildId, "guild-id") }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "emoji-list") { + if (provider === "discord") { + const result = await handleDiscordAction( + { + action: "emojiList", + guildId: requireString(opts.guildId, "guild-id"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + if (provider === "slack") { + const result = await handleSlackAction( + { action: "emojiList", accountId: optionalString(opts.account) }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + throw new Error(`Emoji list is not supported for provider ${provider}.`); + } + + if (action === "emoji-upload") { + if (provider !== "discord") { + throw new Error( + `Emoji upload is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "emojiUpload", + guildId: requireString(opts.guildId, "guild-id"), + name: requireString(opts.emojiName, "emoji-name"), + mediaUrl: requireString(opts.media, "media"), + roleIds: toStringArray(opts.roleIds), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "sticker-upload") { + if (provider !== "discord") { + throw new Error( + `Sticker upload is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "stickerUpload", + guildId: requireString(opts.guildId, "guild-id"), + name: requireString(opts.stickerName, "sticker-name"), + description: requireString(opts.stickerDesc, "sticker-desc"), + tags: requireString(opts.stickerTags, "sticker-tags"), + mediaUrl: requireString(opts.media, "media"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "role-add" || action === "role-remove") { + if (provider !== "discord") { + throw new Error( + `Role changes are only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + guildId: requireString(opts.guildId, "guild-id"), + userId: requireString(opts.userId, "user-id"), + roleId: requireString(opts.roleId, "role-id"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "channel-info") { + if (provider !== "discord") { + throw new Error( + `Channel info is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "channelInfo", + channelId: requireString(opts.channelId, "channel-id"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "channel-list") { + if (provider !== "discord") { + throw new Error( + `Channel list is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "channelList", + guildId: requireString(opts.guildId, "guild-id"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "voice-status") { + if (provider !== "discord") { + throw new Error( + `Voice status is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "voiceStatus", + guildId: requireString(opts.guildId, "guild-id"), + userId: requireString(opts.userId, "user-id"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "event-list") { + if (provider !== "discord") { + throw new Error( + `Event list is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { action: "eventList", guildId: requireString(opts.guildId, "guild-id") }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "event-create") { + if (provider !== "discord") { + throw new Error( + `Event create is only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: "eventCreate", + guildId: requireString(opts.guildId, "guild-id"), + name: requireString(opts.eventName, "event-name"), + startTime: requireString(opts.startTime, "start-time"), + endTime: optionalString(opts.endTime), + description: optionalString(opts.desc), + channelId: optionalString(opts.channelId), + location: optionalString(opts.location), + entityType: optionalString(opts.eventType), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + if (action === "timeout" || action === "kick" || action === "ban") { + if (provider !== "discord") { + throw new Error( + `Moderation actions are only supported for Discord (provider=${provider}).`, + ); + } + const result = await handleDiscordAction( + { + action: action as "timeout" | "kick" | "ban", + guildId: requireString(opts.guildId, "guild-id"), + userId: requireString(opts.userId, "user-id"), + durationMinutes: parseIntOption(opts.durationMin, "duration-min"), + until: optionalString(opts.until), + reason: optionalString(opts.reason), + deleteMessageDays: parseIntOption(opts.deleteDays, "delete-days"), + }, + cfg, + ); + runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + return; + } + + throw new Error(`Unknown action: ${opts.action ?? "unknown"}`); } diff --git a/src/infra/outbound/provider-selection.ts b/src/infra/outbound/provider-selection.ts new file mode 100644 index 000000000..b969c0585 --- /dev/null +++ b/src/infra/outbound/provider-selection.ts @@ -0,0 +1,113 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; +import { listEnabledIMessageAccounts } from "../../imessage/accounts.js"; +import { listEnabledSignalAccounts } from "../../signal/accounts.js"; +import { listEnabledSlackAccounts } from "../../slack/accounts.js"; +import { listEnabledTelegramAccounts } from "../../telegram/accounts.js"; +import { normalizeMessageProvider } from "../../utils/message-provider.js"; +import { + listEnabledWhatsAppAccounts, + resolveWhatsAppAccount, +} from "../../web/accounts.js"; +import { webAuthExists } from "../../web/session.js"; + +export type MessageProviderId = + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; + +const MESSAGE_PROVIDERS: MessageProviderId[] = [ + "whatsapp", + "telegram", + "discord", + "slack", + "signal", + "imessage", +]; + +function isKnownProvider(value: string): value is MessageProviderId { + return (MESSAGE_PROVIDERS as string[]).includes(value); +} + +async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise { + const accounts = listEnabledWhatsAppAccounts(cfg); + if (accounts.length === 0) { + const fallback = resolveWhatsAppAccount({ cfg }); + return await webAuthExists(fallback.authDir); + } + for (const account of accounts) { + if (await webAuthExists(account.authDir)) return true; + } + return false; +} + +function isTelegramConfigured(cfg: ClawdbotConfig): boolean { + return listEnabledTelegramAccounts(cfg).some( + (account) => account.token.trim().length > 0, + ); +} + +function isDiscordConfigured(cfg: ClawdbotConfig): boolean { + return listEnabledDiscordAccounts(cfg).some( + (account) => account.token.trim().length > 0, + ); +} + +function isSlackConfigured(cfg: ClawdbotConfig): boolean { + return listEnabledSlackAccounts(cfg).some( + (account) => (account.botToken ?? "").trim().length > 0, + ); +} + +function isSignalConfigured(cfg: ClawdbotConfig): boolean { + return listEnabledSignalAccounts(cfg).some((account) => account.configured); +} + +function isIMessageConfigured(cfg: ClawdbotConfig): boolean { + return listEnabledIMessageAccounts(cfg).some((account) => account.configured); +} + +export async function listConfiguredMessageProviders( + cfg: ClawdbotConfig, +): Promise { + const providers: MessageProviderId[] = []; + if (await isWhatsAppConfigured(cfg)) providers.push("whatsapp"); + if (isTelegramConfigured(cfg)) providers.push("telegram"); + if (isDiscordConfigured(cfg)) providers.push("discord"); + if (isSlackConfigured(cfg)) providers.push("slack"); + if (isSignalConfigured(cfg)) providers.push("signal"); + if (isIMessageConfigured(cfg)) providers.push("imessage"); + return providers; +} + +export async function resolveMessageProviderSelection(params: { + cfg: ClawdbotConfig; + provider?: string | null; +}): Promise<{ provider: MessageProviderId; configured: MessageProviderId[] }> { + const normalized = normalizeMessageProvider(params.provider); + if (normalized) { + if (!isKnownProvider(normalized)) { + throw new Error(`Unknown provider: ${normalized}`); + } + return { + provider: normalized, + configured: await listConfiguredMessageProviders(params.cfg), + }; + } + + const configured = await listConfiguredMessageProviders(params.cfg); + if (configured.length === 1) { + return { provider: configured[0], configured }; + } + if (configured.length === 0) { + throw new Error("Provider is required (no configured providers detected)."); + } + throw new Error( + `Provider is required when multiple providers are configured: ${configured.join( + ", ", + )}`, + ); +} diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 9df6d32d2..c108ebe72 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -145,13 +145,14 @@ export async function listSlackReactions( export async function sendSlackMessage( to: string, content: string, - opts: SlackActionClientOpts & { mediaUrl?: string } = {}, + opts: SlackActionClientOpts & { mediaUrl?: string; threadTs?: string } = {}, ) { return await sendMessageSlack(to, content, { accountId: opts.accountId, token: opts.token, mediaUrl: opts.mediaUrl, client: opts.client, + threadTs: opts.threadTs, }); }