refactor: require target for message actions
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"}] ]'
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")}`,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,50 +361,29 @@ 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,
|
||||
deps: input.deps,
|
||||
dryRun,
|
||||
});
|
||||
if (handled) {
|
||||
return {
|
||||
kind: "send",
|
||||
channel,
|
||||
action,
|
||||
to,
|
||||
handledBy: "plugin",
|
||||
payload: extractToolPayload(handled),
|
||||
toolResult: handled,
|
||||
dryRun,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result: MessageSendResult = await sendMessage({
|
||||
cfg,
|
||||
mirror:
|
||||
input.sessionKey && !dryRun
|
||||
? {
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
to,
|
||||
content: message,
|
||||
message,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
channel: channel || undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
gifPlayback,
|
||||
dryRun,
|
||||
bestEffort: bestEffort ?? undefined,
|
||||
deps: input.deps,
|
||||
gateway,
|
||||
mirror:
|
||||
input.sessionKey && !dryRun
|
||||
? {
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
}
|
||||
: 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"));
|
||||
|
||||
50
src/infra/outbound/message-action-spec.ts
Normal file
50
src/infra/outbound/message-action-spec.ts
Normal 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";
|
||||
}
|
||||
93
src/infra/outbound/outbound-policy.test.ts
Normal file
93
src/infra/outbound/outbound-policy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
164
src/infra/outbound/outbound-send-service.ts
Normal file
164
src/infra/outbound/outbound-send-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user