From db222070143aa76b869bb305f97637dd34f5a37d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 06:43:40 +0100 Subject: [PATCH] feat: add message tool and CLI --- AGENTS.md | 8 +- CHANGELOG.md | 1 + README.md | 2 +- docs/automation/poll.md | 11 +- docs/cli/index.md | 9 +- docs/gateway/index.md | 2 +- docs/index.md | 2 +- docs/nodes/images.md | 6 +- docs/providers/telegram.md | 2 +- docs/providers/whatsapp.md | 2 +- docs/start/faq.md | 2 +- docs/start/getting-started.md | 2 +- docs/tools/index.md | 13 +- src/agents/clawdbot-tools.ts | 2 + src/agents/pi-tools.test.ts | 2 +- src/agents/tool-display.json | 8 + src/agents/tools/message-tool.ts | 112 ++++++++ src/cli/program.test.ts | 13 +- src/cli/program.ts | 42 +-- .../{send.test.ts => message.test.ts} | 111 +++++++- src/commands/message.ts | 240 ++++++++++++++++++ src/commands/poll.test.ts | 110 -------- src/commands/poll.ts | 121 --------- src/commands/send.ts | 148 ----------- src/infra/outbound/message.ts | 229 +++++++++++++++++ 25 files changed, 763 insertions(+), 437 deletions(-) create mode 100644 src/agents/tools/message-tool.ts rename src/commands/{send.test.ts => message.test.ts} (71%) create mode 100644 src/commands/message.ts delete mode 100644 src/commands/poll.test.ts delete mode 100644 src/commands/poll.ts delete mode 100644 src/commands/send.ts create mode 100644 src/infra/outbound/message.ts diff --git a/AGENTS.md b/AGENTS.md index e1765e93b..8b4566da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,17 +92,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 send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. +- For manual `clawdbot message 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 send` with messages containing exclamation marks, use heredoc syntax: +The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax: ```bash # WRONG - will send "Hello\\!" with backslash -clawdbot send --to "+1234" --message 'Hello!' +clawdbot message send --to "+1234" --message 'Hello!' # CORRECT - use heredoc to avoid escaping -clawdbot send --to "+1234" --message "$(cat <<'EOF' +clawdbot message send --to "+1234" --message "$(cat <<'EOF' Hello! EOF )" diff --git a/CHANGELOG.md b/CHANGELOG.md index b92c75402..7a3a0aef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +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: 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 700940630..66a8612d2 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ clawdbot onboard --install-daemon clawdbot gateway --port 18789 --verbose # Send a message -clawdbot send --to +1234567890 --message "Hello from Clawdbot" +clawdbot message send --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 ca26eafd8..4aac3fa92 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -15,12 +15,12 @@ read_when: ```bash # WhatsApp -clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" -clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 +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 # Discord -clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord -clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48 +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 ``` Options: @@ -49,3 +49,6 @@ Params: 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`). diff --git a/docs/cli/index.md b/docs/cli/index.md index 0e73e9120..0b70f2b8e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -55,8 +55,9 @@ clawdbot [--dev] [--profile ] list info check - send - poll + message + send + poll agent agents list @@ -283,7 +284,7 @@ Options: ## Messaging + agent -### `send` +### `message send` Send a message through a provider. Required: @@ -299,7 +300,7 @@ Options: - `--json` - `--verbose` -### `poll` +### `message poll` Create a poll (WhatsApp or Discord). Required: diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 058e7631f..9b2e3dcf2 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 send --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). +- `clawdbot message send --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 ee18be7de..249b90ae4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -135,7 +135,7 @@ clawdbot gateway --port 19001 Send a test message (requires a running Gateway): ```bash -clawdbot send --to +15555550123 --message "Hello from CLAWDBOT" +clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT" ``` ## Configuration (optional) diff --git a/docs/nodes/images.md b/docs/nodes/images.md index d09936f2d..84c1a3008 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 send --media`. +- Send media with optional captions via `clawdbot message send --media`. - Allow auto-replies from the web inbox to include media alongside text. - Keep per-type limits sane and predictable. ## CLI Surface -- `clawdbot send --media [--message ]` +- `clawdbot message send --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 send`. +- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message send`. - 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 9e3261089..42cf31cf2 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 send --provider telegram --to 123456789 "hi"`. +- Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`. ## Troubleshooting diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index 57ab0e2d9..4cec0dc62 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 send --media --gif-playback` + - CLI: `clawdbot message send --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 e061f8c17..6e1e95c8a 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -561,7 +561,7 @@ Outbound attachments from the agent must include a `MEDIA:` line (o CLI sending: ```bash -clawdbot send --to +15555550123 --message "Here you go" --media /path/to/file.png +clawdbot message send --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 d523ba71c..b940dd738 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -153,7 +153,7 @@ In a new terminal: ```bash clawdbot health -clawdbot send --to +15555550123 --message "Hello from Clawdbot" +clawdbot message send --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 45ae122ee..cd256cc5a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -1,5 +1,5 @@ --- -summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, cron) replacing legacy `clawdbot-*` skills" +summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, message, cron) replacing legacy `clawdbot-*` skills" read_when: - Adding or modifying agent tools - Retiring or changing `clawdbot-*` skills @@ -148,6 +148,17 @@ Notes: - Only available when `agent.imageModel` is configured (primary or fallbacks). - Uses the image model directly (independent of the main chat model). +### `message` +Send messages and polls across providers. + +Core actions: +- `send` (text + optional media) +- `poll` (WhatsApp/Discord polls) + +Notes: +- `send` routes WhatsApp via the Gateway and other providers directly. +- `poll` always routes via the Gateway. + ### `cron` Manage Gateway cron jobs and wakeups. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 59c4bc8db..0d6888627 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -7,6 +7,7 @@ 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"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; @@ -35,6 +36,7 @@ export function createClawdbotTools(options?: { createNodesTool(), createCronTool(), createDiscordTool(), + createMessageTool(), createSlackTool({ agentAccountId: options?.agentAccountId, config: options?.config, diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index d36113c07..94e8fcd2b 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -66,7 +66,7 @@ describe("createClawdbotCodingTools", () => { it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); - const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"]; + const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"]; const collectActionValues = ( schema: unknown, diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index ef95c8595..6db12608e 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -150,6 +150,14 @@ "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] } } }, + "message": { + "emoji": "✉️", + "title": "Message", + "actions": { + "send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] }, + "poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] } + } + }, "agents_list": { "emoji": "🧭", "title": "Agents", diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts new file mode 100644 index 000000000..9f8ba566a --- /dev/null +++ b/src/agents/tools/message-tool.ts @@ -0,0 +1,112 @@ +import { Type } from "@sinclair/typebox"; + +import { + sendMessage, + sendPoll, + type MessagePollResult, + type MessageSendResult, +} from "../../infra/outbound/message.js"; +import type { AnyAgentTool } from "./common.js"; +import { + jsonResult, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "./common.js"; + +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()), + provider: 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()), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), +}); + +export function createMessageTool(): AnyAgentTool { + return { + label: "Message", + name: "message", + description: + "Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.", + parameters: MessageToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const action = readStringParam(params, "action", { required: true }); + const gateway = { + url: readStringParam(params, "gatewayUrl", { trim: false }), + token: readStringParam(params, "gatewayToken", { trim: false }), + timeoutMs: readNumberParam(params, "timeoutMs"), + clientName: "agent" as const, + mode: "agent" as const, + }; + const dryRun = Boolean(params.dryRun); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "mediaUrl", { trim: false }); + const provider = readStringParam(params, "provider"); + const accountId = readStringParam(params, "accountId"); + const gifPlayback = + typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; + const bestEffort = + typeof params.bestEffort === "boolean" ? params.bestEffort : undefined; + + const result: MessageSendResult = await sendMessage({ + to, + content, + mediaUrl: mediaUrl || undefined, + provider: provider || undefined, + accountId: accountId || undefined, + gifPlayback, + dryRun, + bestEffort, + gateway, + }); + return jsonResult(result); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "question", { required: true }); + const options = + readStringArrayParam(params, "options", { required: true }) ?? []; + const maxSelections = readNumberParam(params, "maxSelections", { + integer: true, + }); + const durationHours = readNumberParam(params, "durationHours", { + integer: true, + }); + const provider = readStringParam(params, "provider"); + + const result: MessagePollResult = await sendPoll({ + to, + question, + options, + maxSelections, + durationHours, + provider: provider || undefined, + dryRun, + gateway, + }); + return jsonResult(result); + } + + throw new Error(`Unknown action: ${action}`); + }, + }; +} diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index bae206ad4..7f8d676cb 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 sendCommand = vi.fn(); +const messageSendCommand = vi.fn(); const statusCommand = vi.fn(); const configureCommand = vi.fn(); const setupCommand = vi.fn(); @@ -18,7 +18,10 @@ const runtime = { }), }; -vi.mock("../commands/send.js", () => ({ sendCommand })); +vi.mock("../commands/message.js", () => ({ + messageSendCommand, + messagePollCommand: vi.fn(), +})); vi.mock("../commands/status.js", () => ({ statusCommand })); vi.mock("../commands/configure.js", () => ({ configureCommand })); vi.mock("../commands/setup.js", () => ({ setupCommand })); @@ -43,12 +46,12 @@ describe("cli program", () => { vi.clearAllMocks(); }); - it("runs send with required options", async () => { + it("runs message send with required options", async () => { const program = buildProgram(); - await program.parseAsync(["send", "--to", "+1", "--message", "hi"], { + await program.parseAsync(["message", "send", "--to", "+1", "--message", "hi"], { from: "user", }); - expect(sendCommand).toHaveBeenCalled(); + expect(messageSendCommand).toHaveBeenCalled(); }); it("runs status command", async () => { diff --git a/src/cli/program.ts b/src/cli/program.ts index bccfd049e..1ab75732f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -9,8 +9,7 @@ import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; import { onboardCommand } from "../commands/onboard.js"; -import { pollCommand } from "../commands/poll.js"; -import { sendCommand } from "../commands/send.js"; +import { messagePollCommand, messageSendCommand } from "../commands/message.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; import { statusCommand } from "../commands/status.js"; @@ -146,7 +145,7 @@ export function buildProgram() { "Link personal WhatsApp Web and show QR + connection logs.", ], [ - 'clawdbot send --to +15555550123 --message "Hi" --json', + 'clawdbot message send --to +15555550123 --message "Hi" --json', "Send via your web session and print JSON result.", ], ["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."], @@ -164,7 +163,7 @@ export function buildProgram() { "Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.", ], [ - 'clawdbot send --provider telegram --to @mychat --message "Hi"', + 'clawdbot message send --provider telegram --to @mychat --message "Hi"', "Send via your Telegram bot.", ], ] as const; @@ -402,7 +401,18 @@ export function buildProgram() { } }); - program + const message = 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)", @@ -433,16 +443,16 @@ export function buildProgram() { "after", ` Examples: - clawdbot send --to +15555550123 --message "Hi" - clawdbot send --to +15555550123 --message "Hi" --media photo.jpg - clawdbot send --to +15555550123 --message "Hi" --dry-run # print payload only - clawdbot send --to +15555550123 --message "Hi" --json # machine-readable result`, + 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`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { - await sendCommand( + await messageSendCommand( { ...opts, account: opts.account as string | undefined, @@ -456,7 +466,7 @@ Examples: } }); - program + message .command("poll") .description("Create a poll via WhatsApp or Discord") .requiredOption( @@ -489,16 +499,16 @@ Examples: "after", ` Examples: - clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" - clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 - clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord - clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`, + 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 pollCommand(opts, deps, defaultRuntime); + await messagePollCommand(opts, deps, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/send.test.ts b/src/commands/message.test.ts similarity index 71% rename from src/commands/send.test.ts rename to src/commands/message.test.ts index 0cf52f259..66dea2931 100644 --- a/src/commands/send.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 { sendCommand } from "./send.js"; +import { messagePollCommand, messageSendCommand } from "./message.js"; let testConfig: Record = {}; vi.mock("../config/config.js", async (importOriginal) => { @@ -51,10 +51,10 @@ const makeDeps = (overrides: Partial = {}): CliDeps => ({ ...overrides, }); -describe("sendCommand", () => { +describe("messageSendCommand", () => { it("skips send on dry-run", async () => { const deps = makeDeps(); - await sendCommand( + await messageSendCommand( { to: "+1", message: "hi", @@ -69,7 +69,7 @@ describe("sendCommand", () => { it("sends via gateway", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); const deps = makeDeps(); - await sendCommand( + await messageSendCommand( { to: "+1", message: "hi", @@ -87,7 +87,7 @@ describe("sendCommand", () => { gateway: { mode: "remote", remote: { url: "wss://remote.example" } }, }; const deps = makeDeps(); - await sendCommand( + await messageSendCommand( { to: "+1", message: "hi", @@ -105,7 +105,7 @@ describe("sendCommand", () => { callGatewayMock.mockClear(); callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); const deps = makeDeps(); - await sendCommand( + await messageSendCommand( { to: "+1", message: "hi", @@ -129,7 +129,7 @@ describe("sendCommand", () => { .mockResolvedValue({ messageId: "t1", chatId: "123" }), }); testConfig = { telegram: { botToken: "token-abc" } }; - await sendCommand( + await messageSendCommand( { to: "123", message: "hi", provider: "telegram" }, deps, runtime, @@ -150,7 +150,7 @@ describe("sendCommand", () => { .fn() .mockResolvedValue({ messageId: "t1", chatId: "123" }), }); - await sendCommand( + await messageSendCommand( { to: "123", message: "hi", provider: "telegram" }, deps, runtime, @@ -168,7 +168,7 @@ describe("sendCommand", () => { .fn() .mockResolvedValue({ messageId: "d1", channelId: "chan" }), }); - await sendCommand( + await messageSendCommand( { to: "channel:chan", message: "hi", provider: "discord" }, deps, runtime, @@ -185,7 +185,7 @@ describe("sendCommand", () => { const deps = makeDeps({ sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }), }); - await sendCommand( + await messageSendCommand( { to: "+15551234567", message: "hi", provider: "signal" }, deps, runtime, @@ -204,7 +204,7 @@ describe("sendCommand", () => { .fn() .mockResolvedValue({ messageId: "s1", channelId: "C123" }), }); - await sendCommand( + await messageSendCommand( { to: "channel:C123", message: "hi", provider: "slack" }, deps, runtime, @@ -221,7 +221,7 @@ describe("sendCommand", () => { const deps = makeDeps({ sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }), }); - await sendCommand( + await messageSendCommand( { to: "chat_id:42", message: "hi", provider: "imessage" }, deps, runtime, @@ -237,7 +237,7 @@ describe("sendCommand", () => { it("emits json output", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" }); const deps = makeDeps(); - await sendCommand( + await messageSendCommand( { to: "+1", message: "hi", @@ -251,3 +251,88 @@ describe("sendCommand", () => { ); }); }); + +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"], + provider: "discord", + json: true, + }, + 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, + }); + }); +}); diff --git a/src/commands/message.ts b/src/commands/message.ts new file mode 100644 index 000000000..63ce103fe --- /dev/null +++ b/src/commands/message.ts @@ -0,0 +1,240 @@ +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 { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; +import { + buildOutboundDeliveryJson, + formatGatewaySummary, + formatOutboundDeliverySummary, +} from "../infra/outbound/format.js"; +import { + sendMessage, + sendPoll, + type MessagePollResult, + type MessageSendResult, +} from "../infra/outbound/message.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { normalizeMessageProvider } from "../utils/message-provider.js"; + +type MessageSendOpts = { + to: string; + message: string; + provider?: string; + json?: boolean; + dryRun?: boolean; + media?: string; + gifPlayback?: boolean; + account?: string; +}; + +type MessagePollOpts = { + to: string; + question: string; + option: string[]; + maxSelections?: string; + durationHours?: string; + provider?: string; + json?: boolean; + dryRun?: boolean; +}; + +function parseIntOption(value: unknown, label: string): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.trim().length === 0) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${label} must be a number`); + } + return parsed; +} + +function logSendDryRun(opts: MessageSendOpts, provider: string, runtime: RuntimeEnv) { + runtime.log( + `[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${ + opts.media ? ` (media ${opts.media})` : "" + }`, + ); +} + +function logPollDryRun( + result: MessagePollResult, + runtime: RuntimeEnv, +) { + runtime.log( + `[dry-run] would send poll via ${result.provider} -> ${result.to}:\n Question: ${result.question}\n Options: ${result.options.join( + ", ", + )}\n Max selections: ${result.maxSelections}`, + ); +} + +function logSendResult( + result: MessageSendResult, + opts: MessageSendOpts, + runtime: RuntimeEnv, +) { + if (result.via === "direct") { + const summary = formatOutboundDeliverySummary( + result.provider, + result.result, + ); + runtime.log(success(summary)); + if (opts.json) { + runtime.log( + JSON.stringify( + buildOutboundDeliveryJson({ + provider: result.provider, + via: "direct", + to: opts.to, + result: result.result, + mediaUrl: opts.media ?? null, + }), + null, + 2, + ), + ); + } + return; + } + + const gatewayResult = result.result as { messageId?: string } | undefined; + runtime.log( + success( + formatGatewaySummary({ + provider: result.provider, + messageId: gatewayResult?.messageId ?? null, + }), + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + buildOutboundResultEnvelope({ + delivery: buildOutboundDeliveryJson({ + provider: result.provider, + via: "gateway", + to: opts.to, + result: gatewayResult, + mediaUrl: opts.media ?? null, + }), + }), + null, + 2, + ), + ); + } +} + +export async function messageSendCommand( + opts: MessageSendOpts, + deps: CliDeps, + runtime: RuntimeEnv, +) { + const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; + if (opts.dryRun) { + logSendDryRun(opts, provider, runtime); + 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, + 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, + provider, + dryRun: true, + gateway: { clientName: "cli", mode: "cli" }, + }); + logPollDryRun(result, 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, + 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: opts.to, + result: result.result, + mediaUrl: null, + }), + }), + question: result.question, + options: result.options, + maxSelections: result.maxSelections, + durationHours: result.durationHours, + }, + null, + 2, + ), + ); + } +} diff --git a/src/commands/poll.test.ts b/src/commands/poll.test.ts deleted file mode 100644 index 89b8b5e07..000000000 --- a/src/commands/poll.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { CliDeps } from "../cli/deps.js"; -import { pollCommand } from "./poll.js"; - -let testConfig: Record = {}; -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => testConfig, - }; -}); - -const callGatewayMock = vi.fn(); -vi.mock("../gateway/call.js", () => ({ - callGateway: (...args: unknown[]) => callGatewayMock(...args), - randomIdempotencyKey: () => "idem-1", -})); - -const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSlack: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), -}; - -describe("pollCommand", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - testConfig = {}; - }); - - it("routes through gateway", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); - await pollCommand( - { - 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 pollCommand( - { - 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 pollCommand( - { - to: "channel:C1", - question: "hi?", - option: ["y", "n"], - provider: "discord", - json: true, - }, - 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, - }); - }); -}); diff --git a/src/commands/poll.ts b/src/commands/poll.ts deleted file mode 100644 index d53b69a41..000000000 --- a/src/commands/poll.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { CliDeps } from "../cli/deps.js"; -import { withProgress } from "../cli/progress.js"; -import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; -import { success } from "../globals.js"; -import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; -import { - buildOutboundDeliveryJson, - formatGatewaySummary, -} from "../infra/outbound/format.js"; -import { normalizePollInput, type PollInput } from "../polls.js"; -import type { RuntimeEnv } from "../runtime.js"; - -function parseIntOption(value: unknown, label: string): number | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value !== "string" || value.trim().length === 0) return undefined; - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) { - throw new Error(`${label} must be a number`); - } - return parsed; -} - -export async function pollCommand( - opts: { - to: string; - question: string; - option: string[]; - maxSelections?: string; - durationHours?: string; - provider?: string; - json?: boolean; - dryRun?: boolean; - }, - _deps: CliDeps, - runtime: RuntimeEnv, -) { - const provider = (opts.provider ?? "whatsapp").toLowerCase(); - if (provider !== "whatsapp" && provider !== "discord") { - throw new Error(`Unsupported poll provider: ${provider}`); - } - - const maxSelections = parseIntOption(opts.maxSelections, "max-selections"); - const durationHours = parseIntOption(opts.durationHours, "duration-hours"); - - const pollInput: PollInput = { - question: opts.question, - options: opts.option, - maxSelections, - durationHours, - }; - const maxOptions = provider === "discord" ? 10 : 12; - const normalized = normalizePollInput(pollInput, { maxOptions }); - - if (opts.dryRun) { - runtime.log( - `[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`, - ); - return; - } - - const result = await withProgress( - { - label: `Sending poll via ${provider}…`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await callGateway<{ - messageId: string; - toJid?: string; - channelId?: string; - }>({ - method: "poll", - params: { - to: opts.to, - question: normalized.question, - options: normalized.options, - maxSelections: normalized.maxSelections, - durationHours: normalized.durationHours, - provider, - idempotencyKey: randomIdempotencyKey(), - }, - timeoutMs: 10_000, - clientName: "cli", - mode: "cli", - }), - ); - - runtime.log( - success( - formatGatewaySummary({ - action: "Poll sent", - provider, - messageId: result.messageId ?? null, - }), - ), - ); - if (opts.json) { - runtime.log( - JSON.stringify( - { - ...buildOutboundResultEnvelope({ - delivery: buildOutboundDeliveryJson({ - provider, - via: "gateway", - to: opts.to, - result, - mediaUrl: null, - }), - }), - question: normalized.question, - options: normalized.options, - maxSelections: normalized.maxSelections, - durationHours: normalized.durationHours ?? null, - }, - null, - 2, - ), - ); - } -} diff --git a/src/commands/send.ts b/src/commands/send.ts deleted file mode 100644 index f2cd1c159..000000000 --- a/src/commands/send.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { CliDeps } from "../cli/deps.js"; -import { withProgress } from "../cli/progress.js"; -import { loadConfig } from "../config/config.js"; -import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; -import { success } from "../globals.js"; -import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; -import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; -import { - buildOutboundDeliveryJson, - formatGatewaySummary, - formatOutboundDeliverySummary, -} from "../infra/outbound/format.js"; -import { resolveOutboundTarget } from "../infra/outbound/targets.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { normalizeMessageProvider } from "../utils/message-provider.js"; - -export async function sendCommand( - opts: { - to: string; - message: string; - provider?: string; - json?: boolean; - dryRun?: boolean; - media?: string; - gifPlayback?: boolean; - account?: string; - }, - deps: CliDeps, - runtime: RuntimeEnv, -) { - const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; - - if (opts.dryRun) { - runtime.log( - `[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, - ); - return; - } - - if ( - provider === "telegram" || - provider === "discord" || - provider === "slack" || - provider === "signal" || - provider === "imessage" - ) { - const resolvedTarget = resolveOutboundTarget({ - provider, - to: opts.to, - }); - if (!resolvedTarget.ok) { - throw resolvedTarget.error; - } - const results = await withProgress( - { - label: `Sending via ${provider}…`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => - await deliverOutboundPayloads({ - cfg: loadConfig(), - provider, - to: resolvedTarget.to, - payloads: [{ text: opts.message, mediaUrl: opts.media }], - deps: { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, - }, - }), - ); - const last = results.at(-1); - const summary = formatOutboundDeliverySummary(provider, last); - runtime.log(success(summary)); - if (opts.json) { - runtime.log( - JSON.stringify( - buildOutboundDeliveryJson({ - provider, - via: "direct", - to: opts.to, - result: last, - mediaUrl: opts.media, - }), - null, - 2, - ), - ); - } - return; - } - - // Always send via gateway over WS to avoid multi-session corruption. - const sendViaGateway = async () => - callGateway<{ - messageId: string; - }>({ - method: "send", - params: { - to: opts.to, - message: opts.message, - mediaUrl: opts.media, - gifPlayback: opts.gifPlayback, - accountId: opts.account, - provider, - idempotencyKey: randomIdempotencyKey(), - }, - timeoutMs: 10_000, - clientName: "cli", - mode: "cli", - }); - - const result = await withProgress( - { - label: `Sending via ${provider}…`, - indeterminate: true, - enabled: opts.json !== true, - }, - async () => await sendViaGateway(), - ); - - runtime.log( - success( - formatGatewaySummary({ provider, messageId: result.messageId ?? null }), - ), - ); - if (opts.json) { - runtime.log( - JSON.stringify( - buildOutboundResultEnvelope({ - delivery: buildOutboundDeliveryJson({ - provider, - via: "gateway", - to: opts.to, - result, - mediaUrl: opts.media ?? null, - }), - }), - null, - 2, - ), - ); - } -} diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts new file mode 100644 index 000000000..e283a59be --- /dev/null +++ b/src/infra/outbound/message.ts @@ -0,0 +1,229 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; +import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; +import type { PollInput } from "../../polls.js"; +import { normalizePollInput } from "../../polls.js"; +import { normalizeMessageProvider } from "../../utils/message-provider.js"; +import { + deliverOutboundPayloads, + type OutboundDeliveryResult, + type OutboundSendDeps, +} from "./deliver.js"; +import { resolveOutboundTarget } from "./targets.js"; + +type GatewayCallMode = "cli" | "agent"; + +export type MessageGatewayOptions = { + url?: string; + token?: string; + timeoutMs?: number; + clientName?: GatewayCallMode; + mode?: GatewayCallMode; +}; + +type MessageSendParams = { + to: string; + content: string; + provider?: string; + mediaUrl?: string; + gifPlayback?: boolean; + accountId?: string; + dryRun?: boolean; + bestEffort?: boolean; + deps?: OutboundSendDeps; + cfg?: ClawdbotConfig; + gateway?: MessageGatewayOptions; + idempotencyKey?: string; +}; + +export type MessageSendResult = { + provider: string; + to: string; + via: "direct" | "gateway"; + mediaUrl: string | null; + result?: OutboundDeliveryResult | { messageId: string }; + dryRun?: boolean; +}; + +type MessagePollParams = { + to: string; + question: string; + options: string[]; + maxSelections?: number; + durationHours?: number; + provider?: string; + dryRun?: boolean; + cfg?: ClawdbotConfig; + gateway?: MessageGatewayOptions; + idempotencyKey?: string; +}; + +export type MessagePollResult = { + provider: string; + to: string; + question: string; + options: string[]; + maxSelections: number; + durationHours: number | null; + via: "gateway"; + result?: { + messageId: string; + toJid?: string; + channelId?: string; + }; + dryRun?: boolean; +}; + +function resolveGatewayOptions(opts?: MessageGatewayOptions) { + return { + url: opts?.url, + token: opts?.token, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 10_000, + clientName: opts?.clientName ?? "cli", + mode: opts?.mode ?? "cli", + }; +} + +export async function sendMessage( + params: MessageSendParams, +): Promise { + const provider = normalizeMessageProvider(params.provider) ?? "whatsapp"; + const cfg = params.cfg ?? loadConfig(); + + if (params.dryRun) { + return { + provider, + to: params.to, + via: provider === "whatsapp" ? "gateway" : "direct", + mediaUrl: params.mediaUrl ?? null, + dryRun: true, + }; + } + + if ( + provider === "telegram" || + provider === "discord" || + provider === "slack" || + provider === "signal" || + provider === "imessage" + ) { + const resolvedTarget = resolveOutboundTarget({ + provider, + to: params.to, + }); + if (!resolvedTarget.ok) throw resolvedTarget.error; + + const results = await deliverOutboundPayloads({ + cfg, + provider, + to: resolvedTarget.to, + accountId: params.accountId, + payloads: [{ text: params.content, mediaUrl: params.mediaUrl }], + deps: params.deps, + bestEffort: params.bestEffort, + }); + + return { + provider, + to: params.to, + via: "direct", + mediaUrl: params.mediaUrl ?? null, + result: results.at(-1), + }; + } + + const gateway = resolveGatewayOptions(params.gateway); + const result = await callGateway<{ messageId: string }>({ + url: gateway.url, + token: gateway.token, + method: "send", + params: { + to: params.to, + message: params.content, + mediaUrl: params.mediaUrl, + gifPlayback: params.gifPlayback, + accountId: params.accountId, + provider, + idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(), + }, + timeoutMs: gateway.timeoutMs, + clientName: gateway.clientName, + mode: gateway.mode, + }); + + return { + provider, + to: params.to, + via: "gateway", + mediaUrl: params.mediaUrl ?? null, + result, + }; +} + +export async function sendPoll( + params: MessagePollParams, +): Promise { + const provider = (params.provider ?? "whatsapp").toLowerCase(); + if (provider !== "whatsapp" && provider !== "discord") { + throw new Error(`Unsupported poll provider: ${provider}`); + } + + const pollInput: PollInput = { + question: params.question, + options: params.options, + maxSelections: params.maxSelections, + durationHours: params.durationHours, + }; + const maxOptions = provider === "discord" ? 10 : 12; + const normalized = normalizePollInput(pollInput, { maxOptions }); + + if (params.dryRun) { + return { + provider, + to: params.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours ?? null, + via: "gateway", + dryRun: true, + }; + } + + const gateway = resolveGatewayOptions(params.gateway); + const result = await callGateway<{ + messageId: string; + toJid?: string; + channelId?: string; + }>({ + url: gateway.url, + token: gateway.token, + method: "poll", + params: { + to: params.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours, + provider, + idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(), + }, + timeoutMs: gateway.timeoutMs, + clientName: gateway.clientName, + mode: gateway.mode, + }); + + return { + provider, + to: params.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours ?? null, + via: "gateway", + result, + }; +}