From 876641e11bf8d58c57e778ff3ccf97f03cb13483 Mon Sep 17 00:00:00 2001 From: ANDRES FELIPE CARDONA SANTA Date: Mon, 26 Jan 2026 12:10:08 -0500 Subject: [PATCH] 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. --- src/agents/tools/message-tool.ts | 193 +++++--- src/cli/program/message/register.send.ts | 66 ++- src/infra/outbound/deliver.ts | 219 +++++++-- src/infra/outbound/message-action-runner.ts | 483 +++++++++++++------- src/infra/outbound/message.ts | 78 ++-- src/infra/outbound/outbound-send-service.ts | 58 ++- src/web/active-listener.ts | 29 +- src/web/inbound/send-api.ts | 68 ++- src/web/outbound.test.ts | 210 ++++++--- src/web/outbound.ts | 127 +++-- 10 files changed, 1033 insertions(+), 498 deletions(-) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index eae4356db..857911ee0 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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 { listChannelMessageActions, supportsChannelMessageButtons, supportsChannelMessageCards, -} from "../../channels/plugins/message-actions.js"; +} from '../../channels/plugins/message-actions.js'; import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, -} from "../../channels/plugins/types.js"; -import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { loadConfig } from "../../config/config.js"; -import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; -import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; -import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { resolveSessionAgentId } from "../agent-scope.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; -import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; -import { listChannelSupportedActions } from "../channel-tools.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; -import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readNumberParam, readStringParam } from "./common.js"; +} from '../../channels/plugins/types.js'; +import type { ClawdbotConfig } from '../../config/config.js'; +import { loadConfig } from '../../config/config.js'; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from '../../gateway/protocol/client-info.js'; +import { + getToolResult, + runMessageAction, +} from '../../infra/outbound/message-action-runner.js'; +import { normalizeTargetForProvider } from '../../infra/outbound/target-normalization.js'; +import { normalizeAccountId } from '../../routing/session-key.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; function buildRoutingSchema() { return { 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()), accountId: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()), }; } -function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { +function buildSendSchema(options: { + includeButtons: boolean; + includeCards: boolean; +}) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( 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( - 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()), filename: Type.Optional(Type.String()), buffer: Type.Optional( 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()), mimeType: Type.Optional(Type.String()), @@ -61,27 +79,57 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole asVoice: Type.Optional(Type.Boolean()), bestEffort: 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( Type.Array( Type.Array( Type.Object({ text: 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( Type.Object( {}, { 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; @@ -123,10 +171,14 @@ function buildPollSchema() { function buildChannelTargetSchema() { return { 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( - Type.Array(Type.String({ description: "Channel id filter (repeatable)." })), + Type.Array( + Type.String({ description: 'Channel id filter (repeatable).' }) + ) ), guildId: Type.Optional(Type.String()), userId: Type.Optional(Type.String()), @@ -196,13 +248,17 @@ function buildChannelManagementSchema() { categoryId: Type.Optional(Type.String()), clearParent: Type.Optional( 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 { ...buildRoutingSchema(), ...buildSendSchema(options), @@ -221,7 +277,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include function buildMessageToolSchemaFromActions( actions: readonly string[], - options: { includeButtons: boolean; includeCards: boolean }, + options: { includeButtons: boolean; includeCards: boolean } ) { const props = buildMessageToolSchemaProps(options); return Type.Object({ @@ -242,7 +298,7 @@ type MessageToolOptions = { currentChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; - replyToMode?: "off" | "first" | "all"; + replyToMode?: 'off' | 'first' | 'all'; hasRepliedRef?: { value: boolean }; }; @@ -250,10 +306,13 @@ function buildMessageToolSchema(cfg: ClawdbotConfig) { const actions = listChannelMessageActions(cfg); const includeButtons = supportsChannelMessageButtons(cfg); const includeCards = supportsChannelMessageCards(cfg); - return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { - includeButtons, - includeCards, - }); + return buildMessageToolSchemaFromActions( + actions.length > 0 ? actions : ['send'], + { + includeButtons, + includeCards, + } + ); } function resolveAgentAccountId(value?: string): string | undefined { @@ -268,19 +327,21 @@ function filterActionsForContext(params: { currentChannelId?: string; }): ChannelMessageActionName[] { const channel = normalizeMessageChannel(params.channel); - if (!channel || channel !== "bluebubbles") return params.actions; + if (!channel || channel !== 'bluebubbles') return params.actions; const currentChannelId = params.currentChannelId?.trim(); if (!currentChannelId) return params.actions; const normalizedTarget = normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId; const lowered = normalizedTarget.trim().toLowerCase(); const isGroupTarget = - lowered.startsWith("chat_guid:") || - lowered.startsWith("chat_id:") || - lowered.startsWith("chat_identifier:") || - lowered.startsWith("group:"); + lowered.startsWith('chat_guid:') || + lowered.startsWith('chat_id:') || + lowered.startsWith('chat_identifier:') || + lowered.startsWith('group:'); 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?: { @@ -288,7 +349,8 @@ function buildMessageToolDescription(options?: { currentChannel?: string; currentChannelId?: 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 (options?.currentChannel) { @@ -302,8 +364,8 @@ function buildMessageToolDescription(options?: { }); if (channelActions.length > 0) { // Always include "send" as a base action - const allActions = new Set(["send", ...channelActions]); - const actionList = Array.from(allActions).sort().join(", "); + const allActions = new Set(['send', ...channelActions]); + const actionList = Array.from(allActions).sort().join(', '); return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; } } @@ -312,7 +374,7 @@ function buildMessageToolDescription(options?: { if (options?.config) { const actions = listChannelMessageActions(options.config); 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 { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); - const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; + const schema = options?.config + ? buildMessageToolSchema(options.config) + : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, currentChannel: options?.currentChannelProvider, @@ -329,34 +393,34 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { }); return { - label: "Message", - name: "message", + label: 'Message', + name: 'message', description, parameters: schema, execute: async (_toolCallId, args, signal) => { // Check if already aborted before doing any work if (signal?.aborted) { - const err = new Error("Message send aborted"); - err.name = "AbortError"; + const err = new Error('Message send aborted'); + err.name = 'AbortError'; throw err; } const params = args as Record; const cfg = options?.config ?? loadConfig(); - const action = readStringParam(params, "action", { + const action = readStringParam(params, 'action', { required: true, }) as ChannelMessageActionName; - const accountId = readStringParam(params, "accountId") ?? agentAccountId; + const accountId = readStringParam(params, 'accountId') ?? agentAccountId; if (accountId) { params.accountId = accountId; } const gateway = { - url: readStringParam(params, "gatewayUrl", { trim: false }), - token: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: readNumberParam(params, "timeoutMs"), + url: readStringParam(params, 'gatewayUrl', { trim: false }), + token: readStringParam(params, 'gatewayToken', { trim: false }), + timeoutMs: readNumberParam(params, 'timeoutMs'), clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, - clientDisplayName: "agent", + clientDisplayName: 'agent', mode: GATEWAY_CLIENT_MODES.BACKEND, }; @@ -386,7 +450,10 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { gateway, toolContext, agentId: options?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) + ? resolveSessionAgentId({ + sessionKey: options.agentSessionKey, + config: cfg, + }) : undefined, abortSignal: signal, }); diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 8841c3ce8..9784b5275 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -1,30 +1,66 @@ -import type { Command } from "commander"; -import type { MessageCliHelpers } from "./helpers.js"; +import type { Command } from 'commander'; +import type { MessageCliHelpers } from './helpers.js'; -export function registerMessageSendCommand(message: Command, helpers: MessageCliHelpers) { +export function registerMessageSendCommand( + message: Command, + helpers: MessageCliHelpers +) { helpers .withMessageBase( helpers .withRequiredMessageTarget( message - .command("send") - .description("Send a message") - .option("-m, --message ", "Message body (required unless --media is set)"), + .command('send') + .description('Send a message') + .option( + '-m, --message ', + 'Message body (required unless --media is set)' + ) ) .option( - "--media ", - "Attach media (image/audio/video/document). Accepts local paths or URLs.", + '--media ', + 'Attach media (image/audio/video/document). Accepts local paths or URLs.' ) .option( - "--buttons ", - "Telegram inline keyboard buttons as JSON (array of button rows)", + '--buttons ', + 'Telegram inline keyboard buttons as JSON (array of button rows)' + ) + .option( + '--card ', + 'Adaptive Card JSON object (when supported by the channel)' + ) + .option('--reply-to ', 'Reply-to message id') + .option('--thread-id ', 'Thread id (Telegram forum thread)') + .option( + '--gif-playback', + 'Treat video media as GIF playback (WhatsApp only).', + false + ) + .option( + '--latitude ', + 'Latitude for location message (required with --longitude for native WhatsApp location pin)', + (value: string) => Number(value) + ) + .option( + '--longitude ', + 'Longitude for location message (required with --latitude for native WhatsApp location pin)', + (value: string) => Number(value) + ) + .option( + '--location-name ', + "Optional name for location message (e.g., 'Home', 'Office')" + ) + .option( + '--location-address ', + 'Optional address text for location message' + ) + .option( + '--location-accuracy ', + 'Optional accuracy in meters for location message', + (value: string) => Number(value) ) - .option("--card ", "Adaptive Card JSON object (when supported by the channel)") - .option("--reply-to ", "Reply-to message id") - .option("--thread-id ", "Thread id (Telegram forum thread)") - .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false), ) .action(async (opts) => { - await helpers.runMessageAction("send", opts); + await helpers.runMessageAction('send', opts); }); } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index d246889e9..334b59dbf 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -3,35 +3,43 @@ import { chunkMarkdownTextWithMode, resolveChunkMode, resolveTextChunkLimit, -} from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; -import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; -import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; -import type { ClawdbotConfig } from "../../config/config.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"; +} from '../../auto-reply/chunk.js'; +import type { ReplyPayload } from '../../auto-reply/types.js'; +import { resolveChannelMediaMaxBytes } from '../../channels/plugins/media-limits.js'; +import { loadChannelOutboundAdapter } from '../../channels/plugins/outbound/load.js'; +import type { ChannelOutboundAdapter } from '../../channels/plugins/types.js'; +import type { ClawdbotConfig } from '../../config/config.js'; +import { resolveMarkdownTableMode } from '../../config/markdown-tables.js'; import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, -} from "../../config/sessions.js"; -import type { NormalizedOutboundPayload } from "./payloads.js"; -import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; -import type { OutboundChannel } from "./targets.js"; +} from '../../config/sessions.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 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 = ( to: 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 }>; export type OutboundSendDeps = { @@ -45,12 +53,12 @@ export type OutboundSendDeps = { sendMSTeams?: ( to: string, text: string, - opts?: { mediaUrl?: string }, + opts?: { mediaUrl?: string } ) => Promise<{ messageId: string; conversationId: string }>; }; export type OutboundDeliveryResult = { - channel: Exclude; + channel: Exclude; messageId: string; chatId?: string; channelId?: string; @@ -67,23 +75,26 @@ type Chunker = (text: string, limit: number) => string[]; type ChannelHandler = { chunker: Chunker | null; - chunkerMode?: "text" | "markdown"; + chunkerMode?: 'text' | 'markdown'; textChunkLimit?: number; sendPayload?: (payload: ReplyPayload) => Promise; sendText: (text: string) => Promise; - sendMedia: (caption: string, mediaUrl: string) => Promise; + sendMedia: ( + caption: string, + mediaUrl: string + ) => Promise; }; function throwIfAborted(abortSignal?: AbortSignal): void { if (abortSignal?.aborted) { - throw new Error("Outbound delivery aborted"); + throw new Error('Outbound delivery aborted'); } } // Channel docking: outbound delivery delegates to plugin.outbound adapters. async function createChannelHandler(params: { cfg: ClawdbotConfig; - channel: Exclude; + channel: Exclude; to: string; accountId?: string; replyToId?: string | null; @@ -115,7 +126,7 @@ async function createChannelHandler(params: { function createPluginHandler(params: { outbound?: ChannelOutboundAdapter; cfg: ClawdbotConfig; - channel: Exclude; + channel: Exclude; to: string; accountId?: string; replyToId?: string | null; @@ -138,7 +149,7 @@ function createPluginHandler(params: { outbound.sendPayload!({ cfg: params.cfg, to: params.to, - text: payload.text ?? "", + text: payload.text ?? '', mediaUrl: payload.mediaUrl, accountId: params.accountId, replyToId: params.replyToId, @@ -176,7 +187,7 @@ function createPluginHandler(params: { export async function deliverOutboundPayloads(params: { cfg: ClawdbotConfig; - channel: Exclude; + channel: Exclude; to: string; accountId?: string; payloads: ReplyPayload[]; @@ -186,6 +197,13 @@ export async function deliverOutboundPayloads(params: { gifPlayback?: boolean; abortSignal?: AbortSignal; bestEffort?: boolean; + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + }; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; onPayload?: (payload: NormalizedOutboundPayload) => void; mirror?: { @@ -216,11 +234,13 @@ export async function deliverOutboundPayloads(params: { fallbackLimit: handler.textChunkLimit, }) : undefined; - const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length"; - const isSignalChannel = channel === "signal"; + const chunkMode = handler.chunker + ? resolveChunkMode(cfg, channel, accountId) + : 'length'; + const isSignalChannel = channel === 'signal'; const signalTableMode = isSignalChannel - ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) - : "code"; + ? resolveMarkdownTableMode({ cfg, channel: 'signal', accountId }) + : 'code'; const signalMaxBytes = isSignalChannel ? resolveChannelMediaMaxBytes({ cfg, @@ -237,11 +257,11 @@ export async function deliverOutboundPayloads(params: { results.push(await handler.sendText(text)); return; } - if (chunkMode === "newline") { - const mode = handler.chunkerMode ?? "text"; + if (chunkMode === 'newline') { + const mode = handler.chunkerMode ?? 'text'; const blockChunks = - mode === "markdown" - ? chunkMarkdownTextWithMode(text, textLimit, "newline") + mode === 'markdown' + ? chunkMarkdownTextWithMode(text, textLimit, 'newline') : chunkByParagraph(text, textLimit); 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); return { - channel: "signal" as const, + channel: 'signal' as const, ...(await sendSignal(to, text, { maxBytes: signalMaxBytes, accountId: accountId ?? undefined, - textMode: "plain", + textMode: 'plain', textStyles: styles, })), }; @@ -282,7 +305,9 @@ export async function deliverOutboundPayloads(params: { ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode: signalTableMode, }) - : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); + : markdownToSignalTextChunks(text, textLimit, { + tableMode: signalTableMode, + }); if (signalChunks.length === 0 && text) { signalChunks = [{ text, styles: [] }]; } @@ -294,28 +319,122 @@ export async function deliverOutboundPayloads(params: { const sendSignalMedia = async (caption: string, mediaUrl: string) => { throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { - tableMode: signalTableMode, - })[0] ?? { + const formatted = markdownToSignalTextChunks( + caption, + Number.POSITIVE_INFINITY, + { + tableMode: signalTableMode, + } + )[0] ?? { text: caption, styles: [], }; return { - channel: "signal" as const, + channel: 'signal' as const, ...(await sendSignal(to, formatted.text, { mediaUrl, maxBytes: signalMaxBytes, accountId: accountId ?? undefined, - textMode: "plain", + textMode: 'plain', textStyles: formatted.styles, })), }; }; 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) { const payloadSummary: NormalizedOutboundPayload = { - text: payload.text ?? "", - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + text: payload.text ?? '', + mediaUrls: + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), channelData: payload.channelData, }; try { @@ -337,7 +456,7 @@ export async function deliverOutboundPayloads(params: { let first = true; for (const url of payloadSummary.mediaUrls) { throwIfAborted(abortSignal); - const caption = first ? payloadSummary.text : ""; + const caption = first ? payloadSummary.text : ''; first = false; if (isSignalChannel) { results.push(await sendSignalMedia(caption, url)); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8d02b743c..574d21c6c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,48 +1,60 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import path from 'node:path'; +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 { readNumberParam, readStringArrayParam, readStringParam, -} from "../../agents/tools/common.js"; -import { resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +} from '../../agents/tools/common.js'; +import { parseReplyDirectives } from '../../auto-reply/reply/reply-directives.js'; +import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js'; import type { ChannelId, ChannelMessageActionName, ChannelThreadingToolContext, -} from "../../channels/plugins/types.js"; -import type { ClawdbotConfig } from "../../config/config.js"; +} from '../../channels/plugins/types.js'; +import type { ClawdbotConfig } from '../../config/config.js'; +import { extensionForMime } from '../../media/mime.js'; +import { parseSlackTarget } from '../../slack/targets.js'; import { isDeliverableMessageChannel, normalizeMessageChannel, type GatewayClientMode, type GatewayClientName, -} from "../../utils/message-channel.js"; +} from '../../utils/message-channel.js'; +import { loadWebMedia } from '../../web/media.js'; import { listConfiguredMessageChannels, resolveMessageChannelSelection, -} from "./channel-selection.js"; -import { applyTargetToParams } from "./channel-target.js"; -import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js"; -import type { OutboundSendDeps } from "./deliver.js"; -import type { MessagePollResult, MessageSendResult } from "./message.js"; +} from './channel-selection.js'; +import { applyTargetToParams } from './channel-target.js'; +import type { OutboundSendDeps } from './deliver.js'; +import { + actionHasTarget, + actionRequiresTarget, +} from './message-action-spec.js'; +import type { MessagePollResult, MessageSendResult } from './message.js'; import { applyCrossContextDecoration, buildCrossContextDecoration, - type CrossContextDecoration, enforceCrossContextPolicy, shouldApplyCrossContextMarker, -} from "./outbound-policy.js"; -import { executePollAction, executeSendAction } from "./outbound-send-service.js"; -import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; -import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js"; -import { loadWebMedia } from "../../web/media.js"; -import { extensionForMime } from "../../media/mime.js"; -import { parseSlackTarget } from "../../slack/targets.js"; + type CrossContextDecoration, +} from './outbound-policy.js'; +import { + executePollAction, + executeSendAction, +} from './outbound-send-service.js'; +import { + ensureOutboundSessionEntry, + resolveOutboundSessionRoute, +} from './outbound-session.js'; +import { + resolveChannelTarget, + type ResolvedMessagingTarget, +} from './target-resolver.js'; export type MessageActionRunnerGateway = { url?: string; @@ -69,21 +81,21 @@ export type RunMessageActionParams = { export type MessageActionRunResult = | { - kind: "send"; + kind: 'send'; channel: ChannelId; - action: "send"; + action: 'send'; to: string; - handledBy: "plugin" | "core"; + handledBy: 'plugin' | 'core'; payload: unknown; toolResult?: AgentToolResult; sendResult?: MessageSendResult; dryRun: boolean; } | { - kind: "broadcast"; + kind: 'broadcast'; channel: ChannelId; - action: "broadcast"; - handledBy: "core" | "dry-run"; + action: 'broadcast'; + handledBy: 'core' | 'dry-run'; payload: { results: Array<{ channel: ChannelId; @@ -96,30 +108,30 @@ export type MessageActionRunResult = dryRun: boolean; } | { - kind: "poll"; + kind: 'poll'; channel: ChannelId; - action: "poll"; + action: 'poll'; to: string; - handledBy: "plugin" | "core"; + handledBy: 'plugin' | 'core'; payload: unknown; toolResult?: AgentToolResult; pollResult?: MessagePollResult; dryRun: boolean; } | { - kind: "action"; + kind: 'action'; channel: ChannelId; - action: Exclude; - handledBy: "plugin" | "dry-run"; + action: Exclude; + handledBy: 'plugin' | 'dry-run'; payload: unknown; toolResult?: AgentToolResult; dryRun: boolean; }; export function getToolResult( - result: MessageActionRunResult, + result: MessageActionRunResult ): AgentToolResult | undefined { - return "toolResult" in result ? result.toolResult : undefined; + return 'toolResult' in result ? result.toolResult : undefined; } function extractToolPayload(result: AgentToolResult): unknown { @@ -128,9 +140,9 @@ function extractToolPayload(result: AgentToolResult): unknown { ? result.content.find( (block) => block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", + typeof block === 'object' && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string' ) : undefined; const text = (textBlock as { text?: string } | undefined)?.text; @@ -197,13 +209,16 @@ async function maybeApplyCrossContextMarker(params: { }); } -function readBooleanParam(params: Record, key: string): boolean | undefined { +function readBooleanParam( + params: Record, + key: string +): boolean | undefined { const raw = params[key]; - if (typeof raw === "boolean") return raw; - if (typeof raw === "string") { + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'string') { const trimmed = raw.trim().toLowerCase(); - if (trimmed === "true") return true; - if (trimmed === "false") return false; + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; } return undefined; } @@ -215,11 +230,14 @@ function resolveSlackAutoThreadId(params: { const context = params.toolContext; if (!context?.currentThreadTs || !context.currentChannelId) return undefined; // Only mirror auto-threading when Slack would reply in the active thread for this channel. - if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined; - const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); - if (!parsedTarget || parsedTarget.kind !== "channel") return undefined; - if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) return undefined; - if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined; + if (context.replyToMode !== 'all' && context.replyToMode !== 'first') + return undefined; + const parsedTarget = parseSlackTarget(params.to, { defaultKind: 'channel' }); + if (!parsedTarget || parsedTarget.kind !== 'channel') return undefined; + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) + return undefined; + if (context.replyToMode === 'first' && context.hasRepliedRef?.value) + return undefined; return context.currentThreadTs; } @@ -229,31 +247,35 @@ function resolveAttachmentMaxBytes(params: { accountId?: string | null; }): number | undefined { const fallback = params.cfg.agents?.defaults?.mediaMaxMb; - if (params.channel !== "bluebubbles") { - return typeof fallback === "number" ? fallback * 1024 * 1024 : undefined; + if (params.channel !== 'bluebubbles') { + 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 channelObj = - channelCfg && typeof channelCfg === "object" + channelCfg && typeof channelCfg === 'object' ? (channelCfg as Record) : undefined; const channelMediaMax = - typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; + typeof channelObj?.mediaMaxMb === 'number' + ? channelObj.mediaMaxMb + : undefined; const accountsObj = - channelObj?.accounts && typeof channelObj.accounts === "object" + channelObj?.accounts && typeof channelObj.accounts === 'object' ? (channelObj.accounts as Record) : undefined; - const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; + const accountCfg = + accountId && accountsObj ? accountsObj[accountId] : undefined; const accountMediaMax = - accountCfg && typeof accountCfg === "object" + accountCfg && typeof accountCfg === 'object' ? (accountCfg as Record).mediaMaxMb : undefined; const limitMb = - (typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? + (typeof accountMediaMax === 'number' ? accountMediaMax : undefined) ?? channelMediaMax ?? params.cfg.agents?.defaults?.mediaMaxMb; - return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; + return typeof limitMb === 'number' ? limitMb * 1024 * 1024 : undefined; } function inferAttachmentFilename(params: { @@ -263,7 +285,7 @@ function inferAttachmentFilename(params: { const mediaHint = params.mediaHint?.trim(); if (mediaHint) { try { - if (mediaHint.startsWith("file://")) { + if (mediaHint.startsWith('file://')) { const filePath = fileURLToPath(mediaHint); const base = path.basename(filePath); if (base) return base; @@ -279,15 +301,21 @@ function inferAttachmentFilename(params: { // fall through to content-type based default } } - const ext = params.contentType ? extensionForMime(params.contentType) : undefined; - return ext ? `attachment${ext}` : "attachment"; + const ext = params.contentType + ? 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; 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()); if (!match) return { base64: params.base64, contentType: params.contentType }; const [, mime, payload] = match; @@ -305,16 +333,17 @@ async function hydrateSetGroupIconParams(params: { action: ChannelMessageActionName; dryRun?: boolean; }): Promise { - 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 = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); + readStringParam(params.args, 'path', { trim: false }) ?? + readStringParam(params.args, 'filePath', { trim: false }); 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({ base64: rawBuffer, 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; - if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { + if ( + !params.dryRun && + !readStringParam(params.args, 'buffer', { trim: false }) && + mediaSource + ) { const maxBytes = resolveAttachmentMaxBytes({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, }); const media = await loadWebMedia(mediaSource, maxBytes); - params.args.buffer = media.buffer.toString("base64"); + params.args.buffer = media.buffer.toString('base64'); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; } @@ -362,19 +395,24 @@ async function hydrateSendAttachmentParams(params: { action: ChannelMessageActionName; dryRun?: boolean; }): Promise { - 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 = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); + readStringParam(params.args, 'path', { trim: false }) ?? + readStringParam(params.args, 'filePath', { trim: false }); const contentTypeParam = - readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); - const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim(); - const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim(); + readStringParam(params.args, 'contentType') ?? + readStringParam(params.args, 'mimeType'); + const caption = readStringParam(params.args, 'caption', { + allowEmpty: true, + })?.trim(); + const message = readStringParam(params.args, 'message', { + allowEmpty: true, + })?.trim(); if (!caption && message) params.args.caption = message; - const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); + const rawBuffer = readStringParam(params.args, 'buffer', { trim: false }); const normalized = normalizeBase64Payload({ base64: rawBuffer, 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; - if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { + if ( + !params.dryRun && + !readStringParam(params.args, 'buffer', { trim: false }) && + mediaSource + ) { const maxBytes = resolveAttachmentMaxBytes({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, }); const media = await loadWebMedia(mediaSource, maxBytes); - params.args.buffer = media.buffer.toString("base64"); + params.args.buffer = media.buffer.toString('base64'); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; } @@ -416,7 +458,7 @@ async function hydrateSendAttachmentParams(params: { function parseButtonsParam(params: Record): void { const raw = params.buttons; - if (typeof raw !== "string") return; + if (typeof raw !== 'string') return; const trimmed = raw.trim(); if (!trimmed) { delete params.buttons; @@ -425,13 +467,13 @@ function parseButtonsParam(params: Record): void { try { params.buttons = JSON.parse(trimmed) as unknown; } catch { - throw new Error("--buttons must be valid JSON"); + throw new Error('--buttons must be valid JSON'); } } function parseCardParam(params: Record): void { const raw = params.card; - if (typeof raw !== "string") return; + if (typeof raw !== 'string') return; const trimmed = raw.trim(); if (!trimmed) { delete params.card; @@ -440,12 +482,15 @@ function parseCardParam(params: Record): void { try { params.card = JSON.parse(trimmed) as unknown; } catch { - throw new Error("--card must be valid JSON"); + throw new Error('--card must be valid JSON'); } } -async function resolveChannel(cfg: ClawdbotConfig, params: Record) { - const channelHint = readStringParam(params, "channel"); +async function resolveChannel( + cfg: ClawdbotConfig, + params: Record +) { + const channelHint = readStringParam(params, 'channel'); const selection = await resolveMessageChannelSelection({ cfg, channel: channelHint, @@ -461,7 +506,7 @@ async function resolveActionTarget(params: { accountId?: string | null; }): Promise { 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) { const resolved = await resolveChannelTarget({ cfg: params.cfg, @@ -477,20 +522,27 @@ async function resolveActionTarget(params: { } } const channelIdRaw = - typeof params.args.channelId === "string" ? params.args.channelId.trim() : ""; + typeof params.args.channelId === 'string' + ? params.args.channelId.trim() + : ''; if (channelIdRaw) { const resolved = await resolveChannelTarget({ cfg: params.cfg, channel: params.channel, input: channelIdRaw, accountId: params.accountId ?? undefined, - preferredKind: "group", + preferredKind: 'group', }); if (resolved.ok) { - if (resolved.target.kind === "user") { - throw new Error(`Channel id "${channelIdRaw}" resolved to a user target.`); + if (resolved.target.kind === 'user') { + throw new Error( + `Channel id "${channelIdRaw}" resolved to a user target.` + ); } - params.args.channelId = resolved.target.to.replace(/^(channel|group):/i, ""); + params.args.channelId = resolved.target.to.replace( + /^(channel|group):/i, + '' + ); } else { throw resolved.error; } @@ -510,7 +562,9 @@ type ResolvedActionContext = { resolvedTarget?: ResolvedMessagingTarget; abortSignal?: AbortSignal; }; -function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { +function resolveGateway( + input: RunMessageActionParams +): MessageActionRunnerGateway | undefined { if (!input.gateway) return undefined; return { url: input.gateway.url, @@ -524,24 +578,28 @@ function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGatew async function handleBroadcastAction( input: RunMessageActionParams, - params: Record, + params: Record ): Promise { throwIfAborted(input.abortSignal); - const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false; + const broadcastEnabled = + input.cfg.tools?.message?.broadcast?.enabled !== false; 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) { - 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); 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 = - channelHint && channelHint.trim().toLowerCase() !== "all" + channelHint && channelHint.trim().toLowerCase() !== 'all' ? [await resolveChannel(input.cfg, { channel: channelHint })] : configured; const results: Array<{ @@ -551,7 +609,8 @@ async function handleBroadcastAction( error?: string; 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) { throwIfAborted(input.abortSignal); for (const target of rawTargets) { @@ -565,7 +624,7 @@ async function handleBroadcastAction( if (!resolved.ok) throw resolved.error; const sendResult = await runMessageAction({ ...input, - action: "send", + action: 'send', params: { ...params, channel: targetChannel, @@ -576,7 +635,8 @@ async function handleBroadcastAction( channel: targetChannel, to: resolved.target.to, ok: true, - result: sendResult.kind === "send" ? sendResult.sendResult : undefined, + result: + sendResult.kind === 'send' ? sendResult.sendResult : undefined, }); } catch (err) { if (isAbortError(err)) throw err; @@ -590,10 +650,10 @@ async function handleBroadcastAction( } } return { - kind: "broadcast", - channel: (targetChannels[0] ?? "discord") as ChannelId, - action: "broadcast", - handledBy: input.dryRun ? "dry-run" : "core", + kind: 'broadcast', + channel: (targetChannels[0] ?? 'discord') as ChannelId, + action: 'broadcast', + handledBy: input.dryRun ? 'dry-run' : 'core', payload: { results }, dryRun: Boolean(input.dryRun), }; @@ -601,13 +661,15 @@ async function handleBroadcastAction( function throwIfAborted(abortSignal?: AbortSignal): void { if (abortSignal?.aborted) { - const err = new Error("Message send aborted"); - err.name = "AbortError"; + const err = new Error('Message send aborted'); + err.name = 'AbortError'; throw err; } } -async function handleSendAction(ctx: ResolvedActionContext): Promise { +async function handleSendAction( + ctx: ResolvedActionContext +): Promise { const { cfg, params, @@ -621,19 +683,19 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 90) { + throw new Error( + `Invalid latitude: ${latitude}. Must be between -90 and 90.` + ); + } + // Validate longitude range: -180 to 180 + if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + throw new Error( + `Invalid longitude: ${longitude}. Must be between -180 and 180.` + ); + } + // Validate accuracy if provided (must be positive) + if ( + locationAccuracy != null && + (!Number.isFinite(locationAccuracy) || locationAccuracy < 0) + ) { + throw new Error( + `Invalid location accuracy: ${locationAccuracy}. Must be a positive number.` + ); + } + location = { + latitude, + longitude, + name: locationName ?? undefined, + address: locationAddress ?? undefined, + accuracy: locationAccuracy ?? undefined, + }; + } else if (latitude != null || longitude != null) { + // If only one coordinate is provided, that's an error + throw new Error( + 'Both latitude and longitude are required for location messages.' + ); + } + + const replyToId = readStringParam(params, 'replyTo'); + const threadId = readStringParam(params, 'threadId'); // Slack auto-threading can inject threadTs without explicit params; mirror to that session key. const slackAutoThreadId = - channel === "slack" && !replyToId && !threadId + channel === 'slack' && !replyToId && !threadId ? resolveSlackAutoThreadId({ to, toolContext: input.toolContext }) : undefined; const outboundRoute = @@ -702,7 +817,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; + mergedMediaUrls.length > 0 + ? mergedMediaUrls + : mediaUrl + ? [mediaUrl] + : undefined; throwIfAborted(abortSignal); const send = await executeSendAction({ ctx: { @@ -731,10 +850,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; +async function handlePollAction( + ctx: ResolvedActionContext +): Promise { + const { + cfg, + params, + channel, + accountId, + dryRun, + gateway, + input, + abortSignal, + } = ctx; throwIfAborted(abortSignal); - const action: ChannelMessageActionName = "poll"; - const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "pollQuestion", { + const action: ChannelMessageActionName = 'poll'; + const to = readStringParam(params, 'to', { required: true }); + const question = readStringParam(params, 'pollQuestion', { required: true, }); - const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; + const options = + readStringArrayParam(params, 'pollOption', { required: true }) ?? []; 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 durationHours = readNumberParam(params, "pollDurationHours", { + const allowMultiselect = readBooleanParam(params, 'pollMulti') ?? false; + const durationHours = readNumberParam(params, 'pollDurationHours', { integer: true, }); const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; - const base = typeof params.message === "string" ? params.message : ""; + const base = typeof params.message === 'string' ? params.message : ''; await maybeApplyCrossContextMarker({ cfg, channel, @@ -794,7 +926,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; +async function handlePluginAction( + ctx: ResolvedActionContext +): Promise { + const { + cfg, + params, + channel, + accountId, + dryRun, + gateway, + input, + abortSignal, + } = ctx; throwIfAborted(abortSignal); - const action = input.action as Exclude; + const action = input.action as Exclude< + ChannelMessageActionName, + 'send' | 'poll' | 'broadcast' + >; if (dryRun) { return { - kind: "action", + kind: 'action', channel, action, - handledBy: "dry-run", + handledBy: 'dry-run', payload: { ok: true, dryRun: true, channel, action }, dryRun: true, }; @@ -832,13 +978,15 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise { const cfg = input.cfg; const params = { ...input.params }; @@ -859,14 +1007,16 @@ export async function runMessageAction( parseCardParam(params); const action = input.action; - if (action === "broadcast") { + if (action === 'broadcast') { return handleBroadcastAction(input, params); } - const explicitTarget = typeof params.target === "string" ? params.target.trim() : ""; + const explicitTarget = + typeof params.target === 'string' ? params.target.trim() : ''; const hasLegacyTarget = - (typeof params.to === "string" && params.to.trim().length > 0) || - (typeof params.channelId === "string" && params.channelId.trim().length > 0); + (typeof params.to === 'string' && params.to.trim().length > 0) || + (typeof params.channelId === 'string' && + params.channelId.trim().length > 0); if (explicitTarget && hasLegacyTarget) { delete params.to; delete params.channelId; @@ -883,8 +1033,9 @@ export async function runMessageAction( } } if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) { - const legacyTo = typeof params.to === "string" ? params.to.trim() : ""; - const legacyChannelId = typeof params.channelId === "string" ? params.channelId.trim() : ""; + const legacyTo = typeof params.to === 'string' ? params.to.trim() : ''; + const legacyChannelId = + typeof params.channelId === 'string' ? params.channelId.trim() : ''; const legacyTarget = legacyTo || legacyChannelId; if (legacyTarget) { params.target = legacyTarget; @@ -892,9 +1043,12 @@ export async function runMessageAction( delete params.channelId; } } - const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : ""; + const explicitChannel = + typeof params.channel === 'string' ? params.channel.trim() : ''; if (!explicitChannel) { - const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider); + const inferredChannel = normalizeMessageChannel( + input.toolContext?.currentChannelProvider + ); if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) { params.channel = inferredChannel; } @@ -908,11 +1062,12 @@ export async function runMessageAction( } const channel = await resolveChannel(cfg, params); - const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + const accountId = + readStringParam(params, 'accountId') ?? input.defaultAccountId; if (accountId) { params.accountId = accountId; } - const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); + const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, 'dryRun')); await hydrateSendAttachmentParams({ cfg, @@ -950,7 +1105,7 @@ export async function runMessageAction( const gateway = resolveGateway(input); - if (action === "send") { + if (action === 'send') { return handleSendAction({ cfg, params, @@ -965,7 +1120,7 @@ export async function runMessageAction( }); } - if (action === "poll") { + if (action === 'poll') { return handlePollAction({ cfg, params, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 6f5f88bd2..e749bfa83 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,25 +1,28 @@ -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelId } from "../../channels/plugins/types.js"; -import type { ClawdbotConfig } from "../../config/config.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 { + getChannelPlugin, + normalizeChannelId, +} from '../../channels/plugins/index.js'; +import type { ChannelId } from '../../channels/plugins/types.js'; +import type { ClawdbotConfig } from '../../config/config.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 { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, type GatewayClientMode, type GatewayClientName, -} from "../../utils/message-channel.js"; -import { resolveMessageChannelSelection } from "./channel-selection.js"; +} from '../../utils/message-channel.js'; +import { resolveMessageChannelSelection } from './channel-selection.js'; import { deliverOutboundPayloads, type OutboundDeliveryResult, type OutboundSendDeps, -} from "./deliver.js"; -import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; -import type { OutboundChannel } from "./targets.js"; -import { resolveOutboundTarget } from "./targets.js"; +} from './deliver.js'; +import { normalizeReplyPayloadsForDelivery } from './payloads.js'; +import type { OutboundChannel } from './targets.js'; +import { resolveOutboundTarget } from './targets.js'; export type MessageGatewayOptions = { url?: string; @@ -40,6 +43,13 @@ type MessageSendParams = { accountId?: string; dryRun?: boolean; bestEffort?: boolean; + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + }; deps?: OutboundSendDeps; cfg?: ClawdbotConfig; gateway?: MessageGatewayOptions; @@ -56,7 +66,7 @@ type MessageSendParams = { export type MessageSendResult = { channel: string; to: string; - via: "direct" | "gateway"; + via: 'direct' | 'gateway'; mediaUrl: string | null; mediaUrls?: string[]; result?: OutboundDeliveryResult | { messageId: string }; @@ -83,7 +93,7 @@ export type MessagePollResult = { options: string[]; maxSelections: number; durationHours: number | null; - via: "gateway"; + via: 'gateway'; result?: { messageId: string; toJid?: string; @@ -99,7 +109,7 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) { url: opts?.url, token: opts?.token, timeoutMs: - typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + typeof opts?.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.floor(opts.timeoutMs)) : 10_000, clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, @@ -108,7 +118,9 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) { }; } -export async function sendMessage(params: MessageSendParams): Promise { +export async function sendMessage( + params: MessageSendParams +): Promise { const cfg = params.cfg ?? loadConfig(); const channel = params.channel?.trim() ? normalizeChannelId(params.channel) @@ -120,7 +132,7 @@ export async function sendMessage(params: MessageSendParams): Promise payload.text) .filter(Boolean) - .join("\n"); + .join('\n'); 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; @@ -141,21 +154,21 @@ export async function sendMessage(params: MessageSendParams): Promise; + if (deliveryMode !== 'gateway') { + const outboundChannel = channel as Exclude; const resolvedTarget = resolveOutboundTarget({ channel: outboundChannel, to: params.to, cfg, accountId: params.accountId, - mode: "explicit", + mode: 'explicit', }); if (!resolvedTarget.ok) throw resolvedTarget.error; @@ -169,6 +182,7 @@ export async function sendMessage(params: MessageSendParams): Promise({ url: gateway.url, token: gateway.token, - method: "send", + method: 'send', params: { to: params.to, message: params.content, @@ -213,14 +227,16 @@ export async function sendMessage(params: MessageSendParams): Promise { +export async function sendPoll( + params: MessagePollParams +): Promise { const cfg = params.cfg ?? loadConfig(); const channel = params.channel?.trim() ? normalizeChannelId(params.channel) @@ -252,7 +268,7 @@ export async function sendPoll(params: MessagePollParams): Promise({ url: gateway.url, token: gateway.token, - method: "poll", + method: 'poll', params: { to: params.to, question: normalized.question, @@ -290,7 +306,7 @@ export async function sendPoll(params: MessagePollParams): Promise): unknown { ? result.content.find( (block) => block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", + typeof block === 'object' && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string' ) : undefined; const text = (textBlock as { text?: string } | undefined)?.text; @@ -59,8 +65,8 @@ function extractToolPayload(result: AgentToolResult): unknown { function throwIfAborted(abortSignal?: AbortSignal): void { if (abortSignal?.aborted) { - const err = new Error("Message send aborted"); - err.name = "AbortError"; + const err = new Error('Message send aborted'); + err.name = 'AbortError'; throw err; } } @@ -73,8 +79,15 @@ export async function executeSendAction(params: { mediaUrls?: string[]; gifPlayback?: boolean; bestEffort?: boolean; + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + }; }): Promise<{ - handledBy: "plugin" | "core"; + handledBy: 'plugin' | 'core'; payload: unknown; toolResult?: AgentToolResult; sendResult?: MessageSendResult; @@ -83,7 +96,7 @@ export async function executeSendAction(params: { if (!params.ctx.dryRun) { const handled = await dispatchChannelMessageAction({ channel: params.ctx.channel, - action: "send", + action: 'send', cfg: params.ctx.cfg, params: params.ctx.params, accountId: params.ctx.accountId ?? undefined, @@ -106,7 +119,7 @@ export async function executeSendAction(params: { }); } return { - handledBy: "plugin", + handledBy: 'plugin', payload: extractToolPayload(handled), toolResult: handled, }; @@ -125,6 +138,7 @@ export async function executeSendAction(params: { gifPlayback: params.gifPlayback, dryRun: params.ctx.dryRun, bestEffort: params.bestEffort ?? undefined, + location: params.location, deps: params.ctx.deps, gateway: params.ctx.gateway, mirror: params.ctx.mirror, @@ -132,7 +146,7 @@ export async function executeSendAction(params: { }); return { - handledBy: "core", + handledBy: 'core', payload: result, sendResult: result, }; @@ -146,7 +160,7 @@ export async function executePollAction(params: { maxSelections: number; durationHours?: number; }): Promise<{ - handledBy: "plugin" | "core"; + handledBy: 'plugin' | 'core'; payload: unknown; toolResult?: AgentToolResult; pollResult?: MessagePollResult; @@ -154,7 +168,7 @@ export async function executePollAction(params: { if (!params.ctx.dryRun) { const handled = await dispatchChannelMessageAction({ channel: params.ctx.channel, - action: "poll", + action: 'poll', cfg: params.ctx.cfg, params: params.ctx.params, accountId: params.ctx.accountId ?? undefined, @@ -164,7 +178,7 @@ export async function executePollAction(params: { }); if (handled) { return { - handledBy: "plugin", + handledBy: 'plugin', payload: extractToolPayload(handled), toolResult: handled, }; @@ -184,7 +198,7 @@ export async function executePollAction(params: { }); return { - handledBy: "core", + handledBy: 'core', payload: result, pollResult: result, }; diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index 0147a25b6..9f3558a23 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -1,6 +1,6 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import type { PollInput } from "../polls.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { formatCliCommand } from '../cli/command-format.js'; +import type { PollInput } from '../polls.js'; +import { DEFAULT_ACCOUNT_ID } from '../routing/session-key.js'; export type ActiveWebSendOptions = { gifPlayback?: boolean; @@ -14,6 +14,13 @@ export type ActiveWebListener = { mediaBuffer?: Buffer, mediaType?: string, options?: ActiveWebSendOptions, + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + } ) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendReaction: ( @@ -21,7 +28,7 @@ export type ActiveWebListener = { messageId: string, emoji: string, fromMe: boolean, - participant?: string, + participant?: string ) => Promise; sendComposingTo: (to: string) => Promise; close?: () => Promise; @@ -32,7 +39,7 @@ let _currentListener: ActiveWebListener | null = null; const listeners = new Map(); 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): { @@ -43,7 +50,7 @@ export function requireActiveWebListener(accountId?: string | null): { const listener = listeners.get(id) ?? null; if (!listener) { 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 }; @@ -52,14 +59,14 @@ export function requireActiveWebListener(accountId?: string | null): { export function setActiveWebListener(listener: ActiveWebListener | null): void; export function setActiveWebListener( accountId: string | null | undefined, - listener: ActiveWebListener | null, + listener: ActiveWebListener | null ): void; export function setActiveWebListener( accountIdOrListener: string | ActiveWebListener | null | undefined, - maybeListener?: ActiveWebListener | null, + maybeListener?: ActiveWebListener | null ): void { const { accountId, listener } = - typeof accountIdOrListener === "string" + typeof accountIdOrListener === 'string' ? { accountId: accountIdOrListener, listener: maybeListener ?? null } : { 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); return listeners.get(id) ?? null; } diff --git a/src/web/inbound/send-api.ts b/src/web/inbound/send-api.ts index 06860e896..bacf99318 100644 --- a/src/web/inbound/send-api.ts +++ b/src/web/inbound/send-api.ts @@ -1,12 +1,15 @@ -import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; -import { recordChannelActivity } from "../../infra/channel-activity.js"; -import { toWhatsappJid } from "../../utils.js"; -import type { ActiveWebSendOptions } from "../active-listener.js"; +import type { AnyMessageContent, WAPresence } from '@whiskeysockets/baileys'; +import { recordChannelActivity } from '../../infra/channel-activity.js'; +import { toWhatsappJid } from '../../utils.js'; +import type { ActiveWebSendOptions } from '../active-listener.js'; export function createWebSendApi(params: { sock: { sendMessage: (jid: string, content: AnyMessageContent) => Promise; - sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise; + sendPresenceUpdate: ( + presence: WAPresence, + jid?: string + ) => Promise; }; defaultAccountId: string; }) { @@ -17,19 +20,38 @@ export function createWebSendApi(params: { mediaBuffer?: Buffer, mediaType?: string, sendOptions?: ActiveWebSendOptions, + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + } ): Promise<{ messageId: string }> => { const jid = toWhatsappJid(to); 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 = { image: mediaBuffer, caption: text || undefined, mimetype: mediaType, }; - } else if (mediaType.startsWith("audio/")) { + } else if (mediaType.startsWith('audio/')) { payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType }; - } else if (mediaType.startsWith("video/")) { + } else if (mediaType.startsWith('video/')) { const gifPlayback = sendOptions?.gifPlayback; payload = { video: mediaBuffer, @@ -40,7 +62,7 @@ export function createWebSendApi(params: { } else { payload = { document: mediaBuffer, - fileName: "file", + fileName: 'file', caption: text || undefined, mimetype: mediaType, }; @@ -51,19 +73,19 @@ export function createWebSendApi(params: { const result = await params.sock.sendMessage(jid, payload); const accountId = sendOptions?.accountId ?? params.defaultAccountId; recordChannelActivity({ - channel: "whatsapp", + channel: 'whatsapp', accountId, - direction: "outbound", + direction: 'outbound', }); const messageId = - typeof result === "object" && result && "key" in result - ? String((result as { key?: { id?: string } }).key?.id ?? "unknown") - : "unknown"; + typeof result === 'object' && result && 'key' in result + ? String((result as { key?: { id?: string } }).key?.id ?? 'unknown') + : 'unknown'; return { messageId }; }, sendPoll: async ( to: string, - poll: { question: string; options: string[]; maxSelections?: number }, + poll: { question: string; options: string[]; maxSelections?: number } ): Promise<{ messageId: string }> => { const jid = toWhatsappJid(to); const result = await params.sock.sendMessage(jid, { @@ -74,14 +96,14 @@ export function createWebSendApi(params: { }, } as AnyMessageContent); recordChannelActivity({ - channel: "whatsapp", + channel: 'whatsapp', accountId: params.defaultAccountId, - direction: "outbound", + direction: 'outbound', }); const messageId = - typeof result === "object" && result && "key" in result - ? String((result as { key?: { id?: string } }).key?.id ?? "unknown") - : "unknown"; + typeof result === 'object' && result && 'key' in result + ? String((result as { key?: { id?: string } }).key?.id ?? 'unknown') + : 'unknown'; return { messageId }; }, sendReaction: async ( @@ -89,7 +111,7 @@ export function createWebSendApi(params: { messageId: string, emoji: string, fromMe: boolean, - participant?: string, + participant?: string ): Promise => { const jid = toWhatsappJid(chatJid); await params.sock.sendMessage(jid, { @@ -106,7 +128,7 @@ export function createWebSendApi(params: { }, sendComposingTo: async (to: string): Promise => { const jid = toWhatsappJid(to); - await params.sock.sendPresenceUpdate("composing", jid); + await params.sock.sendPresenceUpdate('composing', jid); }, } as const; } diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index ffb3e8538..3ab138b68 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -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 { setActiveWebListener } from "./active-listener.js"; +import { resetLogger, setLoggerOverride } from '../logging.js'; +import { setActiveWebListener } from './active-listener.js'; const loadWebMediaMock = vi.fn(); -vi.mock("./media.js", () => ({ +vi.mock('./media.js', () => ({ 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 sendMessage = vi.fn(async () => ({ messageId: "msg123" })); - const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); + const sendMessage = vi.fn(async () => ({ messageId: 'msg123' })); + const sendPoll = vi.fn(async () => ({ messageId: 'poll123' })); const sendReaction = vi.fn(async () => {}); beforeEach(() => { @@ -32,137 +36,197 @@ describe("web outbound", () => { setActiveWebListener(null); }); - it("sends message via active listener", async () => { - const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false }); + it('sends message via active listener', async () => { + const result = await sendMessageWhatsApp('+1555', 'hi', { verbose: false }); expect(result).toEqual({ - messageId: "msg123", - toJid: "1555@s.whatsapp.net", + messageId: 'msg123', + toJid: '1555@s.whatsapp.net', }); - expect(sendComposingTo).toHaveBeenCalledWith("+1555"); - expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined); + expect(sendComposingTo).toHaveBeenCalledWith('+1555'); + 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); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' }) ).rejects.toThrow(/No active WhatsApp Web listener/); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' }) ).rejects.toThrow(/channels login/); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp('+1555', 'hi', { verbose: false, accountId: 'work' }) ).rejects.toThrow(/account: work/); }); - it("maps audio to PTT with opus mime when ogg", async () => { - const buf = Buffer.from("audio"); + it('maps audio to PTT with opus mime when ogg', async () => { + const buf = Buffer.from('audio'); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, - contentType: "audio/ogg", - kind: "audio", + contentType: 'audio/ogg', + kind: 'audio', }); - await sendMessageWhatsApp("+1555", "voice note", { + await sendMessageWhatsApp('+1555', 'voice note', { verbose: false, - mediaUrl: "/tmp/voice.ogg", + mediaUrl: '/tmp/voice.ogg', }); expect(sendMessage).toHaveBeenLastCalledWith( - "+1555", - "voice note", + '+1555', + 'voice note', buf, - "audio/ogg; codecs=opus", + 'audio/ogg; codecs=opus', + undefined, + undefined ); }); - it("maps video with caption", async () => { - const buf = Buffer.from("video"); + it('maps video with caption', async () => { + const buf = Buffer.from('video'); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, - contentType: "video/mp4", - kind: "video", + contentType: 'video/mp4', + kind: 'video', }); - await sendMessageWhatsApp("+1555", "clip", { + await sendMessageWhatsApp('+1555', 'clip', { 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 () => { - const buf = Buffer.from("gifvid"); + it('marks gif playback for video when requested', async () => { + const buf = Buffer.from('gifvid'); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, - contentType: "video/mp4", - kind: "video", + contentType: 'video/mp4', + kind: 'video', }); - await sendMessageWhatsApp("+1555", "gif", { + await sendMessageWhatsApp('+1555', 'gif', { verbose: false, - mediaUrl: "/tmp/anim.mp4", - gifPlayback: true, - }); - expect(sendMessage).toHaveBeenLastCalledWith("+1555", "gif", buf, "video/mp4", { + mediaUrl: '/tmp/anim.mp4', gifPlayback: true, }); + expect(sendMessage).toHaveBeenLastCalledWith( + '+1555', + 'gif', + buf, + 'video/mp4', + { + gifPlayback: true, + accountId: undefined, + }, + undefined + ); }); - it("maps image with caption", async () => { - const buf = Buffer.from("img"); + it('maps image with caption', async () => { + const buf = Buffer.from('img'); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, - contentType: "image/jpeg", - kind: "image", + contentType: 'image/jpeg', + kind: 'image', }); - await sendMessageWhatsApp("+1555", "pic", { + await sendMessageWhatsApp('+1555', 'pic', { 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 () => { - const buf = Buffer.from("pdf"); + it('maps other kinds to document with filename', async () => { + const buf = Buffer.from('pdf'); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, - contentType: "application/pdf", - kind: "document", - fileName: "file.pdf", + contentType: 'application/pdf', + kind: 'document', + fileName: 'file.pdf', }); - await sendMessageWhatsApp("+1555", "doc", { + await sendMessageWhatsApp('+1555', 'doc', { 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( - "+1555", - { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 }, - { verbose: false }, + '+1555', + { question: 'Lunch?', options: ['Pizza', 'Sushi'], maxSelections: 2 }, + { verbose: false } ); expect(result).toEqual({ - messageId: "poll123", - toJid: "1555@s.whatsapp.net", + messageId: 'poll123', + toJid: '1555@s.whatsapp.net', }); - expect(sendPoll).toHaveBeenCalledWith("+1555", { - question: "Lunch?", - options: ["Pizza", "Sushi"], + expect(sendPoll).toHaveBeenCalledWith('+1555', { + question: 'Lunch?', + options: ['Pizza', 'Sushi'], maxSelections: 2, durationHours: undefined, }); }); - it("sends reactions via active listener", async () => { - await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { + it('sends reactions via active listener', async () => { + await sendReactionWhatsApp('1555@s.whatsapp.net', 'msg123', '✅', { verbose: false, fromMe: false, }); expect(sendReaction).toHaveBeenCalledWith( - "1555@s.whatsapp.net", - "msg123", - "✅", + '1555@s.whatsapp.net', + 'msg123', + '✅', false, - undefined, + undefined ); }); }); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 0ca867961..c235479ab 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,16 +1,21 @@ -import { randomUUID } from "node:crypto"; +import { randomUUID } from 'node:crypto'; -import { getChildLogger } from "../logging/logger.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizePollInput, type PollInput } from "../polls.js"; -import { toWhatsappJid } from "../utils.js"; -import { loadConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { convertMarkdownTables } from "../markdown/tables.js"; -import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; -import { loadWebMedia } from "./media.js"; +import { loadConfig } from '../config/config.js'; +import { resolveMarkdownTableMode } from '../config/markdown-tables.js'; +import { getChildLogger } from '../logging/logger.js'; +import { createSubsystemLogger } from '../logging/subsystem.js'; +import { convertMarkdownTables } from '../markdown/tables.js'; +import { normalizePollInput, type PollInput } from '../polls.js'; +import { toWhatsappJid } from '../utils.js'; +import { + 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( to: string, @@ -20,23 +25,29 @@ export async function sendMessageWhatsApp( mediaUrl?: string; gifPlayback?: boolean; accountId?: string; - }, + location?: { + latitude: number; + longitude: number; + name?: string; + address?: string; + accuracy?: number; + }; + } ): Promise<{ messageId: string; toJid: string }> { let text = body; const correlationId = randomUUID(); const startedAt = Date.now(); - const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( - options.accountId, - ); + const { listener: active, accountId: resolvedAccountId } = + requireActiveWebListener(options.accountId); const cfg = loadConfig(); const tableMode = resolveMarkdownTableMode({ cfg, - channel: "whatsapp", + channel: 'whatsapp', accountId: resolvedAccountId ?? options.accountId, }); - text = convertMarkdownTables(text ?? "", tableMode); + text = convertMarkdownTables(text ?? '', tableMode); const logger = getChildLogger({ - module: "web-outbound", + module: 'web-outbound', correlationId, to, }); @@ -49,22 +60,28 @@ export async function sendMessageWhatsApp( const caption = text || undefined; mediaBuffer = media.buffer; mediaType = media.contentType; - if (media.kind === "audio") { + if (media.kind === 'audio') { // WhatsApp expects explicit opus codec for PTT voice notes. mediaType = - media.contentType === "audio/ogg" - ? "audio/ogg; codecs=opus" - : (media.contentType ?? "application/octet-stream"); - } else if (media.kind === "video") { - text = caption ?? ""; - } else if (media.kind === "image") { - text = caption ?? ""; + media.contentType === 'audio/ogg' + ? 'audio/ogg; codecs=opus' + : (media.contentType ?? 'application/octet-stream'); + } else if (media.kind === 'video') { + text = caption ?? ''; + } else if (media.kind === 'image') { + text = caption ?? ''; } else { - text = caption ?? ""; + text = caption ?? ''; } } - outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); - logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); + const hasLocation = Boolean(options.location); + outboundLog.info( + `Sending message -> ${jid}${options.mediaUrl ? ' (media)' : ''}${hasLocation ? ' (location)' : ''}` + ); + logger.info( + { jid, hasMedia: Boolean(options.mediaUrl), hasLocation }, + 'sending message' + ); await active.sendComposingTo(to); const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; @@ -76,19 +93,34 @@ export async function sendMessageWhatsApp( } : undefined; const result = sendOptions - ? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions) - : await active.sendMessage(to, text, mediaBuffer, mediaType); - const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; + ? await active.sendMessage( + to, + 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; 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 }; } catch (err) { logger.error( { err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, - "failed to send via web session", + 'failed to send via web session' ); throw err; } @@ -103,12 +135,12 @@ export async function sendReactionWhatsApp( fromMe?: boolean; participant?: string; accountId?: string; - }, + } ): Promise { const correlationId = randomUUID(); const { listener: active } = requireActiveWebListener(options.accountId); const logger = getChildLogger({ - module: "web-outbound", + module: 'web-outbound', correlationId, chatJid, messageId, @@ -116,20 +148,20 @@ export async function sendReactionWhatsApp( try { const jid = toWhatsappJid(chatJid); 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( chatJid, messageId, emoji, options.fromMe ?? false, - options.participant, + options.participant ); 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) { logger.error( { err: String(err), chatJid, messageId, emoji }, - "failed to send reaction via web session", + 'failed to send reaction via web session' ); throw err; } @@ -138,13 +170,13 @@ export async function sendReactionWhatsApp( export async function sendPollWhatsApp( to: string, poll: PollInput, - options: { verbose: boolean; accountId?: string }, + options: { verbose: boolean; accountId?: string } ): Promise<{ messageId: string; toJid: string }> { const correlationId = randomUUID(); const startedAt = Date.now(); const { listener: active } = requireActiveWebListener(options.accountId); const logger = getChildLogger({ - module: "web-outbound", + module: 'web-outbound', correlationId, to, }); @@ -159,18 +191,19 @@ export async function sendPollWhatsApp( optionCount: normalized.options.length, maxSelections: normalized.maxSelections, }, - "sending poll", + 'sending poll' ); 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; outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); - logger.info({ jid, messageId }, "sent poll"); + logger.info({ jid, messageId }, 'sent poll'); return { messageId, toJid: jid }; } catch (err) { logger.error( { err: String(err), to, question: poll.question }, - "failed to send poll via web session", + 'failed to send poll via web session' ); throw err; }