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.
This commit is contained in:
ANDRES FELIPE CARDONA SANTA 2026-01-26 12:10:08 -05:00
parent c8063bdcd8
commit 876641e11b
10 changed files with 1033 additions and 498 deletions

View File

@ -1,55 +1,73 @@
import { Type } from "@sinclair/typebox"; import { Type } from '@sinclair/typebox';
import { BLUEBUBBLES_GROUP_ACTIONS } from '../../channels/plugins/bluebubbles-actions.js';
import { import {
listChannelMessageActions, listChannelMessageActions,
supportsChannelMessageButtons, supportsChannelMessageButtons,
supportsChannelMessageCards, supportsChannelMessageCards,
} from "../../channels/plugins/message-actions.js"; } from '../../channels/plugins/message-actions.js';
import { import {
CHANNEL_MESSAGE_ACTION_NAMES, CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName, type ChannelMessageActionName,
} from "../../channels/plugins/types.js"; } from '../../channels/plugins/types.js';
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import type { ClawdbotConfig } from '../../config/config.js';
import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from '../../config/config.js';
import { loadConfig } from "../../config/config.js"; import {
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; GATEWAY_CLIENT_IDS,
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; GATEWAY_CLIENT_MODES,
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; } from '../../gateway/protocol/client-info.js';
import { resolveSessionAgentId } from "../agent-scope.js"; import {
import { normalizeAccountId } from "../../routing/session-key.js"; getToolResult,
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; runMessageAction,
import { listChannelSupportedActions } from "../channel-tools.js"; } from '../../infra/outbound/message-action-runner.js';
import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeTargetForProvider } from '../../infra/outbound/target-normalization.js';
import type { AnyAgentTool } from "./common.js"; import { normalizeAccountId } from '../../routing/session-key.js';
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { normalizeMessageChannel } from '../../utils/message-channel.js';
import { resolveSessionAgentId } from '../agent-scope.js';
import { listChannelSupportedActions } from '../channel-tools.js';
import {
channelTargetSchema,
channelTargetsSchema,
stringEnum,
} from '../schema/typebox.js';
import type { AnyAgentTool } from './common.js';
import { jsonResult, readNumberParam, readStringParam } from './common.js';
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
function buildRoutingSchema() { function buildRoutingSchema() {
return { return {
channel: Type.Optional(Type.String()), channel: Type.Optional(Type.String()),
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })), target: Type.Optional(
channelTargetSchema({ description: 'Target channel/user id or name.' })
),
targets: Type.Optional(channelTargetsSchema()), targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()), dryRun: Type.Optional(Type.Boolean()),
}; };
} }
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { function buildSendSchema(options: {
includeButtons: boolean;
includeCards: boolean;
}) {
const props: Record<string, unknown> = { const props: Record<string, unknown> = {
message: Type.Optional(Type.String()), message: Type.Optional(Type.String()),
effectId: Type.Optional( effectId: Type.Optional(
Type.String({ Type.String({
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).", description:
}), 'Message effect name/id for sendWithEffect (e.g., invisible ink).',
})
), ),
effect: Type.Optional( effect: Type.Optional(
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }), Type.String({
description: 'Alias for effectId (e.g., invisible-ink, balloons).',
})
), ),
media: Type.Optional(Type.String()), media: Type.Optional(Type.String()),
filename: Type.Optional(Type.String()), filename: Type.Optional(Type.String()),
buffer: Type.Optional( buffer: Type.Optional(
Type.String({ Type.String({
description: "Base64 payload for attachments (optionally a data: URL).", description: 'Base64 payload for attachments (optionally a data: URL).',
}), })
), ),
contentType: Type.Optional(Type.String()), contentType: Type.Optional(Type.String()),
mimeType: Type.Optional(Type.String()), mimeType: Type.Optional(Type.String()),
@ -61,27 +79,57 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
asVoice: Type.Optional(Type.Boolean()), asVoice: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()), bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()),
latitude: Type.Optional(
Type.Number({
description:
'Latitude for location message (required with longitude for native WhatsApp location pin).',
})
),
longitude: Type.Optional(
Type.Number({
description:
'Longitude for location message (required with latitude for native WhatsApp location pin).',
})
),
locationName: Type.Optional(
Type.String({
description:
"Optional name for location message (e.g., 'Home', 'Office').",
})
),
locationAddress: Type.Optional(
Type.String({
description: 'Optional address text for location message.',
})
),
locationAccuracy: Type.Optional(
Type.Number({
description: 'Optional accuracy in meters for location message.',
})
),
buttons: Type.Optional( buttons: Type.Optional(
Type.Array( Type.Array(
Type.Array( Type.Array(
Type.Object({ Type.Object({
text: Type.String(), text: Type.String(),
callback_data: Type.String(), callback_data: Type.String(),
}), })
), ),
{ {
description: "Telegram inline keyboard buttons (array of button rows)", description:
}, 'Telegram inline keyboard buttons (array of button rows)',
), }
)
), ),
card: Type.Optional( card: Type.Optional(
Type.Object( Type.Object(
{}, {},
{ {
additionalProperties: true, additionalProperties: true,
description: "Adaptive Card JSON object (when supported by the channel)", description:
}, 'Adaptive Card JSON object (when supported by the channel)',
), }
)
), ),
}; };
if (!options.includeButtons) delete props.buttons; if (!options.includeButtons) delete props.buttons;
@ -123,10 +171,14 @@ function buildPollSchema() {
function buildChannelTargetSchema() { function buildChannelTargetSchema() {
return { return {
channelId: Type.Optional( channelId: Type.Optional(
Type.String({ description: "Channel id filter (search/thread list/event create)." }), Type.String({
description: 'Channel id filter (search/thread list/event create).',
})
), ),
channelIds: Type.Optional( channelIds: Type.Optional(
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })), Type.Array(
Type.String({ description: 'Channel id filter (repeatable).' })
)
), ),
guildId: Type.Optional(Type.String()), guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()), userId: Type.Optional(Type.String()),
@ -196,13 +248,17 @@ function buildChannelManagementSchema() {
categoryId: Type.Optional(Type.String()), categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional( clearParent: Type.Optional(
Type.Boolean({ Type.Boolean({
description: "Clear the parent/category when supported by the provider.", description:
}), 'Clear the parent/category when supported by the provider.',
})
), ),
}; };
} }
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) { function buildMessageToolSchemaProps(options: {
includeButtons: boolean;
includeCards: boolean;
}) {
return { return {
...buildRoutingSchema(), ...buildRoutingSchema(),
...buildSendSchema(options), ...buildSendSchema(options),
@ -221,7 +277,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
actions: readonly string[], actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean }, options: { includeButtons: boolean; includeCards: boolean }
) { ) {
const props = buildMessageToolSchemaProps(options); const props = buildMessageToolSchemaProps(options);
return Type.Object({ return Type.Object({
@ -242,7 +298,7 @@ type MessageToolOptions = {
currentChannelId?: string; currentChannelId?: string;
currentChannelProvider?: string; currentChannelProvider?: string;
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: 'off' | 'first' | 'all';
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
}; };
@ -250,10 +306,13 @@ function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listChannelMessageActions(cfg); const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg); const includeButtons = supportsChannelMessageButtons(cfg);
const includeCards = supportsChannelMessageCards(cfg); const includeCards = supportsChannelMessageCards(cfg);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { return buildMessageToolSchemaFromActions(
actions.length > 0 ? actions : ['send'],
{
includeButtons, includeButtons,
includeCards, includeCards,
}); }
);
} }
function resolveAgentAccountId(value?: string): string | undefined { function resolveAgentAccountId(value?: string): string | undefined {
@ -268,19 +327,21 @@ function filterActionsForContext(params: {
currentChannelId?: string; currentChannelId?: string;
}): ChannelMessageActionName[] { }): ChannelMessageActionName[] {
const channel = normalizeMessageChannel(params.channel); const channel = normalizeMessageChannel(params.channel);
if (!channel || channel !== "bluebubbles") return params.actions; if (!channel || channel !== 'bluebubbles') return params.actions;
const currentChannelId = params.currentChannelId?.trim(); const currentChannelId = params.currentChannelId?.trim();
if (!currentChannelId) return params.actions; if (!currentChannelId) return params.actions;
const normalizedTarget = const normalizedTarget =
normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId; normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId;
const lowered = normalizedTarget.trim().toLowerCase(); const lowered = normalizedTarget.trim().toLowerCase();
const isGroupTarget = const isGroupTarget =
lowered.startsWith("chat_guid:") || lowered.startsWith('chat_guid:') ||
lowered.startsWith("chat_id:") || lowered.startsWith('chat_id:') ||
lowered.startsWith("chat_identifier:") || lowered.startsWith('chat_identifier:') ||
lowered.startsWith("group:"); lowered.startsWith('group:');
if (isGroupTarget) return params.actions; if (isGroupTarget) return params.actions;
return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)); return params.actions.filter(
(action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)
);
} }
function buildMessageToolDescription(options?: { function buildMessageToolDescription(options?: {
@ -288,7 +349,8 @@ function buildMessageToolDescription(options?: {
currentChannel?: string; currentChannel?: string;
currentChannelId?: string; currentChannelId?: string;
}): string { }): string {
const baseDescription = "Send, delete, and manage messages via channel plugins."; const baseDescription =
'Send, delete, and manage messages via channel plugins.';
// If we have a current channel, show only its supported actions // If we have a current channel, show only its supported actions
if (options?.currentChannel) { if (options?.currentChannel) {
@ -302,8 +364,8 @@ function buildMessageToolDescription(options?: {
}); });
if (channelActions.length > 0) { if (channelActions.length > 0) {
// Always include "send" as a base action // Always include "send" as a base action
const allActions = new Set(["send", ...channelActions]); const allActions = new Set(['send', ...channelActions]);
const actionList = Array.from(allActions).sort().join(", "); const actionList = Array.from(allActions).sort().join(', ');
return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
} }
} }
@ -312,7 +374,7 @@ function buildMessageToolDescription(options?: {
if (options?.config) { if (options?.config) {
const actions = listChannelMessageActions(options.config); const actions = listChannelMessageActions(options.config);
if (actions.length > 0) { if (actions.length > 0) {
return `${baseDescription} Supports actions: ${actions.join(", ")}.`; return `${baseDescription} Supports actions: ${actions.join(', ')}.`;
} }
} }
@ -321,7 +383,9 @@ function buildMessageToolDescription(options?: {
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId); const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; const schema = options?.config
? buildMessageToolSchema(options.config)
: MessageToolSchema;
const description = buildMessageToolDescription({ const description = buildMessageToolDescription({
config: options?.config, config: options?.config,
currentChannel: options?.currentChannelProvider, currentChannel: options?.currentChannelProvider,
@ -329,34 +393,34 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}); });
return { return {
label: "Message", label: 'Message',
name: "message", name: 'message',
description, description,
parameters: schema, parameters: schema,
execute: async (_toolCallId, args, signal) => { execute: async (_toolCallId, args, signal) => {
// Check if already aborted before doing any work // Check if already aborted before doing any work
if (signal?.aborted) { if (signal?.aborted) {
const err = new Error("Message send aborted"); const err = new Error('Message send aborted');
err.name = "AbortError"; err.name = 'AbortError';
throw err; throw err;
} }
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig(); const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { const action = readStringParam(params, 'action', {
required: true, required: true,
}) as ChannelMessageActionName; }) as ChannelMessageActionName;
const accountId = readStringParam(params, "accountId") ?? agentAccountId; const accountId = readStringParam(params, 'accountId') ?? agentAccountId;
if (accountId) { if (accountId) {
params.accountId = accountId; params.accountId = accountId;
} }
const gateway = { const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }), url: readStringParam(params, 'gatewayUrl', { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }), token: readStringParam(params, 'gatewayToken', { trim: false }),
timeoutMs: readNumberParam(params, "timeoutMs"), timeoutMs: readNumberParam(params, 'timeoutMs'),
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
clientDisplayName: "agent", clientDisplayName: 'agent',
mode: GATEWAY_CLIENT_MODES.BACKEND, mode: GATEWAY_CLIENT_MODES.BACKEND,
}; };
@ -386,7 +450,10 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
gateway, gateway,
toolContext, toolContext,
agentId: options?.agentSessionKey agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) ? resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
})
: undefined, : undefined,
abortSignal: signal, abortSignal: signal,
}); });

View File

@ -1,30 +1,66 @@
import type { Command } from "commander"; import type { Command } from 'commander';
import type { MessageCliHelpers } from "./helpers.js"; import type { MessageCliHelpers } from './helpers.js';
export function registerMessageSendCommand(message: Command, helpers: MessageCliHelpers) { export function registerMessageSendCommand(
message: Command,
helpers: MessageCliHelpers
) {
helpers helpers
.withMessageBase( .withMessageBase(
helpers helpers
.withRequiredMessageTarget( .withRequiredMessageTarget(
message message
.command("send") .command('send')
.description("Send a message") .description('Send a message')
.option("-m, --message <text>", "Message body (required unless --media is set)"), .option(
'-m, --message <text>',
'Message body (required unless --media is set)'
)
) )
.option( .option(
"--media <path-or-url>", '--media <path-or-url>',
"Attach media (image/audio/video/document). Accepts local paths or URLs.", 'Attach media (image/audio/video/document). Accepts local paths or URLs.'
) )
.option( .option(
"--buttons <json>", '--buttons <json>',
"Telegram inline keyboard buttons as JSON (array of button rows)", 'Telegram inline keyboard buttons as JSON (array of button rows)'
)
.option(
'--card <json>',
'Adaptive Card JSON object (when supported by the channel)'
)
.option('--reply-to <id>', 'Reply-to message id')
.option('--thread-id <id>', 'Thread id (Telegram forum thread)')
.option(
'--gif-playback',
'Treat video media as GIF playback (WhatsApp only).',
false
)
.option(
'--latitude <number>',
'Latitude for location message (required with --longitude for native WhatsApp location pin)',
(value: string) => Number(value)
)
.option(
'--longitude <number>',
'Longitude for location message (required with --latitude for native WhatsApp location pin)',
(value: string) => Number(value)
)
.option(
'--location-name <text>',
"Optional name for location message (e.g., 'Home', 'Office')"
)
.option(
'--location-address <text>',
'Optional address text for location message'
)
.option(
'--location-accuracy <number>',
'Optional accuracy in meters for location message',
(value: string) => Number(value)
) )
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
) )
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("send", opts); await helpers.runMessageAction('send', opts);
}); });
} }

View File

@ -3,35 +3,43 @@ import {
chunkMarkdownTextWithMode, chunkMarkdownTextWithMode,
resolveChunkMode, resolveChunkMode,
resolveTextChunkLimit, resolveTextChunkLimit,
} from "../../auto-reply/chunk.js"; } from '../../auto-reply/chunk.js';
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from '../../auto-reply/types.js';
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; import { resolveChannelMediaMaxBytes } from '../../channels/plugins/media-limits.js';
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import { loadChannelOutboundAdapter } from '../../channels/plugins/outbound/load.js';
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ChannelOutboundAdapter } from '../../channels/plugins/types.js';
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from '../../config/config.js';
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from '../../config/markdown-tables.js';
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js";
import { import {
appendAssistantMessageToSessionTranscript, appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText, resolveMirroredTranscriptText,
} from "../../config/sessions.js"; } from '../../config/sessions.js';
import type { NormalizedOutboundPayload } from "./payloads.js"; import type { sendMessageDiscord } from '../../discord/send.js';
import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import type { sendMessageIMessage } from '../../imessage/send.js';
import type { OutboundChannel } from "./targets.js"; import {
markdownToSignalTextChunks,
type SignalTextStyleRange,
} from '../../signal/format.js';
import { sendMessageSignal } from '../../signal/send.js';
import type { sendMessageSlack } from '../../slack/send.js';
import type { sendMessageTelegram } from '../../telegram/send.js';
import type { sendMessageWhatsApp } from '../../web/outbound.js';
import type { NormalizedOutboundPayload } from './payloads.js';
import { normalizeReplyPayloadsForDelivery } from './payloads.js';
import type { OutboundChannel } from './targets.js';
export type { NormalizedOutboundPayload } from "./payloads.js"; export { normalizeOutboundPayloads } from './payloads.js';
export { normalizeOutboundPayloads } from "./payloads.js"; export type { NormalizedOutboundPayload } from './payloads.js';
type SendMatrixMessage = ( type SendMatrixMessage = (
to: string, to: string,
text: string, text: string,
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number }, opts?: {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
timeoutMs?: number;
}
) => Promise<{ messageId: string; roomId: string }>; ) => Promise<{ messageId: string; roomId: string }>;
export type OutboundSendDeps = { export type OutboundSendDeps = {
@ -45,12 +53,12 @@ export type OutboundSendDeps = {
sendMSTeams?: ( sendMSTeams?: (
to: string, to: string,
text: string, text: string,
opts?: { mediaUrl?: string }, opts?: { mediaUrl?: string }
) => Promise<{ messageId: string; conversationId: string }>; ) => Promise<{ messageId: string; conversationId: string }>;
}; };
export type OutboundDeliveryResult = { export type OutboundDeliveryResult = {
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, 'none'>;
messageId: string; messageId: string;
chatId?: string; chatId?: string;
channelId?: string; channelId?: string;
@ -67,23 +75,26 @@ type Chunker = (text: string, limit: number) => string[];
type ChannelHandler = { type ChannelHandler = {
chunker: Chunker | null; chunker: Chunker | null;
chunkerMode?: "text" | "markdown"; chunkerMode?: 'text' | 'markdown';
textChunkLimit?: number; textChunkLimit?: number;
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>; sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
sendText: (text: string) => Promise<OutboundDeliveryResult>; sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>; sendMedia: (
caption: string,
mediaUrl: string
) => Promise<OutboundDeliveryResult>;
}; };
function throwIfAborted(abortSignal?: AbortSignal): void { function throwIfAborted(abortSignal?: AbortSignal): void {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error("Outbound delivery aborted"); throw new Error('Outbound delivery aborted');
} }
} }
// Channel docking: outbound delivery delegates to plugin.outbound adapters. // Channel docking: outbound delivery delegates to plugin.outbound adapters.
async function createChannelHandler(params: { async function createChannelHandler(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, 'none'>;
to: string; to: string;
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
@ -115,7 +126,7 @@ async function createChannelHandler(params: {
function createPluginHandler(params: { function createPluginHandler(params: {
outbound?: ChannelOutboundAdapter; outbound?: ChannelOutboundAdapter;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, 'none'>;
to: string; to: string;
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
@ -138,7 +149,7 @@ function createPluginHandler(params: {
outbound.sendPayload!({ outbound.sendPayload!({
cfg: params.cfg, cfg: params.cfg,
to: params.to, to: params.to,
text: payload.text ?? "", text: payload.text ?? '',
mediaUrl: payload.mediaUrl, mediaUrl: payload.mediaUrl,
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
@ -176,7 +187,7 @@ function createPluginHandler(params: {
export async function deliverOutboundPayloads(params: { export async function deliverOutboundPayloads(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, 'none'>;
to: string; to: string;
accountId?: string; accountId?: string;
payloads: ReplyPayload[]; payloads: ReplyPayload[];
@ -186,6 +197,13 @@ export async function deliverOutboundPayloads(params: {
gifPlayback?: boolean; gifPlayback?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
bestEffort?: boolean; bestEffort?: boolean;
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
};
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
onPayload?: (payload: NormalizedOutboundPayload) => void; onPayload?: (payload: NormalizedOutboundPayload) => void;
mirror?: { mirror?: {
@ -216,11 +234,13 @@ export async function deliverOutboundPayloads(params: {
fallbackLimit: handler.textChunkLimit, fallbackLimit: handler.textChunkLimit,
}) })
: undefined; : undefined;
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length"; const chunkMode = handler.chunker
const isSignalChannel = channel === "signal"; ? resolveChunkMode(cfg, channel, accountId)
: 'length';
const isSignalChannel = channel === 'signal';
const signalTableMode = isSignalChannel const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) ? resolveMarkdownTableMode({ cfg, channel: 'signal', accountId })
: "code"; : 'code';
const signalMaxBytes = isSignalChannel const signalMaxBytes = isSignalChannel
? resolveChannelMediaMaxBytes({ ? resolveChannelMediaMaxBytes({
cfg, cfg,
@ -237,11 +257,11 @@ export async function deliverOutboundPayloads(params: {
results.push(await handler.sendText(text)); results.push(await handler.sendText(text));
return; return;
} }
if (chunkMode === "newline") { if (chunkMode === 'newline') {
const mode = handler.chunkerMode ?? "text"; const mode = handler.chunkerMode ?? 'text';
const blockChunks = const blockChunks =
mode === "markdown" mode === 'markdown'
? chunkMarkdownTextWithMode(text, textLimit, "newline") ? chunkMarkdownTextWithMode(text, textLimit, 'newline')
: chunkByParagraph(text, textLimit); : chunkByParagraph(text, textLimit);
if (!blockChunks.length && text) blockChunks.push(text); if (!blockChunks.length && text) blockChunks.push(text);
@ -262,14 +282,17 @@ export async function deliverOutboundPayloads(params: {
} }
}; };
const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => { const sendSignalText = async (
text: string,
styles: SignalTextStyleRange[]
) => {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
return { return {
channel: "signal" as const, channel: 'signal' as const,
...(await sendSignal(to, text, { ...(await sendSignal(to, text, {
maxBytes: signalMaxBytes, maxBytes: signalMaxBytes,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
textMode: "plain", textMode: 'plain',
textStyles: styles, textStyles: styles,
})), })),
}; };
@ -282,7 +305,9 @@ export async function deliverOutboundPayloads(params: {
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
tableMode: signalTableMode, tableMode: signalTableMode,
}) })
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); : markdownToSignalTextChunks(text, textLimit, {
tableMode: signalTableMode,
});
if (signalChunks.length === 0 && text) { if (signalChunks.length === 0 && text) {
signalChunks = [{ text, styles: [] }]; signalChunks = [{ text, styles: [] }];
} }
@ -294,28 +319,122 @@ export async function deliverOutboundPayloads(params: {
const sendSignalMedia = async (caption: string, mediaUrl: string) => { const sendSignalMedia = async (caption: string, mediaUrl: string) => {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { const formatted = markdownToSignalTextChunks(
caption,
Number.POSITIVE_INFINITY,
{
tableMode: signalTableMode, tableMode: signalTableMode,
})[0] ?? { }
)[0] ?? {
text: caption, text: caption,
styles: [], styles: [],
}; };
return { return {
channel: "signal" as const, channel: 'signal' as const,
...(await sendSignal(to, formatted.text, { ...(await sendSignal(to, formatted.text, {
mediaUrl, mediaUrl,
maxBytes: signalMaxBytes, maxBytes: signalMaxBytes,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
textMode: "plain", textMode: 'plain',
textStyles: formatted.styles, textStyles: formatted.styles,
})), })),
}; };
}; };
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads); const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
// Special handling for WhatsApp location messages
if (channel === 'whatsapp' && params.location && deps?.sendWhatsApp) {
const firstPayload = normalizedPayloads[0];
const hasMedia =
(firstPayload?.mediaUrls?.length ?? 0) > 0 || firstPayload?.mediaUrl;
// Only send location if there's no media (location messages are standalone)
if (!hasMedia) {
const text = firstPayload?.text ?? '';
try {
throwIfAborted(abortSignal);
const result = await deps.sendWhatsApp(to, text, {
verbose: false,
accountId: accountId ?? undefined,
location: params.location,
});
results.push({
channel: 'whatsapp',
...result,
});
// If there are more payloads, continue processing them
if (normalizedPayloads.length > 1) {
const remainingPayloads = normalizedPayloads.slice(1);
for (const payload of remainingPayloads) {
const payloadSummary: NormalizedOutboundPayload = {
text: payload.text ?? '',
mediaUrls:
payload.mediaUrls ??
(payload.mediaUrl ? [payload.mediaUrl] : []),
channelData: payload.channelData,
};
try {
throwIfAborted(abortSignal);
params.onPayload?.(payloadSummary);
if (handler.sendPayload && payload.channelData) {
results.push(await handler.sendPayload(payload));
continue;
}
if (payloadSummary.mediaUrls.length === 0) {
if (isSignalChannel) {
await sendSignalTextChunks(payloadSummary.text);
} else {
await sendTextChunks(payloadSummary.text);
}
continue;
}
let first = true;
for (const url of payloadSummary.mediaUrls) {
throwIfAborted(abortSignal);
const caption = first ? payloadSummary.text : '';
first = false;
if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url));
} else {
results.push(await handler.sendMedia(caption, url));
}
}
} catch (err) {
if (!params.bestEffort) throw err;
params.onError?.(err, payloadSummary);
}
}
}
if (params.mirror && results.length > 0) {
const mirrorText = resolveMirroredTranscriptText({
text: params.mirror.text,
mediaUrls: params.mirror.mediaUrls,
});
if (mirrorText) {
await appendAssistantMessageToSessionTranscript({
agentId: params.mirror.agentId,
sessionKey: params.mirror.sessionKey,
text: mirrorText,
});
}
}
return results;
} catch (err) {
if (!params.bestEffort) throw err;
params.onError?.(err, {
text: '',
mediaUrls: [],
channelData: undefined,
});
return results;
}
}
}
for (const payload of normalizedPayloads) { for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = { const payloadSummary: NormalizedOutboundPayload = {
text: payload.text ?? "", text: payload.text ?? '',
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), mediaUrls:
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
channelData: payload.channelData, channelData: payload.channelData,
}; };
try { try {
@ -337,7 +456,7 @@ export async function deliverOutboundPayloads(params: {
let first = true; let first = true;
for (const url of payloadSummary.mediaUrls) { for (const url of payloadSummary.mediaUrls) {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const caption = first ? payloadSummary.text : ""; const caption = first ? payloadSummary.text : '';
first = false; first = false;
if (isSignalChannel) { if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url)); results.push(await sendSignalMedia(caption, url));

View File

@ -1,48 +1,60 @@
import path from "node:path"; import path from 'node:path';
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url';
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from '@mariozechner/pi-agent-core';
import { resolveSessionAgentId } from '../../agents/agent-scope.js';
import { import {
readNumberParam, readNumberParam,
readStringArrayParam, readStringArrayParam,
readStringParam, readStringParam,
} from "../../agents/tools/common.js"; } from '../../agents/tools/common.js';
import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { parseReplyDirectives } from '../../auto-reply/reply/reply-directives.js';
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js';
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
import type { import type {
ChannelId, ChannelId,
ChannelMessageActionName, ChannelMessageActionName,
ChannelThreadingToolContext, ChannelThreadingToolContext,
} from "../../channels/plugins/types.js"; } from '../../channels/plugins/types.js';
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from '../../config/config.js';
import { extensionForMime } from '../../media/mime.js';
import { parseSlackTarget } from '../../slack/targets.js';
import { import {
isDeliverableMessageChannel, isDeliverableMessageChannel,
normalizeMessageChannel, normalizeMessageChannel,
type GatewayClientMode, type GatewayClientMode,
type GatewayClientName, type GatewayClientName,
} from "../../utils/message-channel.js"; } from '../../utils/message-channel.js';
import { loadWebMedia } from '../../web/media.js';
import { import {
listConfiguredMessageChannels, listConfiguredMessageChannels,
resolveMessageChannelSelection, resolveMessageChannelSelection,
} from "./channel-selection.js"; } from './channel-selection.js';
import { applyTargetToParams } from "./channel-target.js"; import { applyTargetToParams } from './channel-target.js';
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js"; import type { OutboundSendDeps } from './deliver.js';
import type { OutboundSendDeps } from "./deliver.js"; import {
import type { MessagePollResult, MessageSendResult } from "./message.js"; actionHasTarget,
actionRequiresTarget,
} from './message-action-spec.js';
import type { MessagePollResult, MessageSendResult } from './message.js';
import { import {
applyCrossContextDecoration, applyCrossContextDecoration,
buildCrossContextDecoration, buildCrossContextDecoration,
type CrossContextDecoration,
enforceCrossContextPolicy, enforceCrossContextPolicy,
shouldApplyCrossContextMarker, shouldApplyCrossContextMarker,
} from "./outbound-policy.js"; type CrossContextDecoration,
import { executePollAction, executeSendAction } from "./outbound-send-service.js"; } from './outbound-policy.js';
import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; import {
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js"; executePollAction,
import { loadWebMedia } from "../../web/media.js"; executeSendAction,
import { extensionForMime } from "../../media/mime.js"; } from './outbound-send-service.js';
import { parseSlackTarget } from "../../slack/targets.js"; import {
ensureOutboundSessionEntry,
resolveOutboundSessionRoute,
} from './outbound-session.js';
import {
resolveChannelTarget,
type ResolvedMessagingTarget,
} from './target-resolver.js';
export type MessageActionRunnerGateway = { export type MessageActionRunnerGateway = {
url?: string; url?: string;
@ -69,21 +81,21 @@ export type RunMessageActionParams = {
export type MessageActionRunResult = export type MessageActionRunResult =
| { | {
kind: "send"; kind: 'send';
channel: ChannelId; channel: ChannelId;
action: "send"; action: 'send';
to: string; to: string;
handledBy: "plugin" | "core"; handledBy: 'plugin' | 'core';
payload: unknown; payload: unknown;
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
sendResult?: MessageSendResult; sendResult?: MessageSendResult;
dryRun: boolean; dryRun: boolean;
} }
| { | {
kind: "broadcast"; kind: 'broadcast';
channel: ChannelId; channel: ChannelId;
action: "broadcast"; action: 'broadcast';
handledBy: "core" | "dry-run"; handledBy: 'core' | 'dry-run';
payload: { payload: {
results: Array<{ results: Array<{
channel: ChannelId; channel: ChannelId;
@ -96,30 +108,30 @@ export type MessageActionRunResult =
dryRun: boolean; dryRun: boolean;
} }
| { | {
kind: "poll"; kind: 'poll';
channel: ChannelId; channel: ChannelId;
action: "poll"; action: 'poll';
to: string; to: string;
handledBy: "plugin" | "core"; handledBy: 'plugin' | 'core';
payload: unknown; payload: unknown;
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
pollResult?: MessagePollResult; pollResult?: MessagePollResult;
dryRun: boolean; dryRun: boolean;
} }
| { | {
kind: "action"; kind: 'action';
channel: ChannelId; channel: ChannelId;
action: Exclude<ChannelMessageActionName, "send" | "poll">; action: Exclude<ChannelMessageActionName, 'send' | 'poll'>;
handledBy: "plugin" | "dry-run"; handledBy: 'plugin' | 'dry-run';
payload: unknown; payload: unknown;
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
dryRun: boolean; dryRun: boolean;
}; };
export function getToolResult( export function getToolResult(
result: MessageActionRunResult, result: MessageActionRunResult
): AgentToolResult<unknown> | undefined { ): AgentToolResult<unknown> | undefined {
return "toolResult" in result ? result.toolResult : undefined; return 'toolResult' in result ? result.toolResult : undefined;
} }
function extractToolPayload(result: AgentToolResult<unknown>): unknown { function extractToolPayload(result: AgentToolResult<unknown>): unknown {
@ -128,9 +140,9 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
? result.content.find( ? result.content.find(
(block) => (block) =>
block && block &&
typeof block === "object" && typeof block === 'object' &&
(block as { type?: unknown }).type === "text" && (block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === "string", typeof (block as { text?: unknown }).text === 'string'
) )
: undefined; : undefined;
const text = (textBlock as { text?: string } | undefined)?.text; const text = (textBlock as { text?: string } | undefined)?.text;
@ -197,13 +209,16 @@ async function maybeApplyCrossContextMarker(params: {
}); });
} }
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined { function readBooleanParam(
params: Record<string, unknown>,
key: string
): boolean | undefined {
const raw = params[key]; const raw = params[key];
if (typeof raw === "boolean") return raw; if (typeof raw === 'boolean') return raw;
if (typeof raw === "string") { if (typeof raw === 'string') {
const trimmed = raw.trim().toLowerCase(); const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") return true; if (trimmed === 'true') return true;
if (trimmed === "false") return false; if (trimmed === 'false') return false;
} }
return undefined; return undefined;
} }
@ -215,11 +230,14 @@ function resolveSlackAutoThreadId(params: {
const context = params.toolContext; const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) return undefined; if (!context?.currentThreadTs || !context.currentChannelId) return undefined;
// Only mirror auto-threading when Slack would reply in the active thread for this channel. // Only mirror auto-threading when Slack would reply in the active thread for this channel.
if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined; if (context.replyToMode !== 'all' && context.replyToMode !== 'first')
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); return undefined;
if (!parsedTarget || parsedTarget.kind !== "channel") return undefined; const parsedTarget = parseSlackTarget(params.to, { defaultKind: 'channel' });
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) return undefined; if (!parsedTarget || parsedTarget.kind !== 'channel') return undefined;
if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined; if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase())
return undefined;
if (context.replyToMode === 'first' && context.hasRepliedRef?.value)
return undefined;
return context.currentThreadTs; return context.currentThreadTs;
} }
@ -229,31 +247,35 @@ function resolveAttachmentMaxBytes(params: {
accountId?: string | null; accountId?: string | null;
}): number | undefined { }): number | undefined {
const fallback = params.cfg.agents?.defaults?.mediaMaxMb; const fallback = params.cfg.agents?.defaults?.mediaMaxMb;
if (params.channel !== "bluebubbles") { if (params.channel !== 'bluebubbles') {
return typeof fallback === "number" ? fallback * 1024 * 1024 : undefined; return typeof fallback === 'number' ? fallback * 1024 * 1024 : undefined;
} }
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : ""; const accountId =
typeof params.accountId === 'string' ? params.accountId.trim() : '';
const channelCfg = params.cfg.channels?.bluebubbles; const channelCfg = params.cfg.channels?.bluebubbles;
const channelObj = const channelObj =
channelCfg && typeof channelCfg === "object" channelCfg && typeof channelCfg === 'object'
? (channelCfg as Record<string, unknown>) ? (channelCfg as Record<string, unknown>)
: undefined; : undefined;
const channelMediaMax = const channelMediaMax =
typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; typeof channelObj?.mediaMaxMb === 'number'
? channelObj.mediaMaxMb
: undefined;
const accountsObj = const accountsObj =
channelObj?.accounts && typeof channelObj.accounts === "object" channelObj?.accounts && typeof channelObj.accounts === 'object'
? (channelObj.accounts as Record<string, unknown>) ? (channelObj.accounts as Record<string, unknown>)
: undefined; : undefined;
const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; const accountCfg =
accountId && accountsObj ? accountsObj[accountId] : undefined;
const accountMediaMax = const accountMediaMax =
accountCfg && typeof accountCfg === "object" accountCfg && typeof accountCfg === 'object'
? (accountCfg as Record<string, unknown>).mediaMaxMb ? (accountCfg as Record<string, unknown>).mediaMaxMb
: undefined; : undefined;
const limitMb = const limitMb =
(typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? (typeof accountMediaMax === 'number' ? accountMediaMax : undefined) ??
channelMediaMax ?? channelMediaMax ??
params.cfg.agents?.defaults?.mediaMaxMb; params.cfg.agents?.defaults?.mediaMaxMb;
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; return typeof limitMb === 'number' ? limitMb * 1024 * 1024 : undefined;
} }
function inferAttachmentFilename(params: { function inferAttachmentFilename(params: {
@ -263,7 +285,7 @@ function inferAttachmentFilename(params: {
const mediaHint = params.mediaHint?.trim(); const mediaHint = params.mediaHint?.trim();
if (mediaHint) { if (mediaHint) {
try { try {
if (mediaHint.startsWith("file://")) { if (mediaHint.startsWith('file://')) {
const filePath = fileURLToPath(mediaHint); const filePath = fileURLToPath(mediaHint);
const base = path.basename(filePath); const base = path.basename(filePath);
if (base) return base; if (base) return base;
@ -279,15 +301,21 @@ function inferAttachmentFilename(params: {
// fall through to content-type based default // fall through to content-type based default
} }
} }
const ext = params.contentType ? extensionForMime(params.contentType) : undefined; const ext = params.contentType
return ext ? `attachment${ext}` : "attachment"; ? extensionForMime(params.contentType)
: undefined;
return ext ? `attachment${ext}` : 'attachment';
} }
function normalizeBase64Payload(params: { base64?: string; contentType?: string }): { function normalizeBase64Payload(params: {
base64?: string;
contentType?: string;
}): {
base64?: string; base64?: string;
contentType?: string; contentType?: string;
} { } {
if (!params.base64) return { base64: params.base64, contentType: params.contentType }; if (!params.base64)
return { base64: params.base64, contentType: params.contentType };
const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim()); const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim());
if (!match) return { base64: params.base64, contentType: params.contentType }; if (!match) return { base64: params.base64, contentType: params.contentType };
const [, mime, payload] = match; const [, mime, payload] = match;
@ -305,16 +333,17 @@ async function hydrateSetGroupIconParams(params: {
action: ChannelMessageActionName; action: ChannelMessageActionName;
dryRun?: boolean; dryRun?: boolean;
}): Promise<void> { }): Promise<void> {
if (params.action !== "setGroupIcon") return; if (params.action !== 'setGroupIcon') return;
const mediaHint = readStringParam(params.args, "media", { trim: false }); const mediaHint = readStringParam(params.args, 'media', { trim: false });
const fileHint = const fileHint =
readStringParam(params.args, "path", { trim: false }) ?? readStringParam(params.args, 'path', { trim: false }) ??
readStringParam(params.args, "filePath", { trim: false }); readStringParam(params.args, 'filePath', { trim: false });
const contentTypeParam = const contentTypeParam =
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); readStringParam(params.args, 'contentType') ??
readStringParam(params.args, 'mimeType');
const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
const normalized = normalizeBase64Payload({ const normalized = normalizeBase64Payload({
base64: rawBuffer, base64: rawBuffer,
contentType: contentTypeParam ?? undefined, contentType: contentTypeParam ?? undefined,
@ -326,17 +355,21 @@ async function hydrateSetGroupIconParams(params: {
} }
} }
const filename = readStringParam(params.args, "filename"); const filename = readStringParam(params.args, 'filename');
const mediaSource = mediaHint ?? fileHint; const mediaSource = mediaHint ?? fileHint;
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { if (
!params.dryRun &&
!readStringParam(params.args, 'buffer', { trim: false }) &&
mediaSource
) {
const maxBytes = resolveAttachmentMaxBytes({ const maxBytes = resolveAttachmentMaxBytes({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
accountId: params.accountId, accountId: params.accountId,
}); });
const media = await loadWebMedia(mediaSource, maxBytes); const media = await loadWebMedia(mediaSource, maxBytes);
params.args.buffer = media.buffer.toString("base64"); params.args.buffer = media.buffer.toString('base64');
if (!contentTypeParam && media.contentType) { if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType; params.args.contentType = media.contentType;
} }
@ -362,19 +395,24 @@ async function hydrateSendAttachmentParams(params: {
action: ChannelMessageActionName; action: ChannelMessageActionName;
dryRun?: boolean; dryRun?: boolean;
}): Promise<void> { }): Promise<void> {
if (params.action !== "sendAttachment") return; if (params.action !== 'sendAttachment') return;
const mediaHint = readStringParam(params.args, "media", { trim: false }); const mediaHint = readStringParam(params.args, 'media', { trim: false });
const fileHint = const fileHint =
readStringParam(params.args, "path", { trim: false }) ?? readStringParam(params.args, 'path', { trim: false }) ??
readStringParam(params.args, "filePath", { trim: false }); readStringParam(params.args, 'filePath', { trim: false });
const contentTypeParam = const contentTypeParam =
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); readStringParam(params.args, 'contentType') ??
const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim(); readStringParam(params.args, 'mimeType');
const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim(); 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; if (!caption && message) params.args.caption = message;
const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); const rawBuffer = readStringParam(params.args, 'buffer', { trim: false });
const normalized = normalizeBase64Payload({ const normalized = normalizeBase64Payload({
base64: rawBuffer, base64: rawBuffer,
contentType: contentTypeParam ?? undefined, contentType: contentTypeParam ?? undefined,
@ -386,17 +424,21 @@ async function hydrateSendAttachmentParams(params: {
} }
} }
const filename = readStringParam(params.args, "filename"); const filename = readStringParam(params.args, 'filename');
const mediaSource = mediaHint ?? fileHint; const mediaSource = mediaHint ?? fileHint;
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { if (
!params.dryRun &&
!readStringParam(params.args, 'buffer', { trim: false }) &&
mediaSource
) {
const maxBytes = resolveAttachmentMaxBytes({ const maxBytes = resolveAttachmentMaxBytes({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
accountId: params.accountId, accountId: params.accountId,
}); });
const media = await loadWebMedia(mediaSource, maxBytes); const media = await loadWebMedia(mediaSource, maxBytes);
params.args.buffer = media.buffer.toString("base64"); params.args.buffer = media.buffer.toString('base64');
if (!contentTypeParam && media.contentType) { if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType; params.args.contentType = media.contentType;
} }
@ -416,7 +458,7 @@ async function hydrateSendAttachmentParams(params: {
function parseButtonsParam(params: Record<string, unknown>): void { function parseButtonsParam(params: Record<string, unknown>): void {
const raw = params.buttons; const raw = params.buttons;
if (typeof raw !== "string") return; if (typeof raw !== 'string') return;
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) { if (!trimmed) {
delete params.buttons; delete params.buttons;
@ -425,13 +467,13 @@ function parseButtonsParam(params: Record<string, unknown>): void {
try { try {
params.buttons = JSON.parse(trimmed) as unknown; params.buttons = JSON.parse(trimmed) as unknown;
} catch { } catch {
throw new Error("--buttons must be valid JSON"); throw new Error('--buttons must be valid JSON');
} }
} }
function parseCardParam(params: Record<string, unknown>): void { function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card; const raw = params.card;
if (typeof raw !== "string") return; if (typeof raw !== 'string') return;
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) { if (!trimmed) {
delete params.card; delete params.card;
@ -440,12 +482,15 @@ function parseCardParam(params: Record<string, unknown>): void {
try { try {
params.card = JSON.parse(trimmed) as unknown; params.card = JSON.parse(trimmed) as unknown;
} catch { } catch {
throw new Error("--card must be valid JSON"); throw new Error('--card must be valid JSON');
} }
} }
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) { async function resolveChannel(
const channelHint = readStringParam(params, "channel"); cfg: ClawdbotConfig,
params: Record<string, unknown>
) {
const channelHint = readStringParam(params, 'channel');
const selection = await resolveMessageChannelSelection({ const selection = await resolveMessageChannelSelection({
cfg, cfg,
channel: channelHint, channel: channelHint,
@ -461,7 +506,7 @@ async function resolveActionTarget(params: {
accountId?: string | null; accountId?: string | null;
}): Promise<ResolvedMessagingTarget | undefined> { }): Promise<ResolvedMessagingTarget | undefined> {
let resolvedTarget: ResolvedMessagingTarget | undefined; let resolvedTarget: ResolvedMessagingTarget | undefined;
const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : ""; const toRaw = typeof params.args.to === 'string' ? params.args.to.trim() : '';
if (toRaw) { if (toRaw) {
const resolved = await resolveChannelTarget({ const resolved = await resolveChannelTarget({
cfg: params.cfg, cfg: params.cfg,
@ -477,20 +522,27 @@ async function resolveActionTarget(params: {
} }
} }
const channelIdRaw = const channelIdRaw =
typeof params.args.channelId === "string" ? params.args.channelId.trim() : ""; typeof params.args.channelId === 'string'
? params.args.channelId.trim()
: '';
if (channelIdRaw) { if (channelIdRaw) {
const resolved = await resolveChannelTarget({ const resolved = await resolveChannelTarget({
cfg: params.cfg, cfg: params.cfg,
channel: params.channel, channel: params.channel,
input: channelIdRaw, input: channelIdRaw,
accountId: params.accountId ?? undefined, accountId: params.accountId ?? undefined,
preferredKind: "group", preferredKind: 'group',
}); });
if (resolved.ok) { if (resolved.ok) {
if (resolved.target.kind === "user") { if (resolved.target.kind === 'user') {
throw new Error(`Channel id "${channelIdRaw}" resolved to a user target.`); throw new Error(
`Channel id "${channelIdRaw}" resolved to a user target.`
);
} }
params.args.channelId = resolved.target.to.replace(/^(channel|group):/i, ""); params.args.channelId = resolved.target.to.replace(
/^(channel|group):/i,
''
);
} else { } else {
throw resolved.error; throw resolved.error;
} }
@ -510,7 +562,9 @@ type ResolvedActionContext = {
resolvedTarget?: ResolvedMessagingTarget; resolvedTarget?: ResolvedMessagingTarget;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}; };
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { function resolveGateway(
input: RunMessageActionParams
): MessageActionRunnerGateway | undefined {
if (!input.gateway) return undefined; if (!input.gateway) return undefined;
return { return {
url: input.gateway.url, url: input.gateway.url,
@ -524,24 +578,28 @@ function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGatew
async function handleBroadcastAction( async function handleBroadcastAction(
input: RunMessageActionParams, input: RunMessageActionParams,
params: Record<string, unknown>, params: Record<string, unknown>
): Promise<MessageActionRunResult> { ): Promise<MessageActionRunResult> {
throwIfAborted(input.abortSignal); throwIfAborted(input.abortSignal);
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false; const broadcastEnabled =
input.cfg.tools?.message?.broadcast?.enabled !== false;
if (!broadcastEnabled) { if (!broadcastEnabled) {
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); throw new Error(
'Broadcast is disabled. Set tools.message.broadcast.enabled to true.'
);
} }
const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? []; const rawTargets =
readStringArrayParam(params, 'targets', { required: true }) ?? [];
if (rawTargets.length === 0) { if (rawTargets.length === 0) {
throw new Error("Broadcast requires at least one target in --targets."); throw new Error('Broadcast requires at least one target in --targets.');
} }
const channelHint = readStringParam(params, "channel"); const channelHint = readStringParam(params, 'channel');
const configured = await listConfiguredMessageChannels(input.cfg); const configured = await listConfiguredMessageChannels(input.cfg);
if (configured.length === 0) { if (configured.length === 0) {
throw new Error("Broadcast requires at least one configured channel."); throw new Error('Broadcast requires at least one configured channel.');
} }
const targetChannels = const targetChannels =
channelHint && channelHint.trim().toLowerCase() !== "all" channelHint && channelHint.trim().toLowerCase() !== 'all'
? [await resolveChannel(input.cfg, { channel: channelHint })] ? [await resolveChannel(input.cfg, { channel: channelHint })]
: configured; : configured;
const results: Array<{ const results: Array<{
@ -551,7 +609,8 @@ async function handleBroadcastAction(
error?: string; error?: string;
result?: MessageSendResult; result?: MessageSendResult;
}> = []; }> = [];
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError"; const isAbortError = (err: unknown): boolean =>
err instanceof Error && err.name === 'AbortError';
for (const targetChannel of targetChannels) { for (const targetChannel of targetChannels) {
throwIfAborted(input.abortSignal); throwIfAborted(input.abortSignal);
for (const target of rawTargets) { for (const target of rawTargets) {
@ -565,7 +624,7 @@ async function handleBroadcastAction(
if (!resolved.ok) throw resolved.error; if (!resolved.ok) throw resolved.error;
const sendResult = await runMessageAction({ const sendResult = await runMessageAction({
...input, ...input,
action: "send", action: 'send',
params: { params: {
...params, ...params,
channel: targetChannel, channel: targetChannel,
@ -576,7 +635,8 @@ async function handleBroadcastAction(
channel: targetChannel, channel: targetChannel,
to: resolved.target.to, to: resolved.target.to,
ok: true, ok: true,
result: sendResult.kind === "send" ? sendResult.sendResult : undefined, result:
sendResult.kind === 'send' ? sendResult.sendResult : undefined,
}); });
} catch (err) { } catch (err) {
if (isAbortError(err)) throw err; if (isAbortError(err)) throw err;
@ -590,10 +650,10 @@ async function handleBroadcastAction(
} }
} }
return { return {
kind: "broadcast", kind: 'broadcast',
channel: (targetChannels[0] ?? "discord") as ChannelId, channel: (targetChannels[0] ?? 'discord') as ChannelId,
action: "broadcast", action: 'broadcast',
handledBy: input.dryRun ? "dry-run" : "core", handledBy: input.dryRun ? 'dry-run' : 'core',
payload: { results }, payload: { results },
dryRun: Boolean(input.dryRun), dryRun: Boolean(input.dryRun),
}; };
@ -601,13 +661,15 @@ async function handleBroadcastAction(
function throwIfAborted(abortSignal?: AbortSignal): void { function throwIfAborted(abortSignal?: AbortSignal): void {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
const err = new Error("Message send aborted"); const err = new Error('Message send aborted');
err.name = "AbortError"; err.name = 'AbortError';
throw err; throw err;
} }
} }
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { async function handleSendAction(
ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const { const {
cfg, cfg,
params, params,
@ -621,19 +683,19 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
abortSignal, abortSignal,
} = ctx; } = ctx;
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const action: ChannelMessageActionName = "send"; const action: ChannelMessageActionName = 'send';
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, 'to', { required: true });
// Support media, path, and filePath parameters for attachments // Support media, path, and filePath parameters for attachments
const mediaHint = const mediaHint =
readStringParam(params, "media", { trim: false }) ?? readStringParam(params, 'media', { trim: false }) ??
readStringParam(params, "path", { trim: false }) ?? readStringParam(params, 'path', { trim: false }) ??
readStringParam(params, "filePath", { trim: false }); readStringParam(params, 'filePath', { trim: false });
const hasCard = params.card != null && typeof params.card === "object"; const hasCard = params.card != null && typeof params.card === 'object';
let message = let message =
readStringParam(params, "message", { readStringParam(params, 'message', {
required: !mediaHint && !hasCard, required: !mediaHint && !hasCard,
allowEmpty: true, allowEmpty: true,
}) ?? ""; }) ?? '';
const parsed = parseReplyDirectives(message); const parsed = parseReplyDirectives(message);
const mergedMediaUrls: string[] = []; const mergedMediaUrls: string[] = [];
@ -668,15 +730,68 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
preferEmbeds: true, preferEmbeds: true,
}); });
const mediaUrl = readStringParam(params, "media", { trim: false }); const mediaUrl = readStringParam(params, 'media', { trim: false });
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; const gifPlayback = readBooleanParam(params, 'gifPlayback') ?? false;
const bestEffort = readBooleanParam(params, "bestEffort"); const bestEffort = readBooleanParam(params, 'bestEffort');
const replyToId = readStringParam(params, "replyTo"); // Extract location parameters
const threadId = readStringParam(params, "threadId"); 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. // Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
const slackAutoThreadId = const slackAutoThreadId =
channel === "slack" && !replyToId && !threadId channel === 'slack' && !replyToId && !threadId
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext }) ? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
: undefined; : undefined;
const outboundRoute = const outboundRoute =
@ -702,7 +817,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
}); });
} }
const mirrorMediaUrls = const mirrorMediaUrls =
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; mergedMediaUrls.length > 0
? mergedMediaUrls
: mediaUrl
? [mediaUrl]
: undefined;
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const send = await executeSendAction({ const send = await executeSendAction({
ctx: { ctx: {
@ -731,10 +850,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined, mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
gifPlayback, gifPlayback,
bestEffort: bestEffort ?? undefined, bestEffort: bestEffort ?? undefined,
location,
}); });
return { return {
kind: "send", kind: 'send',
channel, channel,
action, action,
to, to,
@ -746,24 +866,36 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
}; };
} }
async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { async function handlePollAction(
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const {
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal,
} = ctx;
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const action: ChannelMessageActionName = "poll"; const action: ChannelMessageActionName = 'poll';
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, 'to', { required: true });
const question = readStringParam(params, "pollQuestion", { const question = readStringParam(params, 'pollQuestion', {
required: true, required: true,
}); });
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; const options =
readStringArrayParam(params, 'pollOption', { required: true }) ?? [];
if (options.length < 2) { if (options.length < 2) {
throw new Error("pollOption requires at least two values"); throw new Error('pollOption requires at least two values');
} }
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; const allowMultiselect = readBooleanParam(params, 'pollMulti') ?? false;
const durationHours = readNumberParam(params, "pollDurationHours", { const durationHours = readNumberParam(params, 'pollDurationHours', {
integer: true, integer: true,
}); });
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
const base = typeof params.message === "string" ? params.message : ""; const base = typeof params.message === 'string' ? params.message : '';
await maybeApplyCrossContextMarker({ await maybeApplyCrossContextMarker({
cfg, cfg,
channel, channel,
@ -794,7 +926,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
}); });
return { return {
kind: "poll", kind: 'poll',
channel, channel,
action, action,
to, to,
@ -806,16 +938,30 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
}; };
} }
async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { async function handlePluginAction(
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; ctx: ResolvedActionContext
): Promise<MessageActionRunResult> {
const {
cfg,
params,
channel,
accountId,
dryRun,
gateway,
input,
abortSignal,
} = ctx;
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">; const action = input.action as Exclude<
ChannelMessageActionName,
'send' | 'poll' | 'broadcast'
>;
if (dryRun) { if (dryRun) {
return { return {
kind: "action", kind: 'action',
channel, channel,
action, action,
handledBy: "dry-run", handledBy: 'dry-run',
payload: { ok: true, dryRun: true, channel, action }, payload: { ok: true, dryRun: true, channel, action },
dryRun: true, dryRun: true,
}; };
@ -832,13 +978,15 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
dryRun, dryRun,
}); });
if (!handled) { if (!handled) {
throw new Error(`Message action ${action} not supported for channel ${channel}.`); throw new Error(
`Message action ${action} not supported for channel ${channel}.`
);
} }
return { return {
kind: "action", kind: 'action',
channel, channel,
action, action,
handledBy: "plugin", handledBy: 'plugin',
payload: extractToolPayload(handled), payload: extractToolPayload(handled),
toolResult: handled, toolResult: handled,
dryRun, dryRun,
@ -846,7 +994,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
} }
export async function runMessageAction( export async function runMessageAction(
input: RunMessageActionParams, input: RunMessageActionParams
): Promise<MessageActionRunResult> { ): Promise<MessageActionRunResult> {
const cfg = input.cfg; const cfg = input.cfg;
const params = { ...input.params }; const params = { ...input.params };
@ -859,14 +1007,16 @@ export async function runMessageAction(
parseCardParam(params); parseCardParam(params);
const action = input.action; const action = input.action;
if (action === "broadcast") { if (action === 'broadcast') {
return handleBroadcastAction(input, params); return handleBroadcastAction(input, params);
} }
const explicitTarget = typeof params.target === "string" ? params.target.trim() : ""; const explicitTarget =
typeof params.target === 'string' ? params.target.trim() : '';
const hasLegacyTarget = const hasLegacyTarget =
(typeof params.to === "string" && params.to.trim().length > 0) || (typeof params.to === 'string' && params.to.trim().length > 0) ||
(typeof params.channelId === "string" && params.channelId.trim().length > 0); (typeof params.channelId === 'string' &&
params.channelId.trim().length > 0);
if (explicitTarget && hasLegacyTarget) { if (explicitTarget && hasLegacyTarget) {
delete params.to; delete params.to;
delete params.channelId; delete params.channelId;
@ -883,8 +1033,9 @@ export async function runMessageAction(
} }
} }
if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) { if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) {
const legacyTo = typeof params.to === "string" ? params.to.trim() : ""; const legacyTo = typeof params.to === 'string' ? params.to.trim() : '';
const legacyChannelId = typeof params.channelId === "string" ? params.channelId.trim() : ""; const legacyChannelId =
typeof params.channelId === 'string' ? params.channelId.trim() : '';
const legacyTarget = legacyTo || legacyChannelId; const legacyTarget = legacyTo || legacyChannelId;
if (legacyTarget) { if (legacyTarget) {
params.target = legacyTarget; params.target = legacyTarget;
@ -892,9 +1043,12 @@ export async function runMessageAction(
delete params.channelId; delete params.channelId;
} }
} }
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : ""; const explicitChannel =
typeof params.channel === 'string' ? params.channel.trim() : '';
if (!explicitChannel) { if (!explicitChannel) {
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider); const inferredChannel = normalizeMessageChannel(
input.toolContext?.currentChannelProvider
);
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) { if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
params.channel = inferredChannel; params.channel = inferredChannel;
} }
@ -908,11 +1062,12 @@ export async function runMessageAction(
} }
const channel = await resolveChannel(cfg, params); const channel = await resolveChannel(cfg, params);
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; const accountId =
readStringParam(params, 'accountId') ?? input.defaultAccountId;
if (accountId) { if (accountId) {
params.accountId = accountId; params.accountId = accountId;
} }
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, 'dryRun'));
await hydrateSendAttachmentParams({ await hydrateSendAttachmentParams({
cfg, cfg,
@ -950,7 +1105,7 @@ export async function runMessageAction(
const gateway = resolveGateway(input); const gateway = resolveGateway(input);
if (action === "send") { if (action === 'send') {
return handleSendAction({ return handleSendAction({
cfg, cfg,
params, params,
@ -965,7 +1120,7 @@ export async function runMessageAction(
}); });
} }
if (action === "poll") { if (action === 'poll') {
return handlePollAction({ return handlePollAction({
cfg, cfg,
params, params,

View File

@ -1,25 +1,28 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import {
import type { ChannelId } from "../../channels/plugins/types.js"; getChannelPlugin,
import type { ClawdbotConfig } from "../../config/config.js"; normalizeChannelId,
import { loadConfig } from "../../config/config.js"; } from '../../channels/plugins/index.js';
import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import type { ChannelId } from '../../channels/plugins/types.js';
import type { PollInput } from "../../polls.js"; import type { ClawdbotConfig } from '../../config/config.js';
import { normalizePollInput } from "../../polls.js"; import { loadConfig } from '../../config/config.js';
import { callGateway, randomIdempotencyKey } from '../../gateway/call.js';
import type { PollInput } from '../../polls.js';
import { normalizePollInput } from '../../polls.js';
import { import {
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_NAMES,
type GatewayClientMode, type GatewayClientMode,
type GatewayClientName, type GatewayClientName,
} from "../../utils/message-channel.js"; } from '../../utils/message-channel.js';
import { resolveMessageChannelSelection } from "./channel-selection.js"; import { resolveMessageChannelSelection } from './channel-selection.js';
import { import {
deliverOutboundPayloads, deliverOutboundPayloads,
type OutboundDeliveryResult, type OutboundDeliveryResult,
type OutboundSendDeps, type OutboundSendDeps,
} from "./deliver.js"; } from './deliver.js';
import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { normalizeReplyPayloadsForDelivery } from './payloads.js';
import type { OutboundChannel } from "./targets.js"; import type { OutboundChannel } from './targets.js';
import { resolveOutboundTarget } from "./targets.js"; import { resolveOutboundTarget } from './targets.js';
export type MessageGatewayOptions = { export type MessageGatewayOptions = {
url?: string; url?: string;
@ -40,6 +43,13 @@ type MessageSendParams = {
accountId?: string; accountId?: string;
dryRun?: boolean; dryRun?: boolean;
bestEffort?: boolean; bestEffort?: boolean;
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
};
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
gateway?: MessageGatewayOptions; gateway?: MessageGatewayOptions;
@ -56,7 +66,7 @@ type MessageSendParams = {
export type MessageSendResult = { export type MessageSendResult = {
channel: string; channel: string;
to: string; to: string;
via: "direct" | "gateway"; via: 'direct' | 'gateway';
mediaUrl: string | null; mediaUrl: string | null;
mediaUrls?: string[]; mediaUrls?: string[];
result?: OutboundDeliveryResult | { messageId: string }; result?: OutboundDeliveryResult | { messageId: string };
@ -83,7 +93,7 @@ export type MessagePollResult = {
options: string[]; options: string[];
maxSelections: number; maxSelections: number;
durationHours: number | null; durationHours: number | null;
via: "gateway"; via: 'gateway';
result?: { result?: {
messageId: string; messageId: string;
toJid?: string; toJid?: string;
@ -99,7 +109,7 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
url: opts?.url, url: opts?.url,
token: opts?.token, token: opts?.token,
timeoutMs: timeoutMs:
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) typeof opts?.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs)) ? Math.max(1, Math.floor(opts.timeoutMs))
: 10_000, : 10_000,
clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
@ -108,7 +118,9 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
}; };
} }
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> { export async function sendMessage(
params: MessageSendParams
): Promise<MessageSendResult> {
const cfg = params.cfg ?? loadConfig(); const cfg = params.cfg ?? loadConfig();
const channel = params.channel?.trim() const channel = params.channel?.trim()
? normalizeChannelId(params.channel) ? normalizeChannelId(params.channel)
@ -120,7 +132,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
if (!plugin) { if (!plugin) {
throw new Error(`Unknown channel: ${channel}`); throw new Error(`Unknown channel: ${channel}`);
} }
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct"; const deliveryMode = plugin.outbound?.deliveryMode ?? 'direct';
const normalizedPayloads = normalizeReplyPayloadsForDelivery([ const normalizedPayloads = normalizeReplyPayloadsForDelivery([
{ {
text: params.content, text: params.content,
@ -131,9 +143,10 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
const mirrorText = normalizedPayloads const mirrorText = normalizedPayloads
.map((payload) => payload.text) .map((payload) => payload.text)
.filter(Boolean) .filter(Boolean)
.join("\n"); .join('\n');
const mirrorMediaUrls = normalizedPayloads.flatMap( const mirrorMediaUrls = normalizedPayloads.flatMap(
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), (payload) =>
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : [])
); );
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
@ -141,21 +154,21 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
return { return {
channel, channel,
to: params.to, to: params.to,
via: deliveryMode === "gateway" ? "gateway" : "direct", via: deliveryMode === 'gateway' ? 'gateway' : 'direct',
mediaUrl: primaryMediaUrl, mediaUrl: primaryMediaUrl,
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
dryRun: true, dryRun: true,
}; };
} }
if (deliveryMode !== "gateway") { if (deliveryMode !== 'gateway') {
const outboundChannel = channel as Exclude<OutboundChannel, "none">; const outboundChannel = channel as Exclude<OutboundChannel, 'none'>;
const resolvedTarget = resolveOutboundTarget({ const resolvedTarget = resolveOutboundTarget({
channel: outboundChannel, channel: outboundChannel,
to: params.to, to: params.to,
cfg, cfg,
accountId: params.accountId, accountId: params.accountId,
mode: "explicit", mode: 'explicit',
}); });
if (!resolvedTarget.ok) throw resolvedTarget.error; if (!resolvedTarget.ok) throw resolvedTarget.error;
@ -169,6 +182,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
deps: params.deps, deps: params.deps,
bestEffort: params.bestEffort, bestEffort: params.bestEffort,
abortSignal: params.abortSignal, abortSignal: params.abortSignal,
location: params.location,
mirror: params.mirror mirror: params.mirror
? { ? {
...params.mirror, ...params.mirror,
@ -181,7 +195,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
return { return {
channel, channel,
to: params.to, to: params.to,
via: "direct", via: 'direct',
mediaUrl: primaryMediaUrl, mediaUrl: primaryMediaUrl,
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
result: results.at(-1), result: results.at(-1),
@ -192,7 +206,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
const result = await callGateway<{ messageId: string }>({ const result = await callGateway<{ messageId: string }>({
url: gateway.url, url: gateway.url,
token: gateway.token, token: gateway.token,
method: "send", method: 'send',
params: { params: {
to: params.to, to: params.to,
message: params.content, message: params.content,
@ -213,14 +227,16 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
return { return {
channel, channel,
to: params.to, to: params.to,
via: "gateway", via: 'gateway',
mediaUrl: primaryMediaUrl, mediaUrl: primaryMediaUrl,
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
result, result,
}; };
} }
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> { export async function sendPoll(
params: MessagePollParams
): Promise<MessagePollResult> {
const cfg = params.cfg ?? loadConfig(); const cfg = params.cfg ?? loadConfig();
const channel = params.channel?.trim() const channel = params.channel?.trim()
? normalizeChannelId(params.channel) ? normalizeChannelId(params.channel)
@ -252,7 +268,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
options: normalized.options, options: normalized.options,
maxSelections: normalized.maxSelections, maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours ?? null, durationHours: normalized.durationHours ?? null,
via: "gateway", via: 'gateway',
dryRun: true, dryRun: true,
}; };
} }
@ -267,7 +283,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
}>({ }>({
url: gateway.url, url: gateway.url,
token: gateway.token, token: gateway.token,
method: "poll", method: 'poll',
params: { params: {
to: params.to, to: params.to,
question: normalized.question, question: normalized.question,
@ -290,7 +306,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
options: normalized.options, options: normalized.options,
maxSelections: normalized.maxSelections, maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours ?? null, durationHours: normalized.durationHours ?? null,
via: "gateway", via: 'gateway',
result, result,
}; };
} }

View File

@ -1,12 +1,18 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from '@mariozechner/pi-agent-core';
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js';
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type {
import type { ClawdbotConfig } from "../../config/config.js"; ChannelId,
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; ChannelThreadingToolContext,
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; } from '../../channels/plugins/types.js';
import type { OutboundSendDeps } from "./deliver.js"; import type { ClawdbotConfig } from '../../config/config.js';
import type { MessagePollResult, MessageSendResult } from "./message.js"; import { appendAssistantMessageToSessionTranscript } from '../../config/sessions.js';
import { sendMessage, sendPoll } from "./message.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 = { export type OutboundGatewayContext = {
url?: string; url?: string;
@ -41,9 +47,9 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
? result.content.find( ? result.content.find(
(block) => (block) =>
block && block &&
typeof block === "object" && typeof block === 'object' &&
(block as { type?: unknown }).type === "text" && (block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === "string", typeof (block as { text?: unknown }).text === 'string'
) )
: undefined; : undefined;
const text = (textBlock as { text?: string } | undefined)?.text; const text = (textBlock as { text?: string } | undefined)?.text;
@ -59,8 +65,8 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
function throwIfAborted(abortSignal?: AbortSignal): void { function throwIfAborted(abortSignal?: AbortSignal): void {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
const err = new Error("Message send aborted"); const err = new Error('Message send aborted');
err.name = "AbortError"; err.name = 'AbortError';
throw err; throw err;
} }
} }
@ -73,8 +79,15 @@ export async function executeSendAction(params: {
mediaUrls?: string[]; mediaUrls?: string[];
gifPlayback?: boolean; gifPlayback?: boolean;
bestEffort?: boolean; bestEffort?: boolean;
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
};
}): Promise<{ }): Promise<{
handledBy: "plugin" | "core"; handledBy: 'plugin' | 'core';
payload: unknown; payload: unknown;
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
sendResult?: MessageSendResult; sendResult?: MessageSendResult;
@ -83,7 +96,7 @@ export async function executeSendAction(params: {
if (!params.ctx.dryRun) { if (!params.ctx.dryRun) {
const handled = await dispatchChannelMessageAction({ const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel, channel: params.ctx.channel,
action: "send", action: 'send',
cfg: params.ctx.cfg, cfg: params.ctx.cfg,
params: params.ctx.params, params: params.ctx.params,
accountId: params.ctx.accountId ?? undefined, accountId: params.ctx.accountId ?? undefined,
@ -106,7 +119,7 @@ export async function executeSendAction(params: {
}); });
} }
return { return {
handledBy: "plugin", handledBy: 'plugin',
payload: extractToolPayload(handled), payload: extractToolPayload(handled),
toolResult: handled, toolResult: handled,
}; };
@ -125,6 +138,7 @@ export async function executeSendAction(params: {
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
dryRun: params.ctx.dryRun, dryRun: params.ctx.dryRun,
bestEffort: params.bestEffort ?? undefined, bestEffort: params.bestEffort ?? undefined,
location: params.location,
deps: params.ctx.deps, deps: params.ctx.deps,
gateway: params.ctx.gateway, gateway: params.ctx.gateway,
mirror: params.ctx.mirror, mirror: params.ctx.mirror,
@ -132,7 +146,7 @@ export async function executeSendAction(params: {
}); });
return { return {
handledBy: "core", handledBy: 'core',
payload: result, payload: result,
sendResult: result, sendResult: result,
}; };
@ -146,7 +160,7 @@ export async function executePollAction(params: {
maxSelections: number; maxSelections: number;
durationHours?: number; durationHours?: number;
}): Promise<{ }): Promise<{
handledBy: "plugin" | "core"; handledBy: 'plugin' | 'core';
payload: unknown; payload: unknown;
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
pollResult?: MessagePollResult; pollResult?: MessagePollResult;
@ -154,7 +168,7 @@ export async function executePollAction(params: {
if (!params.ctx.dryRun) { if (!params.ctx.dryRun) {
const handled = await dispatchChannelMessageAction({ const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel, channel: params.ctx.channel,
action: "poll", action: 'poll',
cfg: params.ctx.cfg, cfg: params.ctx.cfg,
params: params.ctx.params, params: params.ctx.params,
accountId: params.ctx.accountId ?? undefined, accountId: params.ctx.accountId ?? undefined,
@ -164,7 +178,7 @@ export async function executePollAction(params: {
}); });
if (handled) { if (handled) {
return { return {
handledBy: "plugin", handledBy: 'plugin',
payload: extractToolPayload(handled), payload: extractToolPayload(handled),
toolResult: handled, toolResult: handled,
}; };
@ -184,7 +198,7 @@ export async function executePollAction(params: {
}); });
return { return {
handledBy: "core", handledBy: 'core',
payload: result, payload: result,
pollResult: result, pollResult: result,
}; };

View File

@ -1,6 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from '../cli/command-format.js';
import type { PollInput } from "../polls.js"; import type { PollInput } from '../polls.js';
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from '../routing/session-key.js';
export type ActiveWebSendOptions = { export type ActiveWebSendOptions = {
gifPlayback?: boolean; gifPlayback?: boolean;
@ -14,6 +14,13 @@ export type ActiveWebListener = {
mediaBuffer?: Buffer, mediaBuffer?: Buffer,
mediaType?: string, mediaType?: string,
options?: ActiveWebSendOptions, options?: ActiveWebSendOptions,
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
}
) => Promise<{ messageId: string }>; ) => Promise<{ messageId: string }>;
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
sendReaction: ( sendReaction: (
@ -21,7 +28,7 @@ export type ActiveWebListener = {
messageId: string, messageId: string,
emoji: string, emoji: string,
fromMe: boolean, fromMe: boolean,
participant?: string, participant?: string
) => Promise<void>; ) => Promise<void>;
sendComposingTo: (to: string) => Promise<void>; sendComposingTo: (to: string) => Promise<void>;
close?: () => Promise<void>; close?: () => Promise<void>;
@ -32,7 +39,7 @@ let _currentListener: ActiveWebListener | null = null;
const listeners = new Map<string, ActiveWebListener>(); const listeners = new Map<string, ActiveWebListener>();
export function resolveWebAccountId(accountId?: string | null): string { export function resolveWebAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID; return (accountId ?? '').trim() || DEFAULT_ACCOUNT_ID;
} }
export function requireActiveWebListener(accountId?: string | null): { export function requireActiveWebListener(accountId?: string | null): {
@ -43,7 +50,7 @@ export function requireActiveWebListener(accountId?: string | null): {
const listener = listeners.get(id) ?? null; const listener = listeners.get(id) ?? null;
if (!listener) { if (!listener) {
throw new Error( throw new Error(
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`clawdbot channels login --channel whatsapp --account ${id}`)}.`, `No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`clawdbot channels login --channel whatsapp --account ${id}`)}.`
); );
} }
return { accountId: id, listener }; return { accountId: id, listener };
@ -52,14 +59,14 @@ export function requireActiveWebListener(accountId?: string | null): {
export function setActiveWebListener(listener: ActiveWebListener | null): void; export function setActiveWebListener(listener: ActiveWebListener | null): void;
export function setActiveWebListener( export function setActiveWebListener(
accountId: string | null | undefined, accountId: string | null | undefined,
listener: ActiveWebListener | null, listener: ActiveWebListener | null
): void; ): void;
export function setActiveWebListener( export function setActiveWebListener(
accountIdOrListener: string | ActiveWebListener | null | undefined, accountIdOrListener: string | ActiveWebListener | null | undefined,
maybeListener?: ActiveWebListener | null, maybeListener?: ActiveWebListener | null
): void { ): void {
const { accountId, listener } = const { accountId, listener } =
typeof accountIdOrListener === "string" typeof accountIdOrListener === 'string'
? { accountId: accountIdOrListener, listener: maybeListener ?? null } ? { accountId: accountIdOrListener, listener: maybeListener ?? null }
: { : {
accountId: DEFAULT_ACCOUNT_ID, accountId: DEFAULT_ACCOUNT_ID,
@ -77,7 +84,9 @@ export function setActiveWebListener(
} }
} }
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null { export function getActiveWebListener(
accountId?: string | null
): ActiveWebListener | null {
const id = resolveWebAccountId(accountId); const id = resolveWebAccountId(accountId);
return listeners.get(id) ?? null; return listeners.get(id) ?? null;
} }

View File

@ -1,12 +1,15 @@
import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; import type { AnyMessageContent, WAPresence } from '@whiskeysockets/baileys';
import { recordChannelActivity } from "../../infra/channel-activity.js"; import { recordChannelActivity } from '../../infra/channel-activity.js';
import { toWhatsappJid } from "../../utils.js"; import { toWhatsappJid } from '../../utils.js';
import type { ActiveWebSendOptions } from "../active-listener.js"; import type { ActiveWebSendOptions } from '../active-listener.js';
export function createWebSendApi(params: { export function createWebSendApi(params: {
sock: { sock: {
sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>; sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>;
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>; sendPresenceUpdate: (
presence: WAPresence,
jid?: string
) => Promise<unknown>;
}; };
defaultAccountId: string; defaultAccountId: string;
}) { }) {
@ -17,19 +20,38 @@ export function createWebSendApi(params: {
mediaBuffer?: Buffer, mediaBuffer?: Buffer,
mediaType?: string, mediaType?: string,
sendOptions?: ActiveWebSendOptions, sendOptions?: ActiveWebSendOptions,
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
}
): Promise<{ messageId: string }> => { ): Promise<{ messageId: string }> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);
let payload: AnyMessageContent; let payload: AnyMessageContent;
if (mediaBuffer && mediaType) {
if (mediaType.startsWith("image/")) { // Location messages take precedence
if (location) {
payload = {
location: {
degreesLatitude: location.latitude,
degreesLongitude: location.longitude,
name: location.name,
address: location.address,
accuracyInMeters: location.accuracy,
},
};
} else if (mediaBuffer && mediaType) {
if (mediaType.startsWith('image/')) {
payload = { payload = {
image: mediaBuffer, image: mediaBuffer,
caption: text || undefined, caption: text || undefined,
mimetype: mediaType, mimetype: mediaType,
}; };
} else if (mediaType.startsWith("audio/")) { } else if (mediaType.startsWith('audio/')) {
payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType }; payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
} else if (mediaType.startsWith("video/")) { } else if (mediaType.startsWith('video/')) {
const gifPlayback = sendOptions?.gifPlayback; const gifPlayback = sendOptions?.gifPlayback;
payload = { payload = {
video: mediaBuffer, video: mediaBuffer,
@ -40,7 +62,7 @@ export function createWebSendApi(params: {
} else { } else {
payload = { payload = {
document: mediaBuffer, document: mediaBuffer,
fileName: "file", fileName: 'file',
caption: text || undefined, caption: text || undefined,
mimetype: mediaType, mimetype: mediaType,
}; };
@ -51,19 +73,19 @@ export function createWebSendApi(params: {
const result = await params.sock.sendMessage(jid, payload); const result = await params.sock.sendMessage(jid, payload);
const accountId = sendOptions?.accountId ?? params.defaultAccountId; const accountId = sendOptions?.accountId ?? params.defaultAccountId;
recordChannelActivity({ recordChannelActivity({
channel: "whatsapp", channel: 'whatsapp',
accountId, accountId,
direction: "outbound", direction: 'outbound',
}); });
const messageId = const messageId =
typeof result === "object" && result && "key" in result typeof result === 'object' && result && 'key' in result
? String((result as { key?: { id?: string } }).key?.id ?? "unknown") ? String((result as { key?: { id?: string } }).key?.id ?? 'unknown')
: "unknown"; : 'unknown';
return { messageId }; return { messageId };
}, },
sendPoll: async ( sendPoll: async (
to: string, to: string,
poll: { question: string; options: string[]; maxSelections?: number }, poll: { question: string; options: string[]; maxSelections?: number }
): Promise<{ messageId: string }> => { ): Promise<{ messageId: string }> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);
const result = await params.sock.sendMessage(jid, { const result = await params.sock.sendMessage(jid, {
@ -74,14 +96,14 @@ export function createWebSendApi(params: {
}, },
} as AnyMessageContent); } as AnyMessageContent);
recordChannelActivity({ recordChannelActivity({
channel: "whatsapp", channel: 'whatsapp',
accountId: params.defaultAccountId, accountId: params.defaultAccountId,
direction: "outbound", direction: 'outbound',
}); });
const messageId = const messageId =
typeof result === "object" && result && "key" in result typeof result === 'object' && result && 'key' in result
? String((result as { key?: { id?: string } }).key?.id ?? "unknown") ? String((result as { key?: { id?: string } }).key?.id ?? 'unknown')
: "unknown"; : 'unknown';
return { messageId }; return { messageId };
}, },
sendReaction: async ( sendReaction: async (
@ -89,7 +111,7 @@ export function createWebSendApi(params: {
messageId: string, messageId: string,
emoji: string, emoji: string,
fromMe: boolean, fromMe: boolean,
participant?: string, participant?: string
): Promise<void> => { ): Promise<void> => {
const jid = toWhatsappJid(chatJid); const jid = toWhatsappJid(chatJid);
await params.sock.sendMessage(jid, { await params.sock.sendMessage(jid, {
@ -106,7 +128,7 @@ export function createWebSendApi(params: {
}, },
sendComposingTo: async (to: string): Promise<void> => { sendComposingTo: async (to: string): Promise<void> => {
const jid = toWhatsappJid(to); const jid = toWhatsappJid(to);
await params.sock.sendPresenceUpdate("composing", jid); await params.sock.sendPresenceUpdate('composing', jid);
}, },
} as const; } as const;
} }

View File

@ -1,19 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from '../logging.js';
import { setActiveWebListener } from "./active-listener.js"; import { setActiveWebListener } from './active-listener.js';
const loadWebMediaMock = vi.fn(); const loadWebMediaMock = vi.fn();
vi.mock("./media.js", () => ({ vi.mock('./media.js', () => ({
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
})); }));
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./outbound.js"; import {
sendMessageWhatsApp,
sendPollWhatsApp,
sendReactionWhatsApp,
} from './outbound.js';
describe("web outbound", () => { describe('web outbound', () => {
const sendComposingTo = vi.fn(async () => {}); const sendComposingTo = vi.fn(async () => {});
const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); const sendMessage = vi.fn(async () => ({ messageId: 'msg123' }));
const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); const sendPoll = vi.fn(async () => ({ messageId: 'poll123' }));
const sendReaction = vi.fn(async () => {}); const sendReaction = vi.fn(async () => {});
beforeEach(() => { beforeEach(() => {
@ -32,137 +36,197 @@ describe("web outbound", () => {
setActiveWebListener(null); setActiveWebListener(null);
}); });
it("sends message via active listener", async () => { it('sends message via active listener', async () => {
const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false }); const result = await sendMessageWhatsApp('+1555', 'hi', { verbose: false });
expect(result).toEqual({ expect(result).toEqual({
messageId: "msg123", messageId: 'msg123',
toJid: "1555@s.whatsapp.net", toJid: '1555@s.whatsapp.net',
}); });
expect(sendComposingTo).toHaveBeenCalledWith("+1555"); expect(sendComposingTo).toHaveBeenCalledWith('+1555');
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined); expect(sendMessage).toHaveBeenCalledWith(
'+1555',
'hi',
undefined,
undefined,
undefined,
undefined
);
}); });
it("throws a helpful error when no active listener exists", async () => { it('throws a helpful error when no active listener exists', async () => {
setActiveWebListener(null); setActiveWebListener(null);
await expect( await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
).rejects.toThrow(/No active WhatsApp Web listener/); ).rejects.toThrow(/No active WhatsApp Web listener/);
await expect( await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
).rejects.toThrow(/channels login/); ).rejects.toThrow(/channels login/);
await expect( await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' })
).rejects.toThrow(/account: work/); ).rejects.toThrow(/account: work/);
}); });
it("maps audio to PTT with opus mime when ogg", async () => { it('maps audio to PTT with opus mime when ogg', async () => {
const buf = Buffer.from("audio"); const buf = Buffer.from('audio');
loadWebMediaMock.mockResolvedValueOnce({ loadWebMediaMock.mockResolvedValueOnce({
buffer: buf, buffer: buf,
contentType: "audio/ogg", contentType: 'audio/ogg',
kind: "audio", kind: 'audio',
}); });
await sendMessageWhatsApp("+1555", "voice note", { await sendMessageWhatsApp('+1555', 'voice note', {
verbose: false, verbose: false,
mediaUrl: "/tmp/voice.ogg", mediaUrl: '/tmp/voice.ogg',
}); });
expect(sendMessage).toHaveBeenLastCalledWith( expect(sendMessage).toHaveBeenLastCalledWith(
"+1555", '+1555',
"voice note", 'voice note',
buf, buf,
"audio/ogg; codecs=opus", 'audio/ogg; codecs=opus',
undefined,
undefined
); );
}); });
it("maps video with caption", async () => { it('maps video with caption', async () => {
const buf = Buffer.from("video"); const buf = Buffer.from('video');
loadWebMediaMock.mockResolvedValueOnce({ loadWebMediaMock.mockResolvedValueOnce({
buffer: buf, buffer: buf,
contentType: "video/mp4", contentType: 'video/mp4',
kind: "video", kind: 'video',
}); });
await sendMessageWhatsApp("+1555", "clip", { await sendMessageWhatsApp('+1555', 'clip', {
verbose: false, verbose: false,
mediaUrl: "/tmp/video.mp4", mediaUrl: '/tmp/video.mp4',
}); });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "clip", buf, "video/mp4"); expect(sendMessage).toHaveBeenLastCalledWith(
'+1555',
'clip',
buf,
'video/mp4',
undefined,
undefined
);
}); });
it("marks gif playback for video when requested", async () => { it('marks gif playback for video when requested', async () => {
const buf = Buffer.from("gifvid"); const buf = Buffer.from('gifvid');
loadWebMediaMock.mockResolvedValueOnce({ loadWebMediaMock.mockResolvedValueOnce({
buffer: buf, buffer: buf,
contentType: "video/mp4", contentType: 'video/mp4',
kind: "video", kind: 'video',
}); });
await sendMessageWhatsApp("+1555", "gif", { await sendMessageWhatsApp('+1555', 'gif', {
verbose: false, verbose: false,
mediaUrl: "/tmp/anim.mp4", mediaUrl: '/tmp/anim.mp4',
gifPlayback: true, gifPlayback: true,
}); });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "gif", buf, "video/mp4", { expect(sendMessage).toHaveBeenLastCalledWith(
'+1555',
'gif',
buf,
'video/mp4',
{
gifPlayback: true, gifPlayback: true,
}); accountId: undefined,
},
undefined
);
}); });
it("maps image with caption", async () => { it('maps image with caption', async () => {
const buf = Buffer.from("img"); const buf = Buffer.from('img');
loadWebMediaMock.mockResolvedValueOnce({ loadWebMediaMock.mockResolvedValueOnce({
buffer: buf, buffer: buf,
contentType: "image/jpeg", contentType: 'image/jpeg',
kind: "image", kind: 'image',
}); });
await sendMessageWhatsApp("+1555", "pic", { await sendMessageWhatsApp('+1555', 'pic', {
verbose: false, verbose: false,
mediaUrl: "/tmp/pic.jpg", mediaUrl: '/tmp/pic.jpg',
}); });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg"); expect(sendMessage).toHaveBeenLastCalledWith(
'+1555',
'pic',
buf,
'image/jpeg',
undefined,
undefined
);
}); });
it("maps other kinds to document with filename", async () => { it('maps other kinds to document with filename', async () => {
const buf = Buffer.from("pdf"); const buf = Buffer.from('pdf');
loadWebMediaMock.mockResolvedValueOnce({ loadWebMediaMock.mockResolvedValueOnce({
buffer: buf, buffer: buf,
contentType: "application/pdf", contentType: 'application/pdf',
kind: "document", kind: 'document',
fileName: "file.pdf", fileName: 'file.pdf',
}); });
await sendMessageWhatsApp("+1555", "doc", { await sendMessageWhatsApp('+1555', 'doc', {
verbose: false, verbose: false,
mediaUrl: "/tmp/file.pdf", mediaUrl: '/tmp/file.pdf',
}); });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf"); expect(sendMessage).toHaveBeenLastCalledWith(
'+1555',
'doc',
buf,
'application/pdf',
undefined,
undefined
);
}); });
it("sends polls via active listener", async () => { it('sends location via active listener', async () => {
const location = {
latitude: 33.538368,
longitude: -7.760028,
name: 'Home',
address: 'Some Address',
accuracy: 12,
};
await sendMessageWhatsApp('+1555', 'pin', {
verbose: false,
location,
});
expect(sendMessage).toHaveBeenLastCalledWith(
'+1555',
'pin',
undefined,
undefined,
undefined,
location
);
});
it('sends polls via active listener', async () => {
const result = await sendPollWhatsApp( const result = await sendPollWhatsApp(
"+1555", '+1555',
{ question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 }, { question: 'Lunch?', options: ['Pizza', 'Sushi'], maxSelections: 2 },
{ verbose: false }, { verbose: false }
); );
expect(result).toEqual({ expect(result).toEqual({
messageId: "poll123", messageId: 'poll123',
toJid: "1555@s.whatsapp.net", toJid: '1555@s.whatsapp.net',
}); });
expect(sendPoll).toHaveBeenCalledWith("+1555", { expect(sendPoll).toHaveBeenCalledWith('+1555', {
question: "Lunch?", question: 'Lunch?',
options: ["Pizza", "Sushi"], options: ['Pizza', 'Sushi'],
maxSelections: 2, maxSelections: 2,
durationHours: undefined, durationHours: undefined,
}); });
}); });
it("sends reactions via active listener", async () => { it('sends reactions via active listener', async () => {
await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { await sendReactionWhatsApp('1555@s.whatsapp.net', 'msg123', '✅', {
verbose: false, verbose: false,
fromMe: false, fromMe: false,
}); });
expect(sendReaction).toHaveBeenCalledWith( expect(sendReaction).toHaveBeenCalledWith(
"1555@s.whatsapp.net", '1555@s.whatsapp.net',
"msg123", 'msg123',
"✅", '✅',
false, false,
undefined, undefined
); );
}); });
}); });

View File

@ -1,16 +1,21 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from 'node:crypto';
import { getChildLogger } from "../logging/logger.js"; import { loadConfig } from '../config/config.js';
import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveMarkdownTableMode } from '../config/markdown-tables.js';
import { normalizePollInput, type PollInput } from "../polls.js"; import { getChildLogger } from '../logging/logger.js';
import { toWhatsappJid } from "../utils.js"; import { createSubsystemLogger } from '../logging/subsystem.js';
import { loadConfig } from "../config/config.js"; import { convertMarkdownTables } from '../markdown/tables.js';
import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { normalizePollInput, type PollInput } from '../polls.js';
import { convertMarkdownTables } from "../markdown/tables.js"; import { toWhatsappJid } from '../utils.js';
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import {
import { loadWebMedia } from "./media.js"; type ActiveWebSendOptions,
requireActiveWebListener,
} from './active-listener.js';
import { loadWebMedia } from './media.js';
const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound"); const outboundLog = createSubsystemLogger('gateway/channels/whatsapp').child(
'outbound'
);
export async function sendMessageWhatsApp( export async function sendMessageWhatsApp(
to: string, to: string,
@ -20,23 +25,29 @@ export async function sendMessageWhatsApp(
mediaUrl?: string; mediaUrl?: string;
gifPlayback?: boolean; gifPlayback?: boolean;
accountId?: string; accountId?: string;
}, location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
accuracy?: number;
};
}
): Promise<{ messageId: string; toJid: string }> { ): Promise<{ messageId: string; toJid: string }> {
let text = body; let text = body;
const correlationId = randomUUID(); const correlationId = randomUUID();
const startedAt = Date.now(); const startedAt = Date.now();
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( const { listener: active, accountId: resolvedAccountId } =
options.accountId, requireActiveWebListener(options.accountId);
);
const cfg = loadConfig(); const cfg = loadConfig();
const tableMode = resolveMarkdownTableMode({ const tableMode = resolveMarkdownTableMode({
cfg, cfg,
channel: "whatsapp", channel: 'whatsapp',
accountId: resolvedAccountId ?? options.accountId, accountId: resolvedAccountId ?? options.accountId,
}); });
text = convertMarkdownTables(text ?? "", tableMode); text = convertMarkdownTables(text ?? '', tableMode);
const logger = getChildLogger({ const logger = getChildLogger({
module: "web-outbound", module: 'web-outbound',
correlationId, correlationId,
to, to,
}); });
@ -49,22 +60,28 @@ export async function sendMessageWhatsApp(
const caption = text || undefined; const caption = text || undefined;
mediaBuffer = media.buffer; mediaBuffer = media.buffer;
mediaType = media.contentType; mediaType = media.contentType;
if (media.kind === "audio") { if (media.kind === 'audio') {
// WhatsApp expects explicit opus codec for PTT voice notes. // WhatsApp expects explicit opus codec for PTT voice notes.
mediaType = mediaType =
media.contentType === "audio/ogg" media.contentType === 'audio/ogg'
? "audio/ogg; codecs=opus" ? 'audio/ogg; codecs=opus'
: (media.contentType ?? "application/octet-stream"); : (media.contentType ?? 'application/octet-stream');
} else if (media.kind === "video") { } else if (media.kind === 'video') {
text = caption ?? ""; text = caption ?? '';
} else if (media.kind === "image") { } else if (media.kind === 'image') {
text = caption ?? ""; text = caption ?? '';
} else { } else {
text = caption ?? ""; text = caption ?? '';
} }
} }
outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); const hasLocation = Boolean(options.location);
logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); outboundLog.info(
`Sending message -> ${jid}${options.mediaUrl ? ' (media)' : ''}${hasLocation ? ' (location)' : ''}`
);
logger.info(
{ jid, hasMedia: Boolean(options.mediaUrl), hasLocation },
'sending message'
);
await active.sendComposingTo(to); await active.sendComposingTo(to);
const hasExplicitAccountId = Boolean(options.accountId?.trim()); const hasExplicitAccountId = Boolean(options.accountId?.trim());
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
@ -76,19 +93,34 @@ export async function sendMessageWhatsApp(
} }
: undefined; : undefined;
const result = sendOptions const result = sendOptions
? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions) ? await active.sendMessage(
: await active.sendMessage(to, text, mediaBuffer, mediaType); to,
const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; text,
mediaBuffer,
mediaType,
sendOptions,
options.location
)
: await active.sendMessage(
to,
text,
mediaBuffer,
mediaType,
undefined,
options.location
);
const messageId =
(result as { messageId?: string })?.messageId ?? 'unknown';
const durationMs = Date.now() - startedAt; const durationMs = Date.now() - startedAt;
outboundLog.info( outboundLog.info(
`Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, `Sent message ${messageId} -> ${jid}${options.mediaUrl ? ' (media)' : ''}${hasLocation ? ' (location)' : ''} (${durationMs}ms)`
); );
logger.info({ jid, messageId }, "sent message"); logger.info({ jid, messageId, hasLocation }, 'sent message');
return { messageId, toJid: jid }; return { messageId, toJid: jid };
} catch (err) { } catch (err) {
logger.error( logger.error(
{ err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, { err: String(err), to, hasMedia: Boolean(options.mediaUrl) },
"failed to send via web session", 'failed to send via web session'
); );
throw err; throw err;
} }
@ -103,12 +135,12 @@ export async function sendReactionWhatsApp(
fromMe?: boolean; fromMe?: boolean;
participant?: string; participant?: string;
accountId?: string; accountId?: string;
}, }
): Promise<void> { ): Promise<void> {
const correlationId = randomUUID(); const correlationId = randomUUID();
const { listener: active } = requireActiveWebListener(options.accountId); const { listener: active } = requireActiveWebListener(options.accountId);
const logger = getChildLogger({ const logger = getChildLogger({
module: "web-outbound", module: 'web-outbound',
correlationId, correlationId,
chatJid, chatJid,
messageId, messageId,
@ -116,20 +148,20 @@ export async function sendReactionWhatsApp(
try { try {
const jid = toWhatsappJid(chatJid); const jid = toWhatsappJid(chatJid);
outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`);
logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); logger.info({ chatJid: jid, messageId, emoji }, 'sending reaction');
await active.sendReaction( await active.sendReaction(
chatJid, chatJid,
messageId, messageId,
emoji, emoji,
options.fromMe ?? false, options.fromMe ?? false,
options.participant, options.participant
); );
outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`);
logger.info({ chatJid: jid, messageId, emoji }, "sent reaction"); logger.info({ chatJid: jid, messageId, emoji }, 'sent reaction');
} catch (err) { } catch (err) {
logger.error( logger.error(
{ err: String(err), chatJid, messageId, emoji }, { err: String(err), chatJid, messageId, emoji },
"failed to send reaction via web session", 'failed to send reaction via web session'
); );
throw err; throw err;
} }
@ -138,13 +170,13 @@ export async function sendReactionWhatsApp(
export async function sendPollWhatsApp( export async function sendPollWhatsApp(
to: string, to: string,
poll: PollInput, poll: PollInput,
options: { verbose: boolean; accountId?: string }, options: { verbose: boolean; accountId?: string }
): Promise<{ messageId: string; toJid: string }> { ): Promise<{ messageId: string; toJid: string }> {
const correlationId = randomUUID(); const correlationId = randomUUID();
const startedAt = Date.now(); const startedAt = Date.now();
const { listener: active } = requireActiveWebListener(options.accountId); const { listener: active } = requireActiveWebListener(options.accountId);
const logger = getChildLogger({ const logger = getChildLogger({
module: "web-outbound", module: 'web-outbound',
correlationId, correlationId,
to, to,
}); });
@ -159,18 +191,19 @@ export async function sendPollWhatsApp(
optionCount: normalized.options.length, optionCount: normalized.options.length,
maxSelections: normalized.maxSelections, maxSelections: normalized.maxSelections,
}, },
"sending poll", 'sending poll'
); );
const result = await active.sendPoll(to, normalized); const result = await active.sendPoll(to, normalized);
const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const messageId =
(result as { messageId?: string })?.messageId ?? 'unknown';
const durationMs = Date.now() - startedAt; const durationMs = Date.now() - startedAt;
outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`);
logger.info({ jid, messageId }, "sent poll"); logger.info({ jid, messageId }, 'sent poll');
return { messageId, toJid: jid }; return { messageId, toJid: jid };
} catch (err) { } catch (err) {
logger.error( logger.error(
{ err: String(err), to, question: poll.question }, { err: String(err), to, question: poll.question },
"failed to send poll via web session", 'failed to send poll via web session'
); );
throw err; throw err;
} }