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:** 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
- Tools: improve `web_fetch` extraction using Readability (with fallback).

View File

@@ -16,19 +16,19 @@ read_when:
```bash
# WhatsApp
clawdbot message poll --to +15555550123 \
clawdbot message poll --target +15555550123 \
--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
# 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"
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
# 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"
```

View File

@@ -447,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
## Polls (Adaptive Cards)
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`.
- The gateway must stay online to record votes.
- 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)
- 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

View File

@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Delivery targets (CLI/cron)
- 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

View File

@@ -15,7 +15,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
- `--json`: output JSON
## 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.
- 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
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)

View File

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

View File

@@ -281,7 +281,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot message send --to <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 gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `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):
```bash
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
```
## Configuration (optional)

View File

@@ -65,7 +65,7 @@ Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser
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"
```

View File

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

View File

@@ -174,7 +174,7 @@ In a new terminal:
```bash
clawdbot status
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.

View File

@@ -20,7 +20,7 @@ runtime on the current machine.
- Output:
- default: prints reply text (plus `MEDIA:<url>` lines)
- `--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.

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ const EXAMPLES = [
"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.",
],
["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.",
],
[
'clawdbot message send --channel telegram --to @mychat --message "Hi"',
'clawdbot message send --channel telegram --target @mychat --message "Hi"',
"Send via your Telegram bot.",
],
] as const;

View File

@@ -25,9 +25,9 @@ export function createMessageCliHelpers(
.option("--verbose", "Verbose logging", false);
const withMessageTarget = (command: Command) =>
command.option("-t, --to <dest>", CHANNEL_TARGET_DESCRIPTION);
command.option("-t, --target <dest>", CHANNEL_TARGET_DESCRIPTION);
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>) => {
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");
helpers
.withMessageBase(
channel
.command("info")
.description("Fetch channel info")
.requiredOption("--channel-id <id>", "Channel id"),
helpers.withRequiredMessageTarget(
channel.command("info").description("Fetch channel info"),
),
)
.action(async (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) {
helpers
.withMessageBase(
helpers.withMessageTarget(
helpers.withRequiredMessageTarget(
message.command("permissions").description("Fetch channel permissions"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await helpers.runMessageAction("permissions", opts);
});

View File

@@ -2,15 +2,10 @@ import type { Command } from "commander";
import type { MessageCliHelpers } from "./helpers.js";
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 = [
helpers
.withMessageBase(
withPinsTarget(
helpers.withMessageTarget(message.command("pin").description("Pin a message")),
),
helpers.withRequiredMessageTarget(message.command("pin").description("Pin a message")),
)
.requiredOption("--message-id <id>", "Message id")
.action(async (opts) => {
@@ -18,9 +13,7 @@ export function registerMessagePinCommands(message: Command, helpers: MessageCli
}),
helpers
.withMessageBase(
withPinsTarget(
helpers.withMessageTarget(message.command("unpin").description("Unpin a message")),
),
helpers.withRequiredMessageTarget(message.command("unpin").description("Unpin a message")),
)
.requiredOption("--message-id <id>", "Message id")
.action(async (opts) => {
@@ -28,9 +21,8 @@ export function registerMessagePinCommands(message: Command, helpers: MessageCli
}),
helpers
.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")
.action(async (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) {
helpers
.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")
.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 helpers.runMessageAction("react", opts);
});
helpers
.withMessageBase(
helpers.withMessageTarget(
helpers.withRequiredMessageTarget(
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 helpers.runMessageAction("reactions", opts);
});

View File

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

View File

@@ -6,14 +6,13 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
helpers
.withMessageBase(
helpers.withMessageTarget(
helpers.withRequiredMessageTarget(
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) => {

View File

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

View File

@@ -88,7 +88,7 @@ describe("messageCommand", () => {
const deps = makeDeps();
await messageCommand(
{
to: "123",
target: "123",
message: "hi",
},
deps,
@@ -104,7 +104,7 @@ describe("messageCommand", () => {
await expect(
messageCommand(
{
to: "123",
target: "123",
message: "hi",
},
deps,
@@ -120,7 +120,7 @@ describe("messageCommand", () => {
{
action: "send",
channel: "whatsapp",
to: "+15551234567",
target: "+15551234567",
message: "hi",
},
deps,
@@ -135,7 +135,7 @@ describe("messageCommand", () => {
{
action: "poll",
channel: "discord",
to: "channel:123456789",
target: "channel:123456789",
pollQuestion: "Snack?",
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 { ClawdbotConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import type { createSubsystemLogger } from "../logging.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -93,6 +94,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
const startAccount = plugin?.gateway?.startAccount;
if (!startAccount) return;
const cfg = loadConfig();
resetDirectoryCache({ channel: channelId, accountId });
const store = getStore(channelId);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
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 { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
import { resolveHooksConfig } from "./hooks.js";
@@ -52,6 +53,8 @@ export function createGatewayReloadHandlers(params: {
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
}
resetDirectoryCache();
if (plan.restartCron) {
state.cronState.cron.stop();
nextState.cronState = buildGatewayCronService({

View File

@@ -1,9 +1,41 @@
import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js";
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";
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 {
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() });
}
clearMatching(match: (key: string) => boolean): void {
for (const key of this.cache.keys()) {
if (match(key)) this.cache.delete(key);
}
}
clear(cfg?: ClawdbotConfig): void {
this.cache.clear();
if (cfg) this.lastConfigRef = cfg;

View File

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

View File

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

View File

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