refactor: require target for message actions

This commit is contained in:
Peter Steinberger 2026-01-17 04:06:14 +00:00
parent 87cecd0268
commit 6e4d86f426
38 changed files with 517 additions and 184 deletions

View File

@ -11,6 +11,7 @@
### Breaking ### Breaking
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. - **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations).
### Changes ### Changes
- Tools: improve `web_fetch` extraction using Readability (with fallback). - Tools: improve `web_fetch` extraction using Readability (with fallback).

View File

@ -16,19 +16,19 @@ read_when:
```bash ```bash
# WhatsApp # WhatsApp
clawdbot message poll --to +15555550123 \ clawdbot message poll --target +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 poll --to 123456789@g.us \ clawdbot message poll --target 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 poll --channel discord --to channel:123456789 \ clawdbot message poll --channel discord --target channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --channel discord --to channel:123456789 \ clawdbot message poll --channel discord --target 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
# MS Teams # MS Teams
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \ clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
``` ```

View File

@ -447,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
## Polls (Adaptive Cards) ## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API). Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
- CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...` - CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...`
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`. - Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
- The gateway must stay online to record votes. - The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed). - Polls do not auto-post result summaries yet (inspect the store file if needed).

View File

@ -444,7 +444,7 @@ The agent sees reactions as **system notifications** in the conversation history
## 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 send --channel telegram --to 123456789 --message "hi"`. - Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`.
## Troubleshooting ## Troubleshooting

View File

@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id as the target. - Use a chat id as the target.
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`. - Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`.
## Troubleshooting ## Troubleshooting

View File

@ -15,7 +15,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
- `--json`: output JSON - `--json`: output JSON
## Notes ## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`). - `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory. - For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting. - Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
@ -23,7 +23,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
```bash ```bash
clawdbot directory peers list --channel slack --query "U0" clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello" clawdbot message send --channel slack --target user:U012ABCDEF --message "hello"
``` ```
## ID formats (by channel) ## ID formats (by channel)

View File

@ -446,8 +446,8 @@ Subcommands:
- `message event <list|create>` - `message event <list|create>`
Examples: Examples:
- `clawdbot message send --to +15555550123 --message "Hi"` - `clawdbot message send --target +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi` - `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
### `agent` ### `agent`
Run one agent turn via the Gateway (or `--local` embedded). Run one agent turn via the Gateway (or `--local` embedded).

View File

@ -21,7 +21,7 @@ Channel selection:
- If exactly one channel is configured, it becomes the default. - If exactly one channel is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams` - Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
Target formats (`--to`): Target formats (`--target`):
- WhatsApp: E.164 or group JID - WhatsApp: E.164 or group JID
- Telegram: chat id or `@username` - Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels) - Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
@ -38,6 +38,7 @@ Name lookup:
- `--channel <name>` - `--channel <name>`
- `--account <id>` - `--account <id>`
- `--target <dest>` (target channel or user for send/poll/read/etc)
- `--targets <name>` (repeat; broadcast only) - `--targets <name>` (repeat; broadcast only)
- `--json` - `--json`
- `--dry-run` - `--dry-run`
@ -49,7 +50,7 @@ Name lookup:
- `send` - `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, plus `--message` or `--media` - Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id) - Telegram only: `--thread-id` (forum topic id)
@ -58,52 +59,47 @@ Name lookup:
- `poll` - `poll`
- Channels: WhatsApp/Discord/MS Teams - Channels: WhatsApp/Discord/MS Teams
- Required: `--to`, `--poll-question`, `--poll-option` (repeat) - Required: `--target`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi` - Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message` - Discord only: `--poll-duration-hours`, `--message`
- `react` - `react`
- Channels: Discord/Slack/Telegram/WhatsApp - Channels: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--to` or `--channel-id` - Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id` - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions) - Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me` - WhatsApp only: `--participant`, `--from-me`
- `reactions` - `reactions`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id` - Required: `--message-id`, `--target`
- Optional: `--limit`, `--channel-id` - Optional: `--limit`
- `read` - `read`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--to` or `--channel-id` - Required: `--target`
- Optional: `--limit`, `--before`, `--after`, `--channel-id` - Optional: `--limit`, `--before`, `--after`
- Discord only: `--around` - Discord only: `--around`
- `edit` - `edit`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--message`, `--to` or `--channel-id` - Required: `--message-id`, `--message`, `--target`
- Optional: `--channel-id`
- `delete` - `delete`
- Channels: Discord/Slack/Telegram - Channels: Discord/Slack/Telegram
- Required: `--message-id`, `--to` or `--channel-id` - Required: `--message-id`, `--target`
- Optional: `--channel-id`
- `pin` / `unpin` - `pin` / `unpin`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--to` or `--channel-id` - Required: `--message-id`, `--target`
- Optional: `--channel-id`
- `pins` (list) - `pins` (list)
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--to` or `--channel-id` - Required: `--target`
- Optional: `--channel-id`
- `permissions` - `permissions`
- Channels: Discord - Channels: Discord
- Required: `--to` or `--channel-id` - Required: `--target`
- Optional: `--channel-id`
- `search` - `search`
- Channels: Discord - Channels: Discord
@ -114,7 +110,7 @@ Name lookup:
- `thread create` - `thread create`
- Channels: Discord - Channels: Discord
- Required: `--thread-name`, `--to` (channel id) or `--channel-id` - Required: `--thread-name`, `--target` (channel id)
- Optional: `--message-id`, `--auto-archive-min` - Optional: `--message-id`, `--auto-archive-min`
- `thread list` - `thread list`
@ -124,7 +120,7 @@ Name lookup:
- `thread reply` - `thread reply`
- Channels: Discord - Channels: Discord
- Required: `--to` (thread id), `--message` - Required: `--target` (thread id), `--message`
- Optional: `--media`, `--reply-to` - Optional: `--media`, `--reply-to`
### Emojis ### Emojis
@ -142,7 +138,7 @@ Name lookup:
- `sticker send` - `sticker send`
- Channels: Discord - Channels: Discord
- Required: `--to`, `--sticker-id` (repeat) - Required: `--target`, `--sticker-id` (repeat)
- Optional: `--message` - Optional: `--message`
- `sticker upload` - `sticker upload`
@ -153,7 +149,7 @@ Name lookup:
- `role info` (Discord): `--guild-id` - `role info` (Discord): `--guild-id`
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id` - `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
- `channel info` (Discord): `--channel-id` - `channel info` (Discord): `--target`
- `channel list` (Discord): `--guild-id` - `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord) - `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id` - `voice status` (Discord): `--guild-id`, `--user-id`
@ -183,13 +179,13 @@ Name lookup:
Send a Discord reply: Send a Discord reply:
``` ```
clawdbot message send --channel discord \ clawdbot message send --channel discord \
--to channel:123 --message "hi" --reply-to 456 --target channel:123 --message "hi" --reply-to 456
``` ```
Create a Discord poll: Create a Discord poll:
``` ```
clawdbot message poll --channel discord \ clawdbot message poll --channel discord \
--to channel:123 \ --target channel:123 \
--poll-question "Snack?" \ --poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \ --poll-option Pizza --poll-option Sushi \
--poll-multi --poll-duration-hours 48 --poll-multi --poll-duration-hours 48
@ -198,13 +194,13 @@ clawdbot message poll --channel discord \
Send a Teams proactive message: Send a Teams proactive message:
``` ```
clawdbot message send --channel msteams \ clawdbot message send --channel msteams \
--to conversation:19:abc@thread.tacv2 --message "hi" --target conversation:19:abc@thread.tacv2 --message "hi"
``` ```
Create a Teams poll: Create a Teams poll:
``` ```
clawdbot message poll --channel msteams \ clawdbot message poll --channel msteams \
--to conversation:19:abc@thread.tacv2 \ --target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" \ --poll-question "Lunch?" \
--poll-option Pizza --poll-option Sushi --poll-option Pizza --poll-option Sushi
``` ```
@ -212,11 +208,11 @@ clawdbot message poll --channel msteams \
React in Slack: React in Slack:
``` ```
clawdbot message react --channel slack \ clawdbot message react --channel slack \
--to C123 --message-id 456 --emoji "✅" --target C123 --message-id 456 --emoji "✅"
``` ```
Send Telegram inline buttons: Send Telegram inline buttons:
``` ```
clawdbot message send --channel telegram --to @mychat --message "Choose:" \ clawdbot message send --channel telegram --target @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' --buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
``` ```

View File

@ -281,7 +281,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 send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot message send --target <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

@ -137,7 +137,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 send --to +15555550123 --message "Hello from Clawdbot" clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
``` ```
## Configuration (optional) ## Configuration (optional)

View File

@ -65,7 +65,7 @@ Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
clawdbot channels login --channel zalouser clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser clawdbot channels logout --channel zalouser
clawdbot channels status --probe clawdbot channels status --probe
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot" clawdbot message send --channel zalouser --target <threadId> --message "Hello from Clawdbot"
clawdbot directory peers list --channel zalouser --query "name" clawdbot directory peers list --channel zalouser --query "name"
``` ```

View File

@ -1495,7 +1495,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
CLI sending: CLI sending:
```bash ```bash
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png clawdbot message send --target +15555550123 --message "Here you go" --media /path/to/file.png
``` ```
Also check: Also check:

View File

@ -174,7 +174,7 @@ In a new terminal:
```bash ```bash
clawdbot status clawdbot status
clawdbot health clawdbot health
clawdbot message send --to +15555550123 --message "Hello from Clawdbot" clawdbot message send --target +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

@ -20,7 +20,7 @@ runtime on the current machine.
- Output: - Output:
- default: prints reply text (plus `MEDIA:<url>` lines) - default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata - `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --to`). - Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
If the Gateway is unreachable, the CLI **falls back** to the embedded local run. If the Gateway is unreachable, the CLI **falls back** to the embedded local run.

View File

@ -47,7 +47,7 @@ describe("message tool mirroring", () => {
await tool.execute("1", { await tool.execute("1", {
action: "send", action: "send",
to: "telegram:123", target: "telegram:123",
message: "", message: "",
media: "https://example.com/files/report.pdf?sig=1", media: "https://example.com/files/report.pdf?sig=1",
}); });
@ -75,7 +75,7 @@ describe("message tool mirroring", () => {
await tool.execute("1", { await tool.execute("1", {
action: "send", action: "send",
to: "telegram:123", target: "telegram:123",
message: "hi", message: "hi",
}); });

View File

@ -26,7 +26,9 @@ const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
function buildRoutingSchema() { function buildRoutingSchema() {
return { return {
channel: Type.Optional(Type.String()), channel: Type.Optional(Type.String()),
to: Type.Optional(channelTargetSchema()), target: Type.Optional(
channelTargetSchema({ description: "Target channel/user id or name." }),
),
targets: Type.Optional(channelTargetsSchema()), targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()), dryRun: Type.Optional(Type.Boolean()),
@ -89,8 +91,12 @@ function buildPollSchema() {
function buildChannelTargetSchema() { function buildChannelTargetSchema() {
return { return {
channelId: Type.Optional(channelTargetSchema()), channelId: Type.Optional(
channelIds: Type.Optional(channelTargetsSchema()), Type.String({ description: "Channel id filter (search/thread list/event create)." }),
),
channelIds: Type.Optional(
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
),
guildId: Type.Optional(Type.String()), guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()), userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()), authorId: Type.Optional(Type.String()),
@ -182,6 +188,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
}; };
} }
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
actions: readonly string[], actions: readonly string[],
options: { includeButtons: boolean }, options: { includeButtons: boolean },

View File

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

View File

@ -10,7 +10,7 @@ const EXAMPLES = [
"Link personal WhatsApp Web and show QR + connection logs.", "Link personal WhatsApp Web and show QR + connection logs.",
], ],
[ [
'clawdbot message send --to +15555550123 --message "Hi" --json', 'clawdbot message send --target +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."],
@ -22,7 +22,7 @@ const EXAMPLES = [
"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 message send --channel telegram --to @mychat --message "Hi"', 'clawdbot message send --channel telegram --target @mychat --message "Hi"',
"Send via your Telegram bot.", "Send via your Telegram bot.",
], ],
] as const; ] as const;

View File

@ -25,9 +25,9 @@ export function createMessageCliHelpers(
.option("--verbose", "Verbose logging", false); .option("--verbose", "Verbose logging", false);
const withMessageTarget = (command: Command) => const withMessageTarget = (command: Command) =>
command.option("-t, --to <dest>", CHANNEL_TARGET_DESCRIPTION); command.option("-t, --target <dest>", CHANNEL_TARGET_DESCRIPTION);
const withRequiredMessageTarget = (command: Command) => const withRequiredMessageTarget = (command: Command) =>
command.requiredOption("-t, --to <dest>", CHANNEL_TARGET_DESCRIPTION); command.requiredOption("-t, --target <dest>", CHANNEL_TARGET_DESCRIPTION);
const runMessageAction = async (action: string, opts: Record<string, unknown>) => { const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));

View File

@ -40,10 +40,9 @@ export function registerMessageDiscordAdminCommands(message: Command, helpers: M
const channel = message.command("channel").description("Channel actions"); const channel = message.command("channel").description("Channel actions");
helpers helpers
.withMessageBase( .withMessageBase(
channel helpers.withRequiredMessageTarget(
.command("info") channel.command("info").description("Fetch channel info"),
.description("Fetch channel info") ),
.requiredOption("--channel-id <id>", "Channel id"),
) )
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("channel-info", opts); await helpers.runMessageAction("channel-info", opts);

View File

@ -5,11 +5,10 @@ import type { MessageCliHelpers } from "./helpers.js";
export function registerMessagePermissionsCommand(message: Command, helpers: MessageCliHelpers) { export function registerMessagePermissionsCommand(message: Command, helpers: MessageCliHelpers) {
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget( helpers.withRequiredMessageTarget(
message.command("permissions").description("Fetch channel permissions"), message.command("permissions").description("Fetch channel permissions"),
), ),
) )
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("permissions", opts); await helpers.runMessageAction("permissions", opts);
}); });

View File

@ -2,15 +2,10 @@ import type { Command } from "commander";
import type { MessageCliHelpers } from "./helpers.js"; import type { MessageCliHelpers } from "./helpers.js";
export function registerMessagePinCommands(message: Command, helpers: MessageCliHelpers) { export function registerMessagePinCommands(message: Command, helpers: MessageCliHelpers) {
const withPinsTarget = (command: Command) =>
command.option("--channel-id <id>", "Channel id (defaults to --to; required for WhatsApp)");
const pins = [ const pins = [
helpers helpers
.withMessageBase( .withMessageBase(
withPinsTarget( helpers.withRequiredMessageTarget(message.command("pin").description("Pin a message")),
helpers.withMessageTarget(message.command("pin").description("Pin a message")),
),
) )
.requiredOption("--message-id <id>", "Message id") .requiredOption("--message-id <id>", "Message id")
.action(async (opts) => { .action(async (opts) => {
@ -18,9 +13,7 @@ export function registerMessagePinCommands(message: Command, helpers: MessageCli
}), }),
helpers helpers
.withMessageBase( .withMessageBase(
withPinsTarget( helpers.withRequiredMessageTarget(message.command("unpin").description("Unpin a message")),
helpers.withMessageTarget(message.command("unpin").description("Unpin a message")),
),
) )
.requiredOption("--message-id <id>", "Message id") .requiredOption("--message-id <id>", "Message id")
.action(async (opts) => { .action(async (opts) => {
@ -28,9 +21,8 @@ export function registerMessagePinCommands(message: Command, helpers: MessageCli
}), }),
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget(message.command("pins").description("List pinned messages")), helpers.withRequiredMessageTarget(message.command("pins").description("List pinned messages")),
) )
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--limit <n>", "Result limit") .option("--limit <n>", "Result limit")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("list-pins", opts); await helpers.runMessageAction("list-pins", opts);

View File

@ -4,27 +4,27 @@ import type { MessageCliHelpers } from "./helpers.js";
export function registerMessageReactionsCommands(message: Command, helpers: MessageCliHelpers) { export function registerMessageReactionsCommands(message: Command, helpers: MessageCliHelpers) {
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget(message.command("react").description("Add or remove a reaction")), helpers.withRequiredMessageTarget(
message.command("react").description("Add or remove a reaction"),
),
) )
.requiredOption("--message-id <id>", "Message id") .requiredOption("--message-id <id>", "Message id")
.option("--emoji <emoji>", "Emoji for reactions") .option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false) .option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant") .option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false) .option("--from-me", "WhatsApp reaction fromMe", false)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("react", opts); await helpers.runMessageAction("react", opts);
}); });
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget( helpers.withRequiredMessageTarget(
message.command("reactions").description("List reactions on a message"), message.command("reactions").description("List reactions on a message"),
), ),
) )
.requiredOption("--message-id <id>", "Message id") .requiredOption("--message-id <id>", "Message id")
.option("--limit <n>", "Result limit") .option("--limit <n>", "Result limit")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("reactions", opts); await helpers.runMessageAction("reactions", opts);
}); });

View File

@ -7,13 +7,12 @@ export function registerMessageReadEditDeleteCommands(
) { ) {
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget(message.command("read").description("Read recent messages")), helpers.withRequiredMessageTarget(message.command("read").description("Read recent messages")),
) )
.option("--limit <n>", "Result limit") .option("--limit <n>", "Result limit")
.option("--before <id>", "Read/search before id") .option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id") .option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id") .option("--around <id>", "Read around id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--include-thread", "Include thread replies (Discord)", false) .option("--include-thread", "Include thread replies (Discord)", false)
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("read", opts); await helpers.runMessageAction("read", opts);
@ -21,7 +20,7 @@ export function registerMessageReadEditDeleteCommands(
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget( helpers.withRequiredMessageTarget(
message message
.command("edit") .command("edit")
.description("Edit a message") .description("Edit a message")
@ -29,7 +28,6 @@ export function registerMessageReadEditDeleteCommands(
.requiredOption("-m, --message <text>", "Message body"), .requiredOption("-m, --message <text>", "Message body"),
), ),
) )
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--thread-id <id>", "Thread id (Telegram forum thread)") .option("--thread-id <id>", "Thread id (Telegram forum thread)")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("edit", opts); await helpers.runMessageAction("edit", opts);
@ -37,14 +35,13 @@ export function registerMessageReadEditDeleteCommands(
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget( helpers.withRequiredMessageTarget(
message message
.command("delete") .command("delete")
.description("Delete a message") .description("Delete a message")
.requiredOption("--message-id <id>", "Message id"), .requiredOption("--message-id <id>", "Message id"),
), ),
) )
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("delete", opts); await helpers.runMessageAction("delete", opts);
}); });

View File

@ -6,14 +6,13 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
helpers helpers
.withMessageBase( .withMessageBase(
helpers.withMessageTarget( helpers.withRequiredMessageTarget(
thread thread
.command("create") .command("create")
.description("Create a thread") .description("Create a thread")
.requiredOption("--thread-name <name>", "Thread name"), .requiredOption("--thread-name <name>", "Thread name"),
), ),
) )
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--message-id <id>", "Message id (optional)") .option("--message-id <id>", "Message id (optional)")
.option("--auto-archive-min <n>", "Thread auto-archive minutes") .option("--auto-archive-min <n>", "Thread auto-archive minutes")
.action(async (opts) => { .action(async (opts) => {

View File

@ -29,10 +29,10 @@ export function registerMessageCommands(program: Command, ctx: ProgramContext) {
() => () =>
` `
Examples: Examples:
clawdbot message send --to +15555550123 --message "Hi" clawdbot message send --target +15555550123 --message "Hi"
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg
clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --channel discord --to 123 --message-id 456 --emoji "✅" clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"
${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`, ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`,
) )

View File

@ -88,7 +88,7 @@ describe("messageCommand", () => {
const deps = makeDeps(); const deps = makeDeps();
await messageCommand( await messageCommand(
{ {
to: "123", target: "123",
message: "hi", message: "hi",
}, },
deps, deps,
@ -104,7 +104,7 @@ describe("messageCommand", () => {
await expect( await expect(
messageCommand( messageCommand(
{ {
to: "123", target: "123",
message: "hi", message: "hi",
}, },
deps, deps,
@ -120,7 +120,7 @@ describe("messageCommand", () => {
{ {
action: "send", action: "send",
channel: "whatsapp", channel: "whatsapp",
to: "+15551234567", target: "+15551234567",
message: "hi", message: "hi",
}, },
deps, deps,
@ -135,7 +135,7 @@ describe("messageCommand", () => {
{ {
action: "poll", action: "poll",
channel: "discord", channel: "discord",
to: "channel:123456789", target: "channel:123456789",
pollQuestion: "Snack?", pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"], pollOption: ["Pizza", "Sushi"],
}, },

View File

@ -3,6 +3,7 @@ import { type ChannelId, getChannelPlugin, listChannelPlugins } from "../channel
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import type { createSubsystemLogger } from "../logging.js"; import type { createSubsystemLogger } from "../logging.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@ -93,6 +94,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
const startAccount = plugin?.gateway?.startAccount; const startAccount = plugin?.gateway?.startAccount;
if (!startAccount) return; if (!startAccount) return;
const cfg = loadConfig(); const cfg = loadConfig();
resetDirectoryCache({ channel: channelId, accountId });
const store = getStore(channelId); const store = getStore(channelId);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg); const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
if (accountIds.length === 0) return; if (accountIds.length === 0) return;

View File

@ -2,6 +2,7 @@ import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js";
import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js"; import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
import { resolveHooksConfig } from "./hooks.js"; import { resolveHooksConfig } from "./hooks.js";
@ -52,6 +53,8 @@ export function createGatewayReloadHandlers(params: {
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig }); nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
} }
resetDirectoryCache();
if (plan.restartCron) { if (plan.restartCron) {
state.cronState.cron.stop(); state.cronState.cron.stop();
nextState.cronState = buildGatewayCronService({ nextState.cronState = buildGatewayCronService({

View File

@ -1,9 +1,41 @@
import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js";
export const CHANNEL_TARGET_DESCRIPTION = export const CHANNEL_TARGET_DESCRIPTION =
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id"; "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id";
export const CHANNEL_TARGETS_DESCRIPTION = export const CHANNEL_TARGETS_DESCRIPTION =
"Recipient/channel targets (same format as --to); accepts ids or names when the directory is available."; "Recipient/channel targets (same format as --target); accepts ids or names when the directory is available.";
export function normalizeChannelTargetInput(raw: string): string { export function normalizeChannelTargetInput(raw: string): string {
return raw.trim(); return raw.trim();
} }
export function applyTargetToParams(params: {
action: string;
args: Record<string, unknown>;
}): void {
const target = typeof params.args.target === "string" ? params.args.target.trim() : "";
const hasLegacyTo = typeof params.args.to === "string";
const hasLegacyChannelId = typeof params.args.channelId === "string";
const mode =
MESSAGE_ACTION_TARGET_MODE[params.action as keyof typeof MESSAGE_ACTION_TARGET_MODE] ?? "none";
if (mode !== "none") {
if (hasLegacyTo || hasLegacyChannelId) {
throw new Error("Use `target` instead of `to`/`channelId`.");
}
} else if (hasLegacyTo) {
throw new Error("Use `target` for actions that accept a destination.");
}
if (!target) return;
if (mode === "channelId") {
params.args.channelId = target;
return;
}
if (mode === "to") {
params.args.to = target;
return;
}
throw new Error(`Action ${params.action} does not accept a target.`);
}

View File

@ -39,6 +39,12 @@ export class DirectoryCache<T> {
this.cache.set(key, { value, fetchedAt: Date.now() }); this.cache.set(key, { value, fetchedAt: Date.now() });
} }
clearMatching(match: (key: string) => boolean): void {
for (const key of this.cache.keys()) {
if (match(key)) this.cache.delete(key);
}
}
clear(cfg?: ClawdbotConfig): void { clear(cfg?: ClawdbotConfig): void {
this.cache.clear(); this.cache.clear();
if (cfg) this.lastConfigRef = cfg; if (cfg) this.lastConfigRef = cfg;

View File

@ -27,7 +27,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "slack", channel: "slack",
to: "#C12345678", target: "#C12345678",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "C12345678" }, toolContext: { currentChannelId: "C12345678" },
@ -43,7 +43,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "slack", channel: "slack",
to: "#C12345678", target: "#C12345678",
media: "https://example.com/note.ogg", media: "https://example.com/note.ogg",
}, },
toolContext: { currentChannelId: "C12345678" }, toolContext: { currentChannelId: "C12345678" },
@ -60,7 +60,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "slack", channel: "slack",
to: "#C12345678", target: "#C12345678",
}, },
toolContext: { currentChannelId: "C12345678" }, toolContext: { currentChannelId: "C12345678" },
dryRun: true, dryRun: true,
@ -74,7 +74,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "slack", channel: "slack",
to: "channel:C99999999", target: "channel:C99999999",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
@ -90,7 +90,7 @@ describe("runMessageAction context isolation", () => {
action: "thread-reply", action: "thread-reply",
params: { params: {
channel: "slack", channel: "slack",
channelId: "C99999999", target: "C99999999",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
@ -106,7 +106,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "whatsapp", channel: "whatsapp",
to: "group:123@g.us", target: "group:123@g.us",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "123@g.us" }, toolContext: { currentChannelId: "123@g.us" },
@ -122,7 +122,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "whatsapp", channel: "whatsapp",
to: "456@g.us", target: "456@g.us",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" }, toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" },
@ -138,7 +138,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "imessage", channel: "imessage",
to: "imessage:+15551234567", target: "imessage:+15551234567",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "imessage:+15551234567" }, toolContext: { currentChannelId: "imessage:+15551234567" },
@ -154,7 +154,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "imessage", channel: "imessage",
to: "imessage:+15551230000", target: "imessage:+15551230000",
message: "hi", message: "hi",
}, },
toolContext: { toolContext: {
@ -174,7 +174,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "telegram", channel: "telegram",
to: "telegram:@ops", target: "telegram:@ops",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
@ -201,7 +201,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "slack", channel: "slack",
to: "channel:C99999999", target: "channel:C99999999",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },

View File

@ -13,13 +13,10 @@ import type {
} from "../../channels/plugins/types.js"; } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import { import { listConfiguredMessageChannels, resolveMessageChannelSelection } from "./channel-selection.js";
listConfiguredMessageChannels, import { applyTargetToParams } from "./channel-target.js";
resolveMessageChannelSelection,
} from "./channel-selection.js";
import type { OutboundSendDeps } from "./deliver.js"; import type { OutboundSendDeps } from "./deliver.js";
import type { MessagePollResult, MessageSendResult } from "./message.js"; import type { MessagePollResult, MessageSendResult } from "./message.js";
import { sendMessage, sendPoll } from "./message.js";
import { import {
applyCrossContextDecoration, applyCrossContextDecoration,
buildCrossContextDecoration, buildCrossContextDecoration,
@ -27,7 +24,9 @@ import {
enforceCrossContextPolicy, enforceCrossContextPolicy,
shouldApplyCrossContextMarker, shouldApplyCrossContextMarker,
} from "./outbound-policy.js"; } from "./outbound-policy.js";
import { resolveMessagingTarget } from "./target-resolver.js"; import { executePollAction, executeSendAction } from "./outbound-send-service.js";
import { actionRequiresTarget } from "./message-action-spec.js";
import { resolveChannelTarget } from "./target-resolver.js";
export type MessageActionRunnerGateway = { export type MessageActionRunnerGateway = {
url?: string; url?: string;
@ -195,7 +194,7 @@ async function resolveActionTarget(params: {
}): Promise<void> { }): Promise<void> {
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : ""; const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : "";
if (toRaw) { if (toRaw) {
const resolved = await resolveMessagingTarget({ const resolved = await resolveChannelTarget({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
input: toRaw, input: toRaw,
@ -210,7 +209,7 @@ async function resolveActionTarget(params: {
const channelIdRaw = const channelIdRaw =
typeof params.args.channelId === "string" ? params.args.channelId.trim() : ""; typeof params.args.channelId === "string" ? params.args.channelId.trim() : "";
if (channelIdRaw) { if (channelIdRaw) {
const resolved = await resolveMessagingTarget({ const resolved = await resolveChannelTarget({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
input: channelIdRaw, input: channelIdRaw,
@ -237,7 +236,6 @@ type ResolvedActionContext = {
gateway?: MessageActionRunnerGateway; gateway?: MessageActionRunnerGateway;
input: RunMessageActionParams; input: RunMessageActionParams;
}; };
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
if (!input.gateway) return undefined; if (!input.gateway) return undefined;
return { return {
@ -281,7 +279,7 @@ async function handleBroadcastAction(
for (const targetChannel of targetChannels) { for (const targetChannel of targetChannels) {
for (const target of rawTargets) { for (const target of rawTargets) {
try { try {
const resolved = await resolveMessagingTarget({ const resolved = await resolveChannelTarget({
cfg: input.cfg, cfg: input.cfg,
channel: targetChannel, channel: targetChannel,
input: target, input: target,
@ -293,7 +291,7 @@ async function handleBroadcastAction(
params: { params: {
...params, ...params,
channel: targetChannel, channel: targetChannel,
to: resolved.target.to, target: resolved.target.to,
}, },
}); });
results.push({ results.push({
@ -326,11 +324,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send"; const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
// Allow message to be omitted when sending media-only (e.g., voice notes)
const mediaHint = readStringParam(params, "media", { trim: false }); const mediaHint = readStringParam(params, "media", { trim: false });
let message = let message =
readStringParam(params, "message", { readStringParam(params, "message", {
required: !mediaHint, // Only require message if no media hint required: !mediaHint,
allowEmpty: true, allowEmpty: true,
}) ?? ""; }) ?? "";
@ -364,50 +361,29 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const mediaUrl = readStringParam(params, "media", { trim: false }); const mediaUrl = readStringParam(params, "media", { trim: false });
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
const bestEffort = readBooleanParam(params, "bestEffort"); const bestEffort = readBooleanParam(params, "bestEffort");
if (!dryRun) { const send = await executeSendAction({
const handled = await dispatchChannelMessageAction({ ctx: {
channel,
action,
cfg, cfg,
channel,
params, params,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
gateway, gateway,
toolContext: input.toolContext, toolContext: input.toolContext,
deps: input.deps,
dryRun, dryRun,
}); mirror:
if (handled) { input.sessionKey && !dryRun
return { ? {
kind: "send", sessionKey: input.sessionKey,
channel, agentId: input.agentId,
action, }
to, : undefined,
handledBy: "plugin", },
payload: extractToolPayload(handled),
toolResult: handled,
dryRun,
};
}
}
const result: MessageSendResult = await sendMessage({
cfg,
to, to,
content: message, message,
mediaUrl: mediaUrl || undefined, mediaUrl: mediaUrl || undefined,
channel: channel || undefined,
accountId: accountId ?? undefined,
gifPlayback, gifPlayback,
dryRun,
bestEffort: bestEffort ?? undefined, bestEffort: bestEffort ?? undefined,
deps: input.deps,
gateway,
mirror:
input.sessionKey && !dryRun
? {
sessionKey: input.sessionKey,
agentId: input.agentId,
}
: undefined,
}); });
return { return {
@ -415,9 +391,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
channel, channel,
action, action,
to, to,
handledBy: "core", handledBy: send.handledBy,
payload: result, payload: send.payload,
sendResult: result, toolResult: send.toolResult,
sendResult: send.sendResult,
dryRun, dryRun,
}; };
} }
@ -458,41 +435,21 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
}); });
} }
if (!dryRun) { const poll = await executePollAction({
const handled = await dispatchChannelMessageAction({ ctx: {
channel,
action,
cfg, cfg,
channel,
params, params,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
gateway, gateway,
toolContext: input.toolContext, toolContext: input.toolContext,
dryRun, dryRun,
}); },
if (handled) {
return {
kind: "poll",
channel,
action,
to,
handledBy: "plugin",
payload: extractToolPayload(handled),
toolResult: handled,
dryRun,
};
}
}
const result: MessagePollResult = await sendPoll({
cfg,
to, to,
question, question,
options, options,
maxSelections, maxSelections,
durationHours: durationHours ?? undefined, durationHours: durationHours ?? undefined,
channel,
dryRun,
gateway,
}); });
return { return {
@ -500,9 +457,10 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
channel, channel,
action, action,
to, to,
handledBy: "core", handledBy: poll.handledBy,
payload: result, payload: poll.payload,
pollResult: result, toolResult: poll.toolResult,
pollResult: poll.pollResult,
dryRun, dryRun,
}; };
} }
@ -560,6 +518,16 @@ export async function runMessageAction(
return handleBroadcastAction(input, params); return handleBroadcastAction(input, params);
} }
applyTargetToParams({ action, args: params });
if (actionRequiresTarget(action)) {
const hasTarget =
(typeof params.to === "string" && params.to.trim()) ||
(typeof params.channelId === "string" && params.channelId.trim());
if (!hasTarget) {
throw new Error(`Action ${action} requires a target.`);
}
}
const channel = await resolveChannel(cfg, params); const channel = await resolveChannel(cfg, params);
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));

View File

@ -0,0 +1,50 @@
import type { ChannelMessageActionName } from "../../channels/plugins/types.js";
export type MessageActionTargetMode = "to" | "channelId" | "none";
export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, MessageActionTargetMode> =
{
send: "to",
broadcast: "none",
poll: "to",
react: "to",
reactions: "to",
read: "to",
edit: "to",
delete: "to",
pin: "to",
unpin: "to",
"list-pins": "to",
permissions: "to",
"thread-create": "to",
"thread-list": "none",
"thread-reply": "to",
search: "none",
sticker: "to",
"member-info": "none",
"role-info": "none",
"emoji-list": "none",
"emoji-upload": "none",
"sticker-upload": "none",
"role-add": "none",
"role-remove": "none",
"channel-info": "channelId",
"channel-list": "none",
"channel-create": "none",
"channel-edit": "channelId",
"channel-delete": "channelId",
"channel-move": "channelId",
"category-create": "none",
"category-edit": "none",
"category-delete": "none",
"voice-status": "none",
"event-list": "none",
"event-create": "none",
timeout: "none",
kick: "none",
ban: "none",
};
export function actionRequiresTarget(action: ChannelMessageActionName): boolean {
return MESSAGE_ACTION_TARGET_MODE[action] !== "none";
}

View File

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
applyCrossContextDecoration,
buildCrossContextDecoration,
enforceCrossContextPolicy,
} from "./outbound-policy.js";
const slackConfig = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as ClawdbotConfig;
const discordConfig = {
channels: {
discord: {},
},
} as ClawdbotConfig;
describe("outbound policy", () => {
it("blocks cross-provider sends by default", () => {
expect(() =>
enforceCrossContextPolicy({
cfg: slackConfig,
channel: "telegram",
action: "send",
args: { to: "telegram:@ops" },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
}),
).toThrow(/Cross-context messaging denied/);
});
it("allows cross-provider sends when enabled", () => {
const cfg = {
...slackConfig,
tools: {
message: { crossContext: { allowAcrossProviders: true } },
},
} as ClawdbotConfig;
expect(() =>
enforceCrossContextPolicy({
cfg,
channel: "telegram",
action: "send",
args: { to: "telegram:@ops" },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
}),
).not.toThrow();
});
it("blocks same-provider cross-context when disabled", () => {
const cfg = {
...slackConfig,
tools: { message: { crossContext: { allowWithinProvider: false } } },
} as ClawdbotConfig;
expect(() =>
enforceCrossContextPolicy({
cfg,
channel: "slack",
action: "send",
args: { to: "C99999999" },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
}),
).toThrow(/Cross-context messaging denied/);
});
it("uses embeds when available and preferred", async () => {
const decoration = await buildCrossContextDecoration({
cfg: discordConfig,
channel: "discord",
target: "123",
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "discord" },
});
expect(decoration).not.toBeNull();
const applied = applyCrossContextDecoration({
message: "hello",
decoration: decoration!,
preferEmbeds: true,
});
expect(applied.usedEmbeds).toBe(true);
expect(applied.embeds?.length).toBeGreaterThan(0);
expect(applied.message).toBe("hello");
});
});

View File

@ -0,0 +1,164 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
import type {
ChannelId,
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import type { OutboundSendDeps } from "./deliver.js";
import type { MessagePollResult, MessageSendResult } from "./message.js";
import { sendMessage, sendPoll } from "./message.js";
export type OutboundGatewayContext = {
url?: string;
token?: string;
timeoutMs?: number;
clientName: GatewayClientName;
clientDisplayName?: string;
mode: GatewayClientMode;
};
export type OutboundSendContext = {
cfg: ClawdbotConfig;
channel: ChannelId;
params: Record<string, unknown>;
accountId?: string | null;
gateway?: OutboundGatewayContext;
toolContext?: ChannelThreadingToolContext;
deps?: OutboundSendDeps;
dryRun: boolean;
mirror?: {
sessionKey: string;
agentId?: string;
};
};
function extractToolPayload(result: AgentToolResult<unknown>): unknown {
if (result.details !== undefined) return result.details;
const textBlock = Array.isArray(result.content)
? result.content.find(
(block) =>
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string",
)
: undefined;
const text = (textBlock as { text?: string } | undefined)?.text;
if (text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
return result.content ?? result;
}
export async function executeSendAction(params: {
ctx: OutboundSendContext;
to: string;
message: string;
mediaUrl?: string;
gifPlayback?: boolean;
bestEffort?: boolean;
}): Promise<{
handledBy: "plugin" | "core";
payload: unknown;
toolResult?: AgentToolResult<unknown>;
sendResult?: MessageSendResult;
}> {
if (!params.ctx.dryRun) {
const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel,
action: "send",
cfg: params.ctx.cfg,
params: params.ctx.params,
accountId: params.ctx.accountId ?? undefined,
gateway: params.ctx.gateway,
toolContext: params.ctx.toolContext,
dryRun: params.ctx.dryRun,
});
if (handled) {
return {
handledBy: "plugin",
payload: extractToolPayload(handled),
toolResult: handled,
};
}
}
const result: MessageSendResult = await sendMessage({
cfg: params.ctx.cfg,
to: params.to,
content: params.message,
mediaUrl: params.mediaUrl || undefined,
channel: params.ctx.channel || undefined,
accountId: params.ctx.accountId ?? undefined,
gifPlayback: params.gifPlayback,
dryRun: params.ctx.dryRun,
bestEffort: params.bestEffort ?? undefined,
deps: params.ctx.deps,
gateway: params.ctx.gateway,
mirror: params.ctx.mirror,
});
return {
handledBy: "core",
payload: result,
sendResult: result,
};
}
export async function executePollAction(params: {
ctx: OutboundSendContext;
to: string;
question: string;
options: string[];
maxSelections: number;
durationHours?: number;
}): Promise<{
handledBy: "plugin" | "core";
payload: unknown;
toolResult?: AgentToolResult<unknown>;
pollResult?: MessagePollResult;
}> {
if (!params.ctx.dryRun) {
const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel,
action: "poll",
cfg: params.ctx.cfg,
params: params.ctx.params,
accountId: params.ctx.accountId ?? undefined,
gateway: params.ctx.gateway,
toolContext: params.ctx.toolContext,
dryRun: params.ctx.dryRun,
});
if (handled) {
return {
handledBy: "plugin",
payload: extractToolPayload(handled),
toolResult: handled,
};
}
}
const result: MessagePollResult = await sendPoll({
cfg: params.ctx.cfg,
to: params.to,
question: params.question,
options: params.options,
maxSelections: params.maxSelections,
durationHours: params.durationHours ?? undefined,
channel: params.ctx.channel,
dryRun: params.ctx.dryRun,
gateway: params.ctx.gateway,
});
return {
handledBy: "core",
payload: result,
pollResult: result,
};
}

View File

@ -23,9 +23,34 @@ export type ResolveMessagingTargetResult =
| { ok: true; target: ResolvedMessagingTarget } | { ok: true; target: ResolvedMessagingTarget }
| { ok: false; error: Error; candidates?: ChannelDirectoryEntry[] }; | { ok: false; error: Error; candidates?: ChannelDirectoryEntry[] };
export async function resolveChannelTarget(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
input: string;
accountId?: string | null;
preferredKind?: TargetResolveKind;
runtime?: RuntimeEnv;
}): Promise<ResolveMessagingTargetResult> {
return resolveMessagingTarget(params);
}
const CACHE_TTL_MS = 30 * 60 * 1000; const CACHE_TTL_MS = 30 * 60 * 1000;
const directoryCache = new DirectoryCache<ChannelDirectoryEntry[]>(CACHE_TTL_MS); const directoryCache = new DirectoryCache<ChannelDirectoryEntry[]>(CACHE_TTL_MS);
export function resetDirectoryCache(params?: { channel?: ChannelId; accountId?: string | null }) {
if (!params?.channel) {
directoryCache.clear();
return;
}
const channelKey = params.channel;
const accountKey = params.accountId ?? "default";
directoryCache.clearMatching((key) => {
if (!key.startsWith(`${channelKey}:`)) return false;
if (!params.accountId) return true;
return key.startsWith(`${channelKey}:${accountKey}:`);
});
}
function normalizeQuery(value: string): string { function normalizeQuery(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }

View File

@ -73,7 +73,7 @@ export function resolveOutboundTarget(params: {
} }
return { return {
ok: false, ok: false,
error: new Error(`Delivering to ${plugin.meta.label} requires --to`), error: new Error(`Delivering to ${plugin.meta.label} requires a destination`),
}; };
} }