openclaw/src/infra/outbound/message-action-runner.ts
ANDRES FELIPE CARDONA SANTA 876641e11b 2214 feat: support native WhatsApp location messages
Add location parameters to the message tool and wire them through WhatsApp web outbound delivery to send native location pins.
2026-01-26 12:10:08 -05:00

1147 lines
31 KiB
TypeScript

import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { AgentToolResult } from '@mariozechner/pi-agent-core';
import { resolveSessionAgentId } from '../../agents/agent-scope.js';
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from '../../agents/tools/common.js';
import { parseReplyDirectives } from '../../auto-reply/reply/reply-directives.js';
import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js';
import type {
ChannelId,
ChannelMessageActionName,
ChannelThreadingToolContext,
} from '../../channels/plugins/types.js';
import type { ClawdbotConfig } from '../../config/config.js';
import { extensionForMime } from '../../media/mime.js';
import { parseSlackTarget } from '../../slack/targets.js';
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type GatewayClientMode,
type GatewayClientName,
} from '../../utils/message-channel.js';
import { loadWebMedia } from '../../web/media.js';
import {
listConfiguredMessageChannels,
resolveMessageChannelSelection,
} from './channel-selection.js';
import { applyTargetToParams } from './channel-target.js';
import type { OutboundSendDeps } from './deliver.js';
import {
actionHasTarget,
actionRequiresTarget,
} from './message-action-spec.js';
import type { MessagePollResult, MessageSendResult } from './message.js';
import {
applyCrossContextDecoration,
buildCrossContextDecoration,
enforceCrossContextPolicy,
shouldApplyCrossContextMarker,
type CrossContextDecoration,
} from './outbound-policy.js';
import {
executePollAction,
executeSendAction,
} from './outbound-send-service.js';
import {
ensureOutboundSessionEntry,
resolveOutboundSessionRoute,
} from './outbound-session.js';
import {
resolveChannelTarget,
type ResolvedMessagingTarget,
} from './target-resolver.js';
export type MessageActionRunnerGateway = {
url?: string;
token?: string;
timeoutMs?: number;
clientName: GatewayClientName;
clientDisplayName?: string;
mode: GatewayClientMode;
};
export type RunMessageActionParams = {
cfg: ClawdbotConfig;
action: ChannelMessageActionName;
params: Record<string, unknown>;
defaultAccountId?: string;
toolContext?: ChannelThreadingToolContext;
gateway?: MessageActionRunnerGateway;
deps?: OutboundSendDeps;
sessionKey?: string;
agentId?: string;
dryRun?: boolean;
abortSignal?: AbortSignal;
};
export type MessageActionRunResult =
| {
kind: 'send';
channel: ChannelId;
action: 'send';
to: string;
handledBy: 'plugin' | 'core';
payload: unknown;
toolResult?: AgentToolResult<unknown>;
sendResult?: MessageSendResult;
dryRun: boolean;
}
| {
kind: 'broadcast';
channel: ChannelId;
action: 'broadcast';
handledBy: 'core' | 'dry-run';
payload: {
results: Array<{
channel: ChannelId;
to: string;
ok: boolean;
error?: string;
result?: MessageSendResult;
}>;
};
dryRun: boolean;
}
| {
kind: 'poll';
channel: ChannelId;
action: 'poll';
to: string;
handledBy: 'plugin' | 'core';
payload: unknown;
toolResult?: AgentToolResult<unknown>;
pollResult?: MessagePollResult;
dryRun: boolean;
}
| {
kind: 'action';
channel: ChannelId;
action: Exclude<ChannelMessageActionName, 'send' | 'poll'>;
handledBy: 'plugin' | 'dry-run';
payload: unknown;
toolResult?: AgentToolResult<unknown>;
dryRun: boolean;
};
export function getToolResult(
result: MessageActionRunResult
): AgentToolResult<unknown> | undefined {
return 'toolResult' in result ? result.toolResult : undefined;
}
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;
}
function applyCrossContextMessageDecoration({
params,
message,
decoration,
preferEmbeds,
}: {
params: Record<string, unknown>;
message: string;
decoration: CrossContextDecoration;
preferEmbeds: boolean;
}): string {
const applied = applyCrossContextDecoration({
message,
decoration,
preferEmbeds,
});
params.message = applied.message;
if (applied.embeds?.length) {
params.embeds = applied.embeds;
}
return applied.message;
}
async function maybeApplyCrossContextMarker(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
action: ChannelMessageActionName;
target: string;
toolContext?: ChannelThreadingToolContext;
accountId?: string | null;
args: Record<string, unknown>;
message: string;
preferEmbeds: boolean;
}): Promise<string> {
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
return params.message;
}
const decoration = await buildCrossContextDecoration({
cfg: params.cfg,
channel: params.channel,
target: params.target,
toolContext: params.toolContext,
accountId: params.accountId ?? undefined,
});
if (!decoration) return params.message;
return applyCrossContextMessageDecoration({
params: params.args,
message: params.message,
decoration,
preferEmbeds: params.preferEmbeds,
});
}
function readBooleanParam(
params: Record<string, unknown>,
key: string
): boolean | undefined {
const raw = params[key];
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'string') {
const trimmed = raw.trim().toLowerCase();
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
}
return undefined;
}
function resolveSlackAutoThreadId(params: {
to: string;
toolContext?: ChannelThreadingToolContext;
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) return undefined;
// Only mirror auto-threading when Slack would reply in the active thread for this channel.
if (context.replyToMode !== 'all' && context.replyToMode !== 'first')
return undefined;
const parsedTarget = parseSlackTarget(params.to, { defaultKind: 'channel' });
if (!parsedTarget || parsedTarget.kind !== 'channel') return undefined;
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase())
return undefined;
if (context.replyToMode === 'first' && context.hasRepliedRef?.value)
return undefined;
return context.currentThreadTs;
}
function resolveAttachmentMaxBytes(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
accountId?: string | null;
}): number | undefined {
const fallback = params.cfg.agents?.defaults?.mediaMaxMb;
if (params.channel !== 'bluebubbles') {
return typeof fallback === 'number' ? fallback * 1024 * 1024 : undefined;
}
const accountId =
typeof params.accountId === 'string' ? params.accountId.trim() : '';
const channelCfg = params.cfg.channels?.bluebubbles;
const channelObj =
channelCfg && typeof channelCfg === 'object'
? (channelCfg as Record<string, unknown>)
: undefined;
const channelMediaMax =
typeof channelObj?.mediaMaxMb === 'number'
? channelObj.mediaMaxMb
: undefined;
const accountsObj =
channelObj?.accounts && typeof channelObj.accounts === 'object'
? (channelObj.accounts as Record<string, unknown>)
: undefined;
const accountCfg =
accountId && accountsObj ? accountsObj[accountId] : undefined;
const accountMediaMax =
accountCfg && typeof accountCfg === 'object'
? (accountCfg as Record<string, unknown>).mediaMaxMb
: undefined;
const limitMb =
(typeof accountMediaMax === 'number' ? accountMediaMax : undefined) ??
channelMediaMax ??
params.cfg.agents?.defaults?.mediaMaxMb;
return typeof limitMb === 'number' ? limitMb * 1024 * 1024 : undefined;
}
function inferAttachmentFilename(params: {
mediaHint?: string;
contentType?: string;
}): string | undefined {
const mediaHint = params.mediaHint?.trim();
if (mediaHint) {
try {
if (mediaHint.startsWith('file://')) {
const filePath = fileURLToPath(mediaHint);
const base = path.basename(filePath);
if (base) return base;
} else if (/^https?:\/\//i.test(mediaHint)) {
const url = new URL(mediaHint);
const base = path.basename(url.pathname);
if (base) return base;
} else {
const base = path.basename(mediaHint);
if (base) return base;
}
} catch {
// fall through to content-type based default
}
}
const ext = params.contentType
? extensionForMime(params.contentType)
: undefined;
return ext ? `attachment${ext}` : 'attachment';
}
function normalizeBase64Payload(params: {
base64?: string;
contentType?: string;
}): {
base64?: string;
contentType?: string;
} {
if (!params.base64)
return { base64: params.base64, contentType: params.contentType };
const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim());
if (!match) return { base64: params.base64, contentType: params.contentType };
const [, mime, payload] = match;
return {
base64: payload,
contentType: params.contentType ?? mime,
};
}
async function hydrateSetGroupIconParams(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
accountId?: string | null;
args: Record<string, unknown>;
action: ChannelMessageActionName;
dryRun?: boolean;
}): Promise<void> {
if (params.action !== 'setGroupIcon') return;
const mediaHint = readStringParam(params.args, 'media', { trim: false });
const fileHint =
readStringParam(params.args, 'path', { trim: false }) ??
readStringParam(params.args, 'filePath', { trim: false });
const contentTypeParam =
readStringParam(params.args, 'contentType') ??
readStringParam(params.args, 'mimeType');
const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
const normalized = normalizeBase64Payload({
base64: rawBuffer,
contentType: contentTypeParam ?? undefined,
});
if (normalized.base64 !== rawBuffer && normalized.base64) {
params.args.buffer = normalized.base64;
if (normalized.contentType && !contentTypeParam) {
params.args.contentType = normalized.contentType;
}
}
const filename = readStringParam(params.args, 'filename');
const mediaSource = mediaHint ?? fileHint;
if (
!params.dryRun &&
!readStringParam(params.args, 'buffer', { trim: false }) &&
mediaSource
) {
const maxBytes = resolveAttachmentMaxBytes({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
});
const media = await loadWebMedia(mediaSource, maxBytes);
params.args.buffer = media.buffer.toString('base64');
if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType;
}
if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: media.fileName ?? mediaSource,
contentType: media.contentType ?? contentTypeParam ?? undefined,
});
}
} else if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: mediaSource,
contentType: contentTypeParam ?? undefined,
});
}
}
async function hydrateSendAttachmentParams(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
accountId?: string | null;
args: Record<string, unknown>;
action: ChannelMessageActionName;
dryRun?: boolean;
}): Promise<void> {
if (params.action !== 'sendAttachment') return;
const mediaHint = readStringParam(params.args, 'media', { trim: false });
const fileHint =
readStringParam(params.args, 'path', { trim: false }) ??
readStringParam(params.args, 'filePath', { trim: false });
const contentTypeParam =
readStringParam(params.args, 'contentType') ??
readStringParam(params.args, 'mimeType');
const caption = readStringParam(params.args, 'caption', {
allowEmpty: true,
})?.trim();
const message = readStringParam(params.args, 'message', {
allowEmpty: true,
})?.trim();
if (!caption && message) params.args.caption = message;
const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
const normalized = normalizeBase64Payload({
base64: rawBuffer,
contentType: contentTypeParam ?? undefined,
});
if (normalized.base64 !== rawBuffer && normalized.base64) {
params.args.buffer = normalized.base64;
if (normalized.contentType && !contentTypeParam) {
params.args.contentType = normalized.contentType;
}
}
const filename = readStringParam(params.args, 'filename');
const mediaSource = mediaHint ?? fileHint;
if (
!params.dryRun &&
!readStringParam(params.args, 'buffer', { trim: false }) &&
mediaSource
) {
const maxBytes = resolveAttachmentMaxBytes({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
});
const media = await loadWebMedia(mediaSource, maxBytes);
params.args.buffer = media.buffer.toString('base64');
if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType;
}
if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: media.fileName ?? mediaSource,
contentType: media.contentType ?? contentTypeParam ?? undefined,
});
}
} else if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: mediaSource,
contentType: contentTypeParam ?? undefined,
});
}
}
function parseButtonsParam(params: Record<string, unknown>): void {
const raw = params.buttons;
if (typeof raw !== 'string') return;
const trimmed = raw.trim();
if (!trimmed) {
delete params.buttons;
return;
}
try {
params.buttons = JSON.parse(trimmed) as unknown;
} catch {
throw new Error('--buttons must be valid JSON');
}
}
function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card;
if (typeof raw !== 'string') return;
const trimmed = raw.trim();
if (!trimmed) {
delete params.card;
return;
}
try {
params.card = JSON.parse(trimmed) as unknown;
} catch {
throw new Error('--card must be valid JSON');
}
}
async function resolveChannel(
cfg: ClawdbotConfig,
params: Record<string, unknown>
) {
const channelHint = readStringParam(params, 'channel');
const selection = await resolveMessageChannelSelection({
cfg,
channel: channelHint,
});
return selection.channel;
}
async function resolveActionTarget(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
action: ChannelMessageActionName;
args: Record<string, unknown>;
accountId?: string | null;
}): Promise<ResolvedMessagingTarget | undefined> {
let resolvedTarget: ResolvedMessagingTarget | undefined;
const toRaw = typeof params.args.to === 'string' ? params.args.to.trim() : '';
if (toRaw) {
const resolved = await resolveChannelTarget({
cfg: params.cfg,
channel: params.channel,
input: toRaw,
accountId: params.accountId ?? undefined,
});
if (resolved.ok) {
params.args.to = resolved.target.to;
resolvedTarget = resolved.target;
} else {
throw resolved.error;
}
}
const channelIdRaw =
typeof params.args.channelId === 'string'
? params.args.channelId.trim()
: '';
if (channelIdRaw) {
const resolved = await resolveChannelTarget({
cfg: params.cfg,
channel: params.channel,
input: channelIdRaw,
accountId: params.accountId ?? undefined,
preferredKind: 'group',
});
if (resolved.ok) {
if (resolved.target.kind === 'user') {
throw new Error(
`Channel id "${channelIdRaw}" resolved to a user target.`
);
}
params.args.channelId = resolved.target.to.replace(
/^(channel|group):/i,
''
);
} else {
throw resolved.error;
}
}
return resolvedTarget;
}
type ResolvedActionContext = {
cfg: ClawdbotConfig;
params: Record<string, unknown>;
channel: ChannelId;
accountId?: string | null;
dryRun: boolean;
gateway?: MessageActionRunnerGateway;
input: RunMessageActionParams;
agentId?: string;
resolvedTarget?: ResolvedMessagingTarget;
abortSignal?: AbortSignal;
};
function resolveGateway(
input: RunMessageActionParams
): MessageActionRunnerGateway | undefined {
if (!input.gateway) return undefined;
return {
url: input.gateway.url,
token: input.gateway.token,
timeoutMs: input.gateway.timeoutMs,
clientName: input.gateway.clientName,
clientDisplayName: input.gateway.clientDisplayName,
mode: input.gateway.mode,
};
}
async function handleBroadcastAction(
input: RunMessageActionParams,
params: Record<string, unknown>
): Promise<MessageActionRunResult> {
throwIfAborted(input.abortSignal);
const broadcastEnabled =
input.cfg.tools?.message?.broadcast?.enabled !== false;
if (!broadcastEnabled) {
throw new Error(
'Broadcast is disabled. Set tools.message.broadcast.enabled to true.'
);
}
const rawTargets =
readStringArrayParam(params, 'targets', { required: true }) ?? [];
if (rawTargets.length === 0) {
throw new Error('Broadcast requires at least one target in --targets.');
}
const channelHint = readStringParam(params, 'channel');
const configured = await listConfiguredMessageChannels(input.cfg);
if (configured.length === 0) {
throw new Error('Broadcast requires at least one configured channel.');
}
const targetChannels =
channelHint && channelHint.trim().toLowerCase() !== 'all'
? [await resolveChannel(input.cfg, { channel: channelHint })]
: configured;
const results: Array<{
channel: ChannelId;
to: string;
ok: boolean;
error?: string;
result?: MessageSendResult;
}> = [];
const isAbortError = (err: unknown): boolean =>
err instanceof Error && err.name === 'AbortError';
for (const targetChannel of targetChannels) {
throwIfAborted(input.abortSignal);
for (const target of rawTargets) {
throwIfAborted(input.abortSignal);
try {
const resolved = await resolveChannelTarget({
cfg: input.cfg,
channel: targetChannel,
input: target,
});
if (!resolved.ok) throw resolved.error;
const sendResult = await runMessageAction({
...input,
action: 'send',
params: {
...params,
channel: targetChannel,
target: resolved.target.to,
},
});
results.push({
channel: targetChannel,
to: resolved.target.to,
ok: true,
result:
sendResult.kind === 'send' ? sendResult.sendResult : undefined,
});
} catch (err) {
if (isAbortError(err)) throw err;
results.push({
channel: targetChannel,
to: target,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
return {
kind: 'broadcast',
channel: (targetChannels[0] ?? 'discord') as ChannelId,
action: 'broadcast',
handledBy: input.dryRun ? 'dry-run' : 'core',
payload: { results },
dryRun: Boolean(input.dryRun),
};
}
function throwIfAborted(abortSignal?: AbortSignal): void {
if (abortSignal?.aborted) {
const err = new Error('Message send aborted');
err.name = 'AbortError';
throw err;
}
}
async function handleSendAction(
ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const {
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
agentId,
resolvedTarget,
abortSignal,
} = ctx;
throwIfAborted(abortSignal);
const action: ChannelMessageActionName = 'send';
const to = readStringParam(params, 'to', { required: true });
// Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, 'media', { trim: false }) ??
readStringParam(params, 'path', { trim: false }) ??
readStringParam(params, 'filePath', { trim: false });
const hasCard = params.card != null && typeof params.card === 'object';
let message =
readStringParam(params, 'message', {
required: !mediaHint && !hasCard,
allowEmpty: true,
}) ?? '';
const parsed = parseReplyDirectives(message);
const mergedMediaUrls: string[] = [];
const seenMedia = new Set<string>();
const pushMedia = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return;
if (seenMedia.has(trimmed)) return;
seenMedia.add(trimmed);
mergedMediaUrls.push(trimmed);
};
pushMedia(mediaHint);
for (const url of parsed.mediaUrls ?? []) pushMedia(url);
pushMedia(parsed.mediaUrl);
message = parsed.text;
params.message = message;
if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId;
if (!params.media) {
// Use path/filePath if media not set, then fall back to parsed directives
params.media = mergedMediaUrls[0] || undefined;
}
message = await maybeApplyCrossContextMarker({
cfg,
channel,
action,
target: to,
toolContext: input.toolContext,
accountId,
args: params,
message,
preferEmbeds: true,
});
const mediaUrl = readStringParam(params, 'media', { trim: false });
const gifPlayback = readBooleanParam(params, 'gifPlayback') ?? false;
const bestEffort = readBooleanParam(params, 'bestEffort');
// Extract location parameters
const latitude = readNumberParam(params, 'latitude');
const longitude = readNumberParam(params, 'longitude');
const locationName = readStringParam(params, 'locationName');
const locationAddress = readStringParam(params, 'locationAddress');
const locationAccuracy = readNumberParam(params, 'locationAccuracy');
// Validate location coordinates if provided
let location:
| {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
}
| undefined;
if (latitude != null && longitude != null) {
// Validate latitude range: -90 to 90
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new Error(
`Invalid latitude: ${latitude}. Must be between -90 and 90.`
);
}
// Validate longitude range: -180 to 180
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
throw new Error(
`Invalid longitude: ${longitude}. Must be between -180 and 180.`
);
}
// Validate accuracy if provided (must be positive)
if (
locationAccuracy != null &&
(!Number.isFinite(locationAccuracy) || locationAccuracy < 0)
) {
throw new Error(
`Invalid location accuracy: ${locationAccuracy}. Must be a positive number.`
);
}
location = {
latitude,
longitude,
name: locationName ?? undefined,
address: locationAddress ?? undefined,
accuracy: locationAccuracy ?? undefined,
};
} else if (latitude != null || longitude != null) {
// If only one coordinate is provided, that's an error
throw new Error(
'Both latitude and longitude are required for location messages.'
);
}
const replyToId = readStringParam(params, 'replyTo');
const threadId = readStringParam(params, 'threadId');
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
const slackAutoThreadId =
channel === 'slack' && !replyToId && !threadId
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const outboundRoute =
agentId && !dryRun
? await resolveOutboundSessionRoute({
cfg,
channel,
agentId,
accountId,
target: to,
resolvedTarget,
replyToId,
threadId: threadId ?? slackAutoThreadId,
})
: null;
if (outboundRoute && agentId && !dryRun) {
await ensureOutboundSessionEntry({
cfg,
agentId,
channel,
accountId,
route: outboundRoute,
});
}
const mirrorMediaUrls =
mergedMediaUrls.length > 0
? mergedMediaUrls
: mediaUrl
? [mediaUrl]
: undefined;
throwIfAborted(abortSignal);
const send = await executeSendAction({
ctx: {
cfg,
channel,
params,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,
deps: input.deps,
dryRun,
mirror:
outboundRoute && !dryRun
? {
sessionKey: outboundRoute.sessionKey,
agentId,
text: message,
mediaUrls: mirrorMediaUrls,
}
: undefined,
abortSignal,
},
to,
message,
mediaUrl: mediaUrl || undefined,
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
gifPlayback,
bestEffort: bestEffort ?? undefined,
location,
});
return {
kind: 'send',
channel,
action,
to,
handledBy: send.handledBy,
payload: send.payload,
toolResult: send.toolResult,
sendResult: send.sendResult,
dryRun,
};
}
async function handlePollAction(
ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const {
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal,
} = ctx;
throwIfAborted(abortSignal);
const action: ChannelMessageActionName = 'poll';
const to = readStringParam(params, 'to', { required: true });
const question = readStringParam(params, 'pollQuestion', {
required: true,
});
const options =
readStringArrayParam(params, 'pollOption', { required: true }) ?? [];
if (options.length < 2) {
throw new Error('pollOption requires at least two values');
}
const allowMultiselect = readBooleanParam(params, 'pollMulti') ?? false;
const durationHours = readNumberParam(params, 'pollDurationHours', {
integer: true,
});
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
const base = typeof params.message === 'string' ? params.message : '';
await maybeApplyCrossContextMarker({
cfg,
channel,
action,
target: to,
toolContext: input.toolContext,
accountId,
args: params,
message: base,
preferEmbeds: true,
});
const poll = await executePollAction({
ctx: {
cfg,
channel,
params,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,
dryRun,
},
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
});
return {
kind: 'poll',
channel,
action,
to,
handledBy: poll.handledBy,
payload: poll.payload,
toolResult: poll.toolResult,
pollResult: poll.pollResult,
dryRun,
};
}
async function handlePluginAction(
ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const {
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal,
} = ctx;
throwIfAborted(abortSignal);
const action = input.action as Exclude<
ChannelMessageActionName,
'send' | 'poll' | 'broadcast'
>;
if (dryRun) {
return {
kind: 'action',
channel,
action,
handledBy: 'dry-run',
payload: { ok: true, dryRun: true, channel, action },
dryRun: true,
};
}
const handled = await dispatchChannelMessageAction({
channel,
action,
cfg,
params,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,
dryRun,
});
if (!handled) {
throw new Error(
`Message action ${action} not supported for channel ${channel}.`
);
}
return {
kind: 'action',
channel,
action,
handledBy: 'plugin',
payload: extractToolPayload(handled),
toolResult: handled,
dryRun,
};
}
export async function runMessageAction(
input: RunMessageActionParams
): Promise<MessageActionRunResult> {
const cfg = input.cfg;
const params = { ...input.params };
const resolvedAgentId =
input.agentId ??
(input.sessionKey
? resolveSessionAgentId({ sessionKey: input.sessionKey, config: cfg })
: undefined);
parseButtonsParam(params);
parseCardParam(params);
const action = input.action;
if (action === 'broadcast') {
return handleBroadcastAction(input, params);
}
const explicitTarget =
typeof params.target === 'string' ? params.target.trim() : '';
const hasLegacyTarget =
(typeof params.to === 'string' && params.to.trim().length > 0) ||
(typeof params.channelId === 'string' &&
params.channelId.trim().length > 0);
if (explicitTarget && hasLegacyTarget) {
delete params.to;
delete params.channelId;
}
if (
!explicitTarget &&
!hasLegacyTarget &&
actionRequiresTarget(action) &&
!actionHasTarget(action, params)
) {
const inferredTarget = input.toolContext?.currentChannelId?.trim();
if (inferredTarget) {
params.target = inferredTarget;
}
}
if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) {
const legacyTo = typeof params.to === 'string' ? params.to.trim() : '';
const legacyChannelId =
typeof params.channelId === 'string' ? params.channelId.trim() : '';
const legacyTarget = legacyTo || legacyChannelId;
if (legacyTarget) {
params.target = legacyTarget;
delete params.to;
delete params.channelId;
}
}
const explicitChannel =
typeof params.channel === 'string' ? params.channel.trim() : '';
if (!explicitChannel) {
const inferredChannel = normalizeMessageChannel(
input.toolContext?.currentChannelProvider
);
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
params.channel = inferredChannel;
}
}
applyTargetToParams({ action, args: params });
if (actionRequiresTarget(action)) {
if (!actionHasTarget(action, params)) {
throw new Error(`Action ${action} requires a target.`);
}
}
const channel = await resolveChannel(cfg, params);
const accountId =
readStringParam(params, 'accountId') ?? input.defaultAccountId;
if (accountId) {
params.accountId = accountId;
}
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, 'dryRun'));
await hydrateSendAttachmentParams({
cfg,
channel,
accountId,
args: params,
action,
dryRun,
});
await hydrateSetGroupIconParams({
cfg,
channel,
accountId,
args: params,
action,
dryRun,
});
const resolvedTarget = await resolveActionTarget({
cfg,
channel,
action,
args: params,
accountId,
});
enforceCrossContextPolicy({
channel,
action,
args: params,
toolContext: input.toolContext,
cfg,
});
const gateway = resolveGateway(input);
if (action === 'send') {
return handleSendAction({
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
agentId: resolvedAgentId,
resolvedTarget,
abortSignal: input.abortSignal,
});
}
if (action === 'poll') {
return handlePollAction({
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal: input.abortSignal,
});
}
return handlePluginAction({
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal: input.abortSignal,
});
}