feat: add message tool and CLI
This commit is contained in:
@@ -92,17 +92,17 @@
|
|||||||
- Voice wake forwarding tips:
|
- 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.
|
- 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`.
|
- 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
|
## 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
|
```bash
|
||||||
# WRONG - will send "Hello\\!" with backslash
|
# 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
|
# CORRECT - use heredoc to avoid escaping
|
||||||
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||||
Hello!
|
Hello!
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
- Commands: accept /models as an alias for /model.
|
- Commands: accept /models as an alias for /model.
|
||||||
- Debugging: add raw model stream logging flags and document gateway watch mode.
|
- 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).
|
- 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.
|
- 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
|
- 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
|
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ clawdbot onboard --install-daemon
|
|||||||
clawdbot gateway --port 18789 --verbose
|
clawdbot gateway --port 18789 --verbose
|
||||||
|
|
||||||
# Send a message
|
# 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)
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
clawdbot agent --message "Ship checklist" --thinking high
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ read_when:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# WhatsApp
|
# WhatsApp
|
||||||
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
|
clawdbot message 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 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
|
clawdbot message 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 "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
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`.
|
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).
|
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`).
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
info
|
info
|
||||||
check
|
check
|
||||||
|
message
|
||||||
send
|
send
|
||||||
poll
|
poll
|
||||||
agent
|
agent
|
||||||
@@ -283,7 +284,7 @@ Options:
|
|||||||
|
|
||||||
## Messaging + agent
|
## Messaging + agent
|
||||||
|
|
||||||
### `send`
|
### `message send`
|
||||||
Send a message through a provider.
|
Send a message through a provider.
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
@@ -299,7 +300,7 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--verbose`
|
- `--verbose`
|
||||||
|
|
||||||
### `poll`
|
### `message poll`
|
||||||
Create a poll (WhatsApp or Discord).
|
Create a poll (WhatsApp or Discord).
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
|
|||||||
|
|
||||||
## CLI helpers
|
## CLI helpers
|
||||||
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
|
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
|
||||||
- `clawdbot send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
- `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
||||||
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
|
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
|
||||||
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
||||||
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
|
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ clawdbot gateway --port 19001
|
|||||||
Send a test message (requires a running Gateway):
|
Send a test message (requires a running Gateway):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot send --to +15555550123 --message "Hello from CLAWDBOT"
|
clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration (optional)
|
## Configuration (optional)
|
||||||
|
|||||||
@@ -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.
|
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
|
||||||
|
|
||||||
## Goals
|
## 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.
|
- Allow auto-replies from the web inbox to include media alongside text.
|
||||||
- Keep per-type limits sane and predictable.
|
- Keep per-type limits sane and predictable.
|
||||||
|
|
||||||
## CLI Surface
|
## CLI Surface
|
||||||
- `clawdbot send --media <path-or-url> [--message <caption>]`
|
- `clawdbot message send --media <path-or-url> [--message <caption>]`
|
||||||
- `--media` optional; caption can be empty for media-only sends.
|
- `--media` optional; caption can be empty for media-only sends.
|
||||||
- `--dry-run` prints the resolved payload; `--json` emits `{ provider, to, messageId, mediaUrl, caption }`.
|
- `--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
|
## Auto-Reply Pipeline
|
||||||
- `getReplyFromConfig` returns `{ text?, mediaUrl?, mediaUrls? }`.
|
- `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.
|
- Multiple media entries are sent sequentially if provided.
|
||||||
|
|
||||||
## Inbound Media to Commands (Pi)
|
## Inbound Media to Commands (Pi)
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
|||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
- 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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ Behavior:
|
|||||||
- Caption only on first media item.
|
- Caption only on first media item.
|
||||||
- Media fetch supports HTTP(S) and local paths.
|
- Media fetch supports HTTP(S) and local paths.
|
||||||
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
|
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
|
||||||
- CLI: `clawdbot send --media <mp4> --gif-playback`
|
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
||||||
- Gateway: `send` params include `gifPlayback: true`
|
- Gateway: `send` params include `gifPlayback: true`
|
||||||
|
|
||||||
## Media limits + optimization
|
## Media limits + optimization
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
|
|||||||
CLI sending:
|
CLI sending:
|
||||||
|
|
||||||
```bash
|
```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).
|
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ In a new terminal:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot health
|
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.
|
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.
|
||||||
|
|||||||
@@ -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:
|
read_when:
|
||||||
- Adding or modifying agent tools
|
- Adding or modifying agent tools
|
||||||
- Retiring or changing `clawdbot-*` skills
|
- Retiring or changing `clawdbot-*` skills
|
||||||
@@ -148,6 +148,17 @@ Notes:
|
|||||||
- Only available when `agent.imageModel` is configured (primary or fallbacks).
|
- Only available when `agent.imageModel` is configured (primary or fallbacks).
|
||||||
- Uses the image model directly (independent of the main chat model).
|
- 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`
|
### `cron`
|
||||||
Manage Gateway cron jobs and wakeups.
|
Manage Gateway cron jobs and wakeups.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createCronTool } from "./tools/cron-tool.js";
|
|||||||
import { createDiscordTool } from "./tools/discord-tool.js";
|
import { createDiscordTool } from "./tools/discord-tool.js";
|
||||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||||
import { createImageTool } from "./tools/image-tool.js";
|
import { createImageTool } from "./tools/image-tool.js";
|
||||||
|
import { createMessageTool } from "./tools/message-tool.js";
|
||||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||||
@@ -35,6 +36,7 @@ export function createClawdbotTools(options?: {
|
|||||||
createNodesTool(),
|
createNodesTool(),
|
||||||
createCronTool(),
|
createCronTool(),
|
||||||
createDiscordTool(),
|
createDiscordTool(),
|
||||||
|
createMessageTool(),
|
||||||
createSlackTool({
|
createSlackTool({
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
|
|
||||||
it("preserves action enums in normalized schemas", () => {
|
it("preserves action enums in normalized schemas", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
const tools = createClawdbotCodingTools();
|
||||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"];
|
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||||
|
|
||||||
const collectActionValues = (
|
const collectActionValues = (
|
||||||
schema: unknown,
|
schema: unknown,
|
||||||
|
|||||||
@@ -150,6 +150,14 @@
|
|||||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
"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": {
|
"agents_list": {
|
||||||
"emoji": "🧭",
|
"emoji": "🧭",
|
||||||
"title": "Agents",
|
"title": "Agents",
|
||||||
|
|||||||
112
src/agents/tools/message-tool.ts
Normal file
112
src/agents/tools/message-tool.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const sendCommand = vi.fn();
|
const messageSendCommand = vi.fn();
|
||||||
const statusCommand = vi.fn();
|
const statusCommand = vi.fn();
|
||||||
const configureCommand = vi.fn();
|
const configureCommand = vi.fn();
|
||||||
const setupCommand = 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/status.js", () => ({ statusCommand }));
|
||||||
vi.mock("../commands/configure.js", () => ({ configureCommand }));
|
vi.mock("../commands/configure.js", () => ({ configureCommand }));
|
||||||
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
||||||
@@ -43,12 +46,12 @@ describe("cli program", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs send with required options", async () => {
|
it("runs message send with required options", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
await program.parseAsync(["message", "send", "--to", "+1", "--message", "hi"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(sendCommand).toHaveBeenCalled();
|
expect(messageSendCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs status command", async () => {
|
it("runs status command", async () => {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { configureCommand } from "../commands/configure.js";
|
|||||||
import { doctorCommand } from "../commands/doctor.js";
|
import { doctorCommand } from "../commands/doctor.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
import { onboardCommand } from "../commands/onboard.js";
|
import { onboardCommand } from "../commands/onboard.js";
|
||||||
import { pollCommand } from "../commands/poll.js";
|
import { messagePollCommand, messageSendCommand } from "../commands/message.js";
|
||||||
import { sendCommand } from "../commands/send.js";
|
|
||||||
import { sessionsCommand } from "../commands/sessions.js";
|
import { sessionsCommand } from "../commands/sessions.js";
|
||||||
import { setupCommand } from "../commands/setup.js";
|
import { setupCommand } from "../commands/setup.js";
|
||||||
import { statusCommand } from "../commands/status.js";
|
import { statusCommand } from "../commands/status.js";
|
||||||
@@ -146,7 +145,7 @@ export function buildProgram() {
|
|||||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
"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.",
|
"Send via your web session and print JSON result.",
|
||||||
],
|
],
|
||||||
["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."],
|
["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.",
|
"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.",
|
"Send via your Telegram bot.",
|
||||||
],
|
],
|
||||||
] as const;
|
] 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")
|
.command("send")
|
||||||
.description(
|
.description(
|
||||||
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
|
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
|
||||||
@@ -433,16 +443,16 @@ export function buildProgram() {
|
|||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
clawdbot send --to +15555550123 --message "Hi"
|
clawdbot message send --to +15555550123 --message "Hi"
|
||||||
clawdbot send --to +15555550123 --message "Hi" --media photo.jpg
|
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
|
||||||
clawdbot send --to +15555550123 --message "Hi" --dry-run # print payload only
|
clawdbot message 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" --json # machine-readable result`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
...opts,
|
...opts,
|
||||||
account: opts.account as string | undefined,
|
account: opts.account as string | undefined,
|
||||||
@@ -456,7 +466,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
message
|
||||||
.command("poll")
|
.command("poll")
|
||||||
.description("Create a poll via WhatsApp or Discord")
|
.description("Create a poll via WhatsApp or Discord")
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
@@ -489,16 +499,16 @@ Examples:
|
|||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
|
clawdbot message 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 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 message 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 "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await pollCommand(opts, deps, defaultRuntime);
|
await messagePollCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { sendCommand } from "./send.js";
|
import { messagePollCommand, messageSendCommand } from "./message.js";
|
||||||
|
|
||||||
let testConfig: Record<string, unknown> = {};
|
let testConfig: Record<string, unknown> = {};
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
@@ -51,10 +51,10 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendCommand", () => {
|
describe("messageSendCommand", () => {
|
||||||
it("skips send on dry-run", async () => {
|
it("skips send on dry-run", async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -69,7 +69,7 @@ describe("sendCommand", () => {
|
|||||||
it("sends via gateway", async () => {
|
it("sends via gateway", async () => {
|
||||||
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
|
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -87,7 +87,7 @@ describe("sendCommand", () => {
|
|||||||
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
|
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
|
||||||
};
|
};
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -105,7 +105,7 @@ describe("sendCommand", () => {
|
|||||||
callGatewayMock.mockClear();
|
callGatewayMock.mockClear();
|
||||||
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
|
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -129,7 +129,7 @@ describe("sendCommand", () => {
|
|||||||
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
||||||
});
|
});
|
||||||
testConfig = { telegram: { botToken: "token-abc" } };
|
testConfig = { telegram: { botToken: "token-abc" } };
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "123", message: "hi", provider: "telegram" },
|
{ to: "123", message: "hi", provider: "telegram" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -150,7 +150,7 @@ describe("sendCommand", () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "123", message: "hi", provider: "telegram" },
|
{ to: "123", message: "hi", provider: "telegram" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -168,7 +168,7 @@ describe("sendCommand", () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
|
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "channel:chan", message: "hi", provider: "discord" },
|
{ to: "channel:chan", message: "hi", provider: "discord" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -185,7 +185,7 @@ describe("sendCommand", () => {
|
|||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
|
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "+15551234567", message: "hi", provider: "signal" },
|
{ to: "+15551234567", message: "hi", provider: "signal" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -204,7 +204,7 @@ describe("sendCommand", () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
|
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "channel:C123", message: "hi", provider: "slack" },
|
{ to: "channel:C123", message: "hi", provider: "slack" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -221,7 +221,7 @@ describe("sendCommand", () => {
|
|||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
|
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{ to: "chat_id:42", message: "hi", provider: "imessage" },
|
{ to: "chat_id:42", message: "hi", provider: "imessage" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -237,7 +237,7 @@ describe("sendCommand", () => {
|
|||||||
it("emits json output", async () => {
|
it("emits json output", async () => {
|
||||||
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
await sendCommand(
|
await messageSendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
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<string, unknown>
|
||||||
|
| 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<string, unknown>;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
240
src/commands/message.ts
Normal file
240
src/commands/message.ts
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
|
||||||
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<string, unknown>
|
|
||||||
| 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<string, unknown>;
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
229
src/infra/outbound/message.ts
Normal file
229
src/infra/outbound/message.ts
Normal file
@@ -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<MessageSendResult> {
|
||||||
|
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<MessagePollResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user