From 6e4d86f42698b1020c810b8ecd70e148e2a5e6d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 04:06:14 +0000 Subject: [PATCH] refactor: require target for message actions --- CHANGELOG.md | 1 + docs/automation/poll.md | 10 +- docs/channels/msteams.md | 2 +- docs/channels/telegram.md | 2 +- docs/channels/zalo.md | 2 +- docs/cli/directory.md | 4 +- docs/cli/index.md | 4 +- docs/cli/message.md | 54 +++--- docs/gateway/index.md | 2 +- docs/index.md | 2 +- docs/plugins/zalouser.md | 2 +- docs/start/faq.md | 2 +- docs/start/getting-started.md | 2 +- docs/tools/agent-send.md | 2 +- src/agents/tools/message-tool.test.ts | 4 +- src/agents/tools/message-tool.ts | 13 +- src/cli/program.smoke.test.ts | 2 +- src/cli/program/help.ts | 4 +- src/cli/program/message/helpers.ts | 4 +- .../program/message/register.discord-admin.ts | 7 +- .../message/register.permissions-search.ts | 3 +- src/cli/program/message/register.pins.ts | 14 +- src/cli/program/message/register.reactions.ts | 8 +- .../message/register.read-edit-delete.ts | 9 +- src/cli/program/message/register.thread.ts | 3 +- src/cli/program/register.message.ts | 8 +- src/commands/message.test.ts | 8 +- src/gateway/server-channels.ts | 2 + src/gateway/server-reload-handlers.ts | 3 + src/infra/outbound/channel-target.ts | 34 +++- src/infra/outbound/directory-cache.ts | 6 + .../outbound/message-action-runner.test.ts | 22 +-- src/infra/outbound/message-action-runner.ts | 122 +++++-------- src/infra/outbound/message-action-spec.ts | 50 ++++++ src/infra/outbound/outbound-policy.test.ts | 93 ++++++++++ src/infra/outbound/outbound-send-service.ts | 164 ++++++++++++++++++ src/infra/outbound/target-resolver.ts | 25 +++ src/infra/outbound/targets.ts | 2 +- 38 files changed, 517 insertions(+), 184 deletions(-) create mode 100644 src/infra/outbound/message-action-spec.ts create mode 100644 src/infra/outbound/outbound-policy.test.ts create mode 100644 src/infra/outbound/outbound-send-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9731f62b0..e9b7817bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/automation/poll.md b/docs/automation/poll.md index d22de2eec..628bdff85 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -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" ``` diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 7c4cfb54d..79f96bbba 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -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: ...` +- CLI: `clawdbot message poll --channel msteams --target conversation: ...` - 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). diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 77097cb98..3a38d8931 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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 diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index b4d23bcf1..7e31545dc 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -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 diff --git a/docs/cli/directory.md b/docs/cli/directory.md index db05d637e..ebb544313 100644 --- a/docs/cli/directory.md +++ b/docs/cli/directory.md @@ -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) diff --git a/docs/cli/index.md b/docs/cli/index.md index 7f024cf64..7a453a850 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -446,8 +446,8 @@ Subcommands: - `message event ` 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). diff --git a/docs/cli/message.md b/docs/cli/message.md index 723825931..6baa73ebc 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -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:` or `user:` (or `<@id>` mention; raw numeric ids are treated as channels) @@ -38,6 +38,7 @@ Name lookup: - `--channel ` - `--account ` +- `--target ` (target channel or user for send/poll/read/etc) - `--targets ` (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"}] ]' ``` diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 1f60db40b..53ee56ea0 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -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 --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). +- `clawdbot message send --target --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot agent --message "hi" --to ` — run an agent turn (waits for final by default). - `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging. - `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd). diff --git a/docs/index.md b/docs/index.md index c85600243..a1b4d1507 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/plugins/zalouser.md b/docs/plugins/zalouser.md index fbaa0bbd2..d2f2bef6c 100644 --- a/docs/plugins/zalouser.md +++ b/docs/plugins/zalouser.md @@ -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 --message "Hello from Clawdbot" +clawdbot message send --channel zalouser --target --message "Hello from Clawdbot" clawdbot directory peers list --channel zalouser --query "name" ``` diff --git a/docs/start/faq.md b/docs/start/faq.md index 0986b816b..49c2a527a 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -1495,7 +1495,7 @@ Outbound attachments from the agent must include a `MEDIA:` 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: diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 4077eecac..2ae176c25 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -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 won’t be able to respond without it. diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index c5b8eaa59..1c22c2be3 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -20,7 +20,7 @@ runtime on the current machine. - Output: - default: prints reply text (plus `MEDIA:` 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. diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 9174d22ba..de56ba8c6 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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", }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 5820196aa..81e3c67f5 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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 }, diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 172e437a6..875510265 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -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(); diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index dfe972873..12ca20920 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -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; diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index 249110300..bb7f16ee1 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -25,9 +25,9 @@ export function createMessageCliHelpers( .option("--verbose", "Verbose logging", false); const withMessageTarget = (command: Command) => - command.option("-t, --to ", CHANNEL_TARGET_DESCRIPTION); + command.option("-t, --target ", CHANNEL_TARGET_DESCRIPTION); const withRequiredMessageTarget = (command: Command) => - command.requiredOption("-t, --to ", CHANNEL_TARGET_DESCRIPTION); + command.requiredOption("-t, --target ", CHANNEL_TARGET_DESCRIPTION); const runMessageAction = async (action: string, opts: Record) => { setVerbose(Boolean(opts.verbose)); diff --git a/src/cli/program/message/register.discord-admin.ts b/src/cli/program/message/register.discord-admin.ts index 95e104766..23a9313b2 100644 --- a/src/cli/program/message/register.discord-admin.ts +++ b/src/cli/program/message/register.discord-admin.ts @@ -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 ", "Channel id"), + helpers.withRequiredMessageTarget( + channel.command("info").description("Fetch channel info"), + ), ) .action(async (opts) => { await helpers.runMessageAction("channel-info", opts); diff --git a/src/cli/program/message/register.permissions-search.ts b/src/cli/program/message/register.permissions-search.ts index b0173709d..4aeb7103e 100644 --- a/src/cli/program/message/register.permissions-search.ts +++ b/src/cli/program/message/register.permissions-search.ts @@ -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 ", "Channel id (defaults to --to)") .action(async (opts) => { await helpers.runMessageAction("permissions", opts); }); diff --git a/src/cli/program/message/register.pins.ts b/src/cli/program/message/register.pins.ts index c55315590..962f2f55d 100644 --- a/src/cli/program/message/register.pins.ts +++ b/src/cli/program/message/register.pins.ts @@ -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 ", "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 ", "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 ", "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 ", "Channel id (defaults to --to)") .option("--limit ", "Result limit") .action(async (opts) => { await helpers.runMessageAction("list-pins", opts); diff --git a/src/cli/program/message/register.reactions.ts b/src/cli/program/message/register.reactions.ts index 461b080d1..d8d635bd9 100644 --- a/src/cli/program/message/register.reactions.ts +++ b/src/cli/program/message/register.reactions.ts @@ -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 ", "Message id") .option("--emoji ", "Emoji for reactions") .option("--remove", "Remove reaction", false) .option("--participant ", "WhatsApp reaction participant") .option("--from-me", "WhatsApp reaction fromMe", false) - .option("--channel-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 ", "Message id") .option("--limit ", "Result limit") - .option("--channel-id ", "Channel id (defaults to --to)") .action(async (opts) => { await helpers.runMessageAction("reactions", opts); }); diff --git a/src/cli/program/message/register.read-edit-delete.ts b/src/cli/program/message/register.read-edit-delete.ts index 03d781772..7ddcf5dbf 100644 --- a/src/cli/program/message/register.read-edit-delete.ts +++ b/src/cli/program/message/register.read-edit-delete.ts @@ -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 ", "Result limit") .option("--before ", "Read/search before id") .option("--after ", "Read/search after id") .option("--around ", "Read around id") - .option("--channel-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 ", "Message body"), ), ) - .option("--channel-id ", "Channel id (defaults to --to)") .option("--thread-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 ", "Message id"), ), ) - .option("--channel-id ", "Channel id (defaults to --to)") .action(async (opts) => { await helpers.runMessageAction("delete", opts); }); diff --git a/src/cli/program/message/register.thread.ts b/src/cli/program/message/register.thread.ts index 8940e61a3..e58cb55af 100644 --- a/src/cli/program/message/register.thread.ts +++ b/src/cli/program/message/register.thread.ts @@ -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 ", "Thread name"), ), ) - .option("--channel-id ", "Channel id (defaults to --to)") .option("--message-id ", "Message id (optional)") .option("--auto-archive-min ", "Thread auto-archive minutes") .action(async (opts) => { diff --git a/src/cli/program/register.message.ts b/src/cli/program/register.message.ts index df211fd6c..bcff92a98 100644 --- a/src/cli/program/register.message.ts +++ b/src/cli/program/register.message.ts @@ -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")}`, ) diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index a8a6e9639..e18736653 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -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"], }, diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 3773a2102..b9593fbfc 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -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; diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 88fe168f7..97d48be44 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -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({ diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index d60813267..fc53dda34 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -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; +}): 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.`); +} diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index 197d9789f..f2dd4f1f0 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -39,6 +39,12 @@ export class DirectoryCache { 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; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index e175d0928..26cc6bbe0 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -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" }, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index e0580cd1a..87d66705c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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 { 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 = + { + 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"; +} diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts new file mode 100644 index 000000000..3751a8e70 --- /dev/null +++ b/src/infra/outbound/outbound-policy.test.ts @@ -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"); + }); +}); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts new file mode 100644 index 000000000..280d77bb3 --- /dev/null +++ b/src/infra/outbound/outbound-send-service.ts @@ -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; + accountId?: string | null; + gateway?: OutboundGatewayContext; + toolContext?: ChannelThreadingToolContext; + deps?: OutboundSendDeps; + dryRun: boolean; + mirror?: { + sessionKey: string; + agentId?: string; + }; +}; + +function extractToolPayload(result: AgentToolResult): 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; + 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; + 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, + }; +} diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 8d9ade55e..e493b6bed 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -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 { + return resolveMessagingTarget(params); +} + const CACHE_TTL_MS = 30 * 60 * 1000; const directoryCache = new DirectoryCache(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(); } diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 9235079ba..9df3c731f 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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`), }; }