feat: switch message cli to subcommands

This commit is contained in:
Peter Steinberger
2026-01-09 08:59:54 +01:00
parent 7ed53e243d
commit f9be9ad426
14 changed files with 561 additions and 263 deletions

View File

@@ -93,17 +93,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}`. Dont add extra quotes. - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps 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 apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message --action send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping. - For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
## Exclamation Mark Escaping Workaround ## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message --action 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 message --action 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 message --action send --to "+1234" --message "$(cat <<'EOF' clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello! Hello!
EOF EOF
)" )"

View File

@@ -5,7 +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 `message send`/`message poll` with `message --action ...`, and fold Discord/Slack/Telegram/WhatsApp tools into `message` (provider required unless only one configured). - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- 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

View File

@@ -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 message --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

View File

@@ -15,15 +15,15 @@ read_when:
```bash ```bash
# WhatsApp # WhatsApp
clawdbot message --action poll --to +15555550123 \ clawdbot message poll --to +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
clawdbot message --action poll --to 123456789@g.us \ clawdbot message poll --to 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord # Discord
clawdbot message --action poll --provider discord --to channel:123456789 \ clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message --action poll --provider discord --to channel:123456789 \ clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
``` ```

View File

@@ -13,12 +13,9 @@ Single outbound command for sending messages and provider actions
## Usage ## Usage
``` ```
clawdbot message --action <action> [--provider <name>] [flags] clawdbot message <subcommand> [flags]
``` ```
Defaults:
- `--action send`
Provider selection: Provider selection:
- `--provider` required if more than one provider is configured. - `--provider` required if more than one provider is configured.
- If exactly one provider is configured, it becomes the default. - If exactly one provider is configured, it becomes the default.
@@ -33,170 +30,124 @@ Target formats (`--to`):
## Common flags ## Common flags
- `--to <dest>` - `--provider <name>`
- `--message <text>` - `--account <id>`
- `--media <url>`
- `--message-id <id>`
- `--reply-to <id>`
- `--thread-id <id>` (Telegram forum thread)
- `--account <id>` (multi-account providers)
- `--dry-run`
- `--json` - `--json`
- `--dry-run`
- `--verbose` - `--verbose`
## Actions ## Actions
### `send` ### Core
Providers: whatsapp, telegram, discord, slack, signal, imessage
Required: `--to`, `--message`
Optional: `--media`, `--reply-to`, `--thread-id`, `--account`, `--gif-playback`
### `react` - `send`
Providers: discord, slack, telegram, whatsapp - Required: `--to`, `--message`
Required: `--to`, `--message-id` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--account`
### `reactions` - `poll`
Providers: discord, slack - Required: `--to`, `--poll-question`, `--poll-option` (repeat)
Required: `--to`, `--message-id` - Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
Optional: `--limit`
### `read` - `react`
Providers: discord, slack - Required: `--to`, `--message-id`
Required: `--to` - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
Optional: `--limit`, `--before`, `--after`, `--around`
### `edit` - `reactions`
Providers: discord, slack - Required: `--to`, `--message-id`
Required: `--to`, `--message-id`, `--message` - Optional: `--limit`, `--channel-id`
### `delete` - `read`
Providers: discord, slack - Required: `--to`
Required: `--to`, `--message-id` - Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
### `pin` - `edit`
Providers: discord, slack - Required: `--to`, `--message-id`, `--message`
Required: `--to`, `--message-id` - Optional: `--channel-id`
### `unpin` - `delete`
Providers: discord, slack - Required: `--to`, `--message-id`
Required: `--to`, `--message-id` - Optional: `--channel-id`
### `list-pins` - `pin` / `unpin`
Providers: discord, slack - Required: `--to`, `--message-id`
Required: `--to` - Optional: `--channel-id`
### `poll` - `pins` (list)
Providers: whatsapp, discord - Required: `--to`
Required: `--to`, `--poll-question`, `--poll-option` (repeat) - Optional: `--channel-id`
Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
### `sticker` - `permissions`
Providers: discord - Required: `--to`
Required: `--to`, `--sticker-id` (repeat) - Optional: `--channel-id`
Optional: `--message`
### `permissions` - `search`
Providers: discord - Required: `--guild-id`, `--query`
Required: `--to` (channel id) - Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
### `thread-create` ### Threads
Providers: discord
Required: `--to` (channel id), `--thread-name`
Optional: `--message-id`, `--auto-archive-min`
### `thread-list` - `thread create`
Providers: discord - Required: `--thread-name`, `--to` (channel id) or `--channel-id`
Required: `--guild-id` - Optional: `--message-id`, `--auto-archive-min`
Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
### `thread-reply` - `thread list`
Providers: discord - Required: `--guild-id`
Required: `--to` (thread id), `--message` - Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
Optional: `--media`, `--reply-to`
### `search` - `thread reply`
Providers: discord - Required: `--to` (thread id), `--message`
Required: `--guild-id`, `--query` - Optional: `--media`, `--reply-to`
Optional: `--channel-id`, `--channel-ids`, `--author-id`, `--author-ids`, `--limit`
### `member-info` ### Emojis
Providers: discord, slack
Required: `--user-id`
Discord only: also `--guild-id`
### `role-info` - `emoji list`
Providers: discord - Discord: `--guild-id`
Required: `--guild-id`
### `emoji-list` - `emoji upload`
Providers: discord, slack - Required: `--guild-id`, `--emoji-name`, `--media`
Discord only: `--guild-id` - Optional: `--role-ids` (repeat)
### `emoji-upload` ### Stickers
Providers: discord
Required: `--guild-id`, `--emoji-name`, `--media`
Optional: `--role-ids` (repeat)
### `sticker-upload` - `sticker send`
Providers: discord - Required: `--to`, `--sticker-id` (repeat)
Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media` - Optional: `--message`
### `role-add` - `sticker upload`
Providers: discord - Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
Required: `--guild-id`, `--user-id`, `--role-id`
### `role-remove` ### Roles / Channels / Members / Voice
Providers: discord
Required: `--guild-id`, `--user-id`, `--role-id`
### `channel-info` - `role info` (Discord): `--guild-id`
Providers: discord - `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
Required: `--channel-id` - `channel info` (Discord): `--channel-id`
- `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id`
### `channel-list` ### Events
Providers: discord
Required: `--guild-id`
### `voice-status` - `event list` (Discord): `--guild-id`
Providers: discord - `event create` (Discord): `--guild-id`, `--event-name`, `--start-time`
Required: `--guild-id`, `--user-id` - Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
### `event-list` ### Moderation (Discord)
Providers: discord
Required: `--guild-id`
### `event-create` - `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
Providers: discord - `kick`: `--guild-id`, `--user-id`
Required: `--guild-id`, `--event-name`, `--start-time` - `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
### `timeout`
Providers: discord
Required: `--guild-id`, `--user-id`
Optional: `--duration-min`, `--until`, `--reason`
### `kick`
Providers: discord
Required: `--guild-id`, `--user-id`
Optional: `--reason`
### `ban`
Providers: discord
Required: `--guild-id`, `--user-id`
Optional: `--reason`, `--delete-days`
## Examples ## Examples
Send a Discord reply: Send a Discord reply:
``` ```
clawdbot message --action send --provider discord \ clawdbot message send --provider discord \
--to channel:123 --message "hi" --reply-to 456 --to channel:123 --message "hi" --reply-to 456
``` ```
Create a Discord poll: Create a Discord poll:
``` ```
clawdbot message --action poll --provider discord \ clawdbot message poll --provider discord \
--to channel:123 \ --to channel:123 \
--poll-question "Snack?" \ --poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \ --poll-option Pizza --poll-option Sushi \
@@ -205,6 +156,6 @@ clawdbot message --action poll --provider discord \
React in Slack: React in Slack:
``` ```
clawdbot message --action react --provider slack \ clawdbot message react --provider slack \
--to C123 --message-id 456 --emoji "✅" --to C123 --message-id 456 --emoji "✅"
``` ```

View File

@@ -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 message --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).

View File

@@ -134,7 +134,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway): Send a test message (requires a running Gateway):
```bash ```bash
clawdbot message --to +15555550123 --message "Hello from CLAWDBOT" clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT"
``` ```
## Configuration (optional) ## Configuration (optional)

View File

@@ -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 message --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 message --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 message`. - 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)

View File

@@ -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 message --provider telegram --to 123456789 --message "hi"`. - Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`.
## Troubleshooting ## Troubleshooting

View File

@@ -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 message --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

View File

@@ -560,7 +560,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
CLI sending: CLI sending:
```bash ```bash
clawdbot message --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).

View File

@@ -152,7 +152,7 @@ In a new terminal:
```bash ```bash
clawdbot health clawdbot health
clawdbot message --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 wont be able to respond without it. If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent wont be able to respond without it.

View File

@@ -47,9 +47,12 @@ describe("cli program", () => {
it("runs message with required options", async () => { it("runs message with required options", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["message", "--to", "+1", "--message", "hi"], { await program.parseAsync(
from: "user", ["message", "send", "--to", "+1", "--message", "hi"],
}); {
from: "user",
},
);
expect(messageCommand).toHaveBeenCalled(); expect(messageCommand).toHaveBeenCalled();
}); });

View File

@@ -408,128 +408,472 @@ export function buildProgram() {
} }
}); });
program const message = program
.command("message") .command("message")
.description("Send messages and provider actions") .description("Send messages and provider actions")
.option(
"-a, --action <action>",
"Action: send|poll|react|reactions|read|edit|delete|pin|unpin|list-pins|permissions|thread-create|thread-list|thread-reply|search|sticker|member-info|role-info|emoji-list|emoji-upload|sticker-upload|role-add|role-remove|channel-info|channel-list|voice-status|event-list|event-create|timeout|kick|ban",
"send",
)
.option(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
)
.option("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--message-id <id>", "Message id (edit/delete/react/pin)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--account <id>", "Provider account id")
.option(
"--provider <provider>",
"Provider: whatsapp|telegram|discord|slack|signal|imessage",
)
.option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false)
.option("--limit <n>", "Result limit for read/reactions/search")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id (Discord)")
.option("--poll-question <text>", "Poll question")
.option(
"--poll-option <choice>",
"Poll option (repeat 2-12 times)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("--channel-id <id>", "Channel id")
.option(
"--channel-ids <id>",
"Channel id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--guild-id <id>", "Guild id")
.option("--user-id <id>", "User id")
.option("--author-id <id>", "Author id")
.option(
"--author-ids <id>",
"Author id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--role-id <id>", "Role id")
.option(
"--role-ids <id>",
"Role id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--emoji-name <name>", "Emoji name")
.option(
"--sticker-id <id>",
"Sticker id (repeat)",
(value: string, previous: string[]) => previous.concat([value]),
[] as string[],
)
.option("--sticker-name <name>", "Sticker name")
.option("--sticker-desc <text>", "Sticker description")
.option("--sticker-tags <tags>", "Sticker tags")
.option("--thread-name <name>", "Thread name")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.option("--query <text>", "Search query")
.option("--event-name <name>", "Event name")
.option("--event-type <stage|external|voice>", "Event type")
.option("--start-time <iso>", "Event start time")
.option("--end-time <iso>", "Event end time")
.option("--desc <text>", "Event description")
.option("--location <text>", "Event location")
.option("--duration-min <n>", "Timeout duration minutes")
.option("--until <iso>", "Timeout until")
.option("--reason <text>", "Moderation reason")
.option("--delete-days <n>", "Ban delete message days")
.option("--include-archived", "Include archived threads", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
clawdbot message --to +15555550123 --message "Hi" clawdbot message send --to +15555550123 --message "Hi"
clawdbot message --action send --to +15555550123 --message "Hi" --media photo.jpg clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message --action poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message --action react --provider discord --to 123 --message-id 456 --emoji "✅"`, clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅"`,
)
.action(() => {
message.help({ error: true });
});
const withMessageBase = (command: Command) =>
command
.option(
"--provider <provider>",
"Provider: whatsapp|telegram|discord|slack|signal|imessage",
)
.option("--account <id>", "Provider account id")
.option("--json", "Output result as JSON", false)
.option("--dry-run", "Print payload and skip sending", false)
.option("--verbose", "Verbose logging", false);
const withMessageTarget = (command: Command) =>
command.option(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const withRequiredMessageTarget = (command: Command) =>
command.requiredOption(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const runMessageAction = async (
action: string,
opts: Record<string, unknown>,
) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await messageCommand(
{
...opts,
action,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
};
withMessageBase(
withRequiredMessageTarget(
message
.command("send")
.description("Send a message")
.requiredOption("-m, --message <text>", "Message body"),
)
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
),
).action(async (opts) => {
await runMessageAction("send", opts);
});
withMessageBase(
withRequiredMessageTarget(
message.command("poll").description("Send a poll"),
),
)
.requiredOption("--poll-question <text>", "Poll question")
.option(
"--poll-option <choice>",
"Poll option (repeat 2-12 times)",
collectOption,
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("-m, --message <text>", "Optional message body")
.action(async (opts) => {
await runMessageAction("poll", opts);
});
withMessageBase(
withMessageTarget(
message.command("react").description("Add or remove a reaction"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("react", opts);
});
withMessageBase(
withMessageTarget(
message.command("reactions").description("List reactions on a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--limit <n>", "Result limit")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("reactions", opts);
});
withMessageBase(
withMessageTarget(
message.command("read").description("Read recent messages"),
),
)
.option("--limit <n>", "Result limit")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id (Discord)")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("read", opts);
});
withMessageBase(
withMessageTarget(
message
.command("edit")
.description("Edit a message")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("edit", opts);
});
withMessageBase(
withMessageTarget(
message.command("delete").description("Delete a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("delete", opts);
});
withMessageBase(
withMessageTarget(message.command("pin").description("Pin a message")),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("pin", opts);
});
withMessageBase(
withMessageTarget(message.command("unpin").description("Unpin a message")),
)
.option("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("unpin", opts);
});
withMessageBase(
withMessageTarget(
message.command("pins").description("List pinned messages"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("list-pins", opts);
});
withMessageBase(
withMessageTarget(
message.command("permissions").description("Fetch channel permissions"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("permissions", opts);
});
withMessageBase(
message.command("search").description("Search Discord messages"),
)
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--query <text>", "Search query")
.option("--channel-id <id>", "Channel id")
.option(
"--channel-ids <id>",
"Channel id (repeat)",
collectOption,
[] as string[],
)
.option("--author-id <id>", "Author id")
.option(
"--author-ids <id>",
"Author id (repeat)",
collectOption,
[] as string[],
)
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("search", opts);
});
const thread = message.command("thread").description("Thread actions");
withMessageBase(
withMessageTarget(
thread
.command("create")
.description("Create a thread")
.requiredOption("--thread-name <name>", "Thread name"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--message-id <id>", "Message id (optional)")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.action(async (opts) => {
await runMessageAction("thread-create", opts);
});
withMessageBase(
thread
.command("list")
.description("List threads")
.requiredOption("--guild-id <id>", "Guild id"),
)
.option("--channel-id <id>", "Channel id")
.option("--include-archived", "Include archived threads", false)
.option("--before <id>", "Read/search before id")
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("thread-list", opts);
});
withMessageBase(
withRequiredMessageTarget(
thread
.command("reply")
.description("Reply in a thread")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--reply-to <id>", "Reply-to message id")
.action(async (opts) => {
await runMessageAction("thread-reply", opts);
});
const emoji = message.command("emoji").description("Emoji actions");
withMessageBase(emoji.command("list").description("List emojis"))
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("emoji-list", opts);
});
withMessageBase(
emoji
.command("upload")
.description("Upload an emoji")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--emoji-name <name>", "Emoji name")
.requiredOption("--media <path-or-url>", "Emoji media (path or URL)")
.option(
"--role-ids <id>",
"Role id (repeat)",
collectOption,
[] as string[],
) )
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); await runMessageAction("emoji-upload", opts);
const deps = createDefaultDeps(); });
try {
await messageCommand( const sticker = message.command("sticker").description("Sticker actions");
{ withMessageBase(
...opts, withRequiredMessageTarget(
account: opts.account as string | undefined, sticker.command("send").description("Send stickers"),
}, ),
deps, )
defaultRuntime, .requiredOption("--sticker-id <id>", "Sticker id (repeat)", collectOption)
); .option("-m, --message <text>", "Optional message body")
} catch (err) { .action(async (opts) => {
defaultRuntime.error(String(err)); await runMessageAction("sticker", opts);
defaultRuntime.exit(1); });
}
withMessageBase(
sticker
.command("upload")
.description("Upload a sticker")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--sticker-name <name>", "Sticker name")
.requiredOption("--sticker-desc <text>", "Sticker description")
.requiredOption("--sticker-tags <tags>", "Sticker tags")
.requiredOption("--media <path-or-url>", "Sticker media (path or URL)")
.action(async (opts) => {
await runMessageAction("sticker-upload", opts);
});
const role = message.command("role").description("Role actions");
withMessageBase(
role
.command("info")
.description("List roles")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("role-info", opts);
});
withMessageBase(
role
.command("add")
.description("Add role to a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-add", opts);
});
withMessageBase(
role
.command("remove")
.description("Remove role from a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-remove", opts);
});
const channel = message.command("channel").description("Channel actions");
withMessageBase(
channel
.command("info")
.description("Fetch channel info")
.requiredOption("--channel-id <id>", "Channel id"),
).action(async (opts) => {
await runMessageAction("channel-info", opts);
});
withMessageBase(
channel
.command("list")
.description("List channels")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("channel-list", opts);
});
const member = message.command("member").description("Member actions");
withMessageBase(
member
.command("info")
.description("Fetch member info")
.requiredOption("--user-id <id>", "User id"),
)
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("member-info", opts);
});
const voice = message.command("voice").description("Voice actions");
withMessageBase(
voice
.command("status")
.description("Fetch voice status")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
).action(async (opts) => {
await runMessageAction("voice-status", opts);
});
const event = message.command("event").description("Event actions");
withMessageBase(
event
.command("list")
.description("List scheduled events")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("event-list", opts);
});
withMessageBase(
event
.command("create")
.description("Create a scheduled event")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--event-name <name>", "Event name")
.requiredOption("--start-time <iso>", "Event start time"),
)
.option("--end-time <iso>", "Event end time")
.option("--desc <text>", "Event description")
.option("--channel-id <id>", "Channel id")
.option("--location <text>", "Event location")
.option("--event-type <stage|external|voice>", "Event type")
.action(async (opts) => {
await runMessageAction("event-create", opts);
});
withMessageBase(
message
.command("timeout")
.description("Timeout a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--duration-min <n>", "Timeout duration minutes")
.option("--until <iso>", "Timeout until")
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("timeout", opts);
});
withMessageBase(
message
.command("kick")
.description("Kick a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("kick", opts);
});
withMessageBase(
message
.command("ban")
.description("Ban a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.option("--delete-days <n>", "Ban delete message days")
.action(async (opts) => {
await runMessageAction("ban", opts);
}); });
program program