refactor: require target for message actions

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

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,10 @@ import type {
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import {
listConfiguredMessageChannels,
resolveMessageChannelSelection,
} from "./channel-selection.js";
import { listConfiguredMessageChannels, resolveMessageChannelSelection } from "./channel-selection.js";
import { applyTargetToParams } from "./channel-target.js";
import type { OutboundSendDeps } from "./deliver.js";
import type { MessagePollResult, MessageSendResult } from "./message.js";
import { sendMessage, sendPoll } from "./message.js";
import {
applyCrossContextDecoration,
buildCrossContextDecoration,
@@ -27,7 +24,9 @@ import {
enforceCrossContextPolicy,
shouldApplyCrossContextMarker,
} from "./outbound-policy.js";
import { resolveMessagingTarget } from "./target-resolver.js";
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
import { actionRequiresTarget } from "./message-action-spec.js";
import { resolveChannelTarget } from "./target-resolver.js";
export type MessageActionRunnerGateway = {
url?: string;
@@ -195,7 +194,7 @@ async function resolveActionTarget(params: {
}): Promise<void> {
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : "";
if (toRaw) {
const resolved = await resolveMessagingTarget({
const resolved = await resolveChannelTarget({
cfg: params.cfg,
channel: params.channel,
input: toRaw,
@@ -210,7 +209,7 @@ async function resolveActionTarget(params: {
const channelIdRaw =
typeof params.args.channelId === "string" ? params.args.channelId.trim() : "";
if (channelIdRaw) {
const resolved = await resolveMessagingTarget({
const resolved = await resolveChannelTarget({
cfg: params.cfg,
channel: params.channel,
input: channelIdRaw,
@@ -237,7 +236,6 @@ type ResolvedActionContext = {
gateway?: MessageActionRunnerGateway;
input: RunMessageActionParams;
};
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
if (!input.gateway) return undefined;
return {
@@ -281,7 +279,7 @@ async function handleBroadcastAction(
for (const targetChannel of targetChannels) {
for (const target of rawTargets) {
try {
const resolved = await resolveMessagingTarget({
const resolved = await resolveChannelTarget({
cfg: input.cfg,
channel: targetChannel,
input: target,
@@ -293,7 +291,7 @@ async function handleBroadcastAction(
params: {
...params,
channel: targetChannel,
to: resolved.target.to,
target: resolved.target.to,
},
});
results.push({
@@ -326,11 +324,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true });
// Allow message to be omitted when sending media-only (e.g., voice notes)
const mediaHint = readStringParam(params, "media", { trim: false });
let message =
readStringParam(params, "message", {
required: !mediaHint, // Only require message if no media hint
required: !mediaHint,
allowEmpty: true,
}) ?? "";
@@ -364,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"));

View File

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

View File

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

View File

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

View File

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

View File

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