From 4001f2f41e84be16ec71b9e791d337756d9427ac Mon Sep 17 00:00:00 2001 From: ANDRES FELIPE CARDONA SANTA Date: Mon, 26 Jan 2026 12:30:49 -0500 Subject: [PATCH] 2214 chore: format whatsapp location changes --- src/agents/tools/message-tool.ts | 181 ++++---- src/cli/program/message/register.send.ts | 76 ++-- src/infra/outbound/deliver.ts | 133 +++--- src/infra/outbound/message-action-runner.ts | 454 ++++++++------------ src/infra/outbound/message.ts | 70 ++- src/infra/outbound/outbound-send-service.ts | 50 +-- src/web/active-listener.ts | 24 +- src/web/inbound/send-api.ts | 49 +-- src/web/outbound.test.ts | 190 ++++---- src/web/outbound.ts | 117 ++--- 10 files changed, 561 insertions(+), 783 deletions(-) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 857911ee0..e51dec5d2 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,73 +1,57 @@ -import { Type } from '@sinclair/typebox'; -import { BLUEBUBBLES_GROUP_ACTIONS } from '../../channels/plugins/bluebubbles-actions.js'; +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 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'; +} 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).', - }) + 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()), @@ -82,30 +66,29 @@ function buildSendSchema(options: { latitude: Type.Optional( Type.Number({ description: - 'Latitude for location message (required with longitude for native WhatsApp location pin).', - }) + "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).', - }) + "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').", - }) + description: "Optional name for location message (e.g., 'Home', 'Office').", + }), ), locationAddress: Type.Optional( Type.String({ - description: 'Optional address text for location message.', - }) + description: "Optional address text for location message.", + }), ), locationAccuracy: Type.Optional( Type.Number({ - description: 'Optional accuracy in meters for location message.', - }) + description: "Optional accuracy in meters for location message.", + }), ), buttons: Type.Optional( Type.Array( @@ -113,23 +96,21 @@ function buildSendSchema(options: { 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; @@ -172,13 +153,11 @@ function buildChannelTargetSchema() { return { channelId: Type.Optional( Type.String({ - description: 'Channel id filter (search/thread list/event create).', - }) + 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()), @@ -248,17 +227,13 @@ 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), @@ -277,7 +252,7 @@ function buildMessageToolSchemaProps(options: { function buildMessageToolSchemaFromActions( actions: readonly string[], - options: { includeButtons: boolean; includeCards: boolean } + options: { includeButtons: boolean; includeCards: boolean }, ) { const props = buildMessageToolSchemaProps(options); return Type.Object({ @@ -298,7 +273,7 @@ type MessageToolOptions = { currentChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; - replyToMode?: 'off' | 'first' | 'all'; + replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; }; @@ -306,13 +281,10 @@ 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 { @@ -327,21 +299,19 @@ 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?: { @@ -349,8 +319,7 @@ 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) { @@ -364,8 +333,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}.`; } } @@ -374,7 +343,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(", ")}.`; } } @@ -383,9 +352,7 @@ 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, @@ -393,34 +360,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, }; diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 9784b5275..3c6000bb2 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -1,66 +1,50 @@ -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( - '--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 + "--longitude ", + "Longitude for location message (required with --latitude for native WhatsApp location pin)", + (value: string) => Number(value), ) .option( - '--latitude ', - 'Latitude for location message (required with --longitude for native WhatsApp location pin)', - (value: string) => Number(value) + "--location-name ", + "Optional name for location message (e.g., 'Home', 'Office')", ) + .option("--location-address ", "Optional address text for location message") .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) - ) + "--location-accuracy ", + "Optional accuracy in meters for location message", + (value: string) => Number(value), + ), ) .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 334b59dbf..bb09fbf71 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -3,33 +3,30 @@ 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'; +} 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 { 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'; +} 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 { normalizeOutboundPayloads } from './payloads.js'; -export type { NormalizedOutboundPayload } from './payloads.js'; +export { normalizeOutboundPayloads } from "./payloads.js"; +export type { NormalizedOutboundPayload } from "./payloads.js"; type SendMatrixMessage = ( to: string, @@ -39,7 +36,7 @@ type SendMatrixMessage = ( replyToId?: string; threadId?: string; timeoutMs?: number; - } + }, ) => Promise<{ messageId: string; roomId: string }>; export type OutboundSendDeps = { @@ -53,12 +50,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; @@ -75,26 +72,23 @@ 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; @@ -126,7 +120,7 @@ async function createChannelHandler(params: { function createPluginHandler(params: { outbound?: ChannelOutboundAdapter; cfg: ClawdbotConfig; - channel: Exclude; + channel: Exclude; to: string; accountId?: string; replyToId?: string | null; @@ -149,7 +143,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, @@ -187,7 +181,7 @@ function createPluginHandler(params: { export async function deliverOutboundPayloads(params: { cfg: ClawdbotConfig; - channel: Exclude; + channel: Exclude; to: string; accountId?: string; payloads: ReplyPayload[]; @@ -234,13 +228,11 @@ 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, @@ -257,11 +249,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); @@ -282,17 +274,14 @@ 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, })), }; @@ -319,23 +308,19 @@ 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, })), }; @@ -343,13 +328,12 @@ export async function deliverOutboundPayloads(params: { const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads); // Special handling for WhatsApp location messages - if (channel === 'whatsapp' && params.location && deps?.sendWhatsApp) { + if (channel === "whatsapp" && params.location && deps?.sendWhatsApp) { const firstPayload = normalizedPayloads[0]; - const hasMedia = - (firstPayload?.mediaUrls?.length ?? 0) > 0 || firstPayload?.mediaUrl; + 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 ?? ''; + const text = firstPayload?.text ?? ""; try { throwIfAborted(abortSignal); const result = await deps.sendWhatsApp(to, text, { @@ -358,7 +342,7 @@ export async function deliverOutboundPayloads(params: { location: params.location, }); results.push({ - channel: 'whatsapp', + channel: "whatsapp", ...result, }); // If there are more payloads, continue processing them @@ -366,10 +350,8 @@ export async function deliverOutboundPayloads(params: { const remainingPayloads = normalizedPayloads.slice(1); for (const payload of remainingPayloads) { 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 { @@ -390,7 +372,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)); @@ -421,7 +403,7 @@ export async function deliverOutboundPayloads(params: { } catch (err) { if (!params.bestEffort) throw err; params.onError?.(err, { - text: '', + text: "", mediaUrls: [], channelData: undefined, }); @@ -432,9 +414,8 @@ export async function deliverOutboundPayloads(params: { 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 { @@ -456,7 +437,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 574d21c6c..7b13e72b2 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,60 +1,48 @@ -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 { resolveSessionAgentId } from '../../agents/agent-scope.js'; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { readNumberParam, readStringArrayParam, readStringParam, -} from '../../agents/tools/common.js'; -import { parseReplyDirectives } from '../../auto-reply/reply/reply-directives.js'; -import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js'; +} from "../../agents/tools/common.js"; +import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; import type { ChannelId, ChannelMessageActionName, ChannelThreadingToolContext, -} from '../../channels/plugins/types.js'; -import type { ClawdbotConfig } from '../../config/config.js'; -import { extensionForMime } from '../../media/mime.js'; -import { parseSlackTarget } from '../../slack/targets.js'; +} from "../../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { extensionForMime } from "../../media/mime.js"; +import { parseSlackTarget } from "../../slack/targets.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type GatewayClientMode, type GatewayClientName, -} from '../../utils/message-channel.js'; -import { loadWebMedia } from '../../web/media.js'; +} from "../../utils/message-channel.js"; +import { loadWebMedia } from "../../web/media.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, -} from './channel-selection.js'; -import { applyTargetToParams } from './channel-target.js'; -import type { OutboundSendDeps } from './deliver.js'; -import { - actionHasTarget, - actionRequiresTarget, -} from './message-action-spec.js'; -import type { MessagePollResult, MessageSendResult } from './message.js'; +} from "./channel-selection.js"; +import { applyTargetToParams } from "./channel-target.js"; +import type { OutboundSendDeps } from "./deliver.js"; +import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; +import type { MessagePollResult, MessageSendResult } from "./message.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, enforceCrossContextPolicy, shouldApplyCrossContextMarker, type CrossContextDecoration, -} from './outbound-policy.js'; -import { - executePollAction, - executeSendAction, -} from './outbound-send-service.js'; -import { - ensureOutboundSessionEntry, - resolveOutboundSessionRoute, -} from './outbound-session.js'; -import { - resolveChannelTarget, - type ResolvedMessagingTarget, -} from './target-resolver.js'; +} 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; @@ -81,21 +69,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; @@ -108,30 +96,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 { @@ -140,9 +128,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; @@ -209,16 +197,13 @@ 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; } @@ -230,14 +215,11 @@ 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; } @@ -247,35 +229,31 @@ 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: { @@ -285,7 +263,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; @@ -301,21 +279,15 @@ 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; @@ -333,17 +305,16 @@ 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, @@ -355,21 +326,17 @@ 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; } @@ -395,24 +362,23 @@ 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', { + readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); + const caption = readStringParam(params.args, "caption", { allowEmpty: true, })?.trim(); - const message = readStringParam(params.args, 'message', { + 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, @@ -424,21 +390,17 @@ 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; } @@ -458,7 +420,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; @@ -467,13 +429,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; @@ -482,15 +444,12 @@ 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, @@ -506,7 +465,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, @@ -522,27 +481,20 @@ 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; } @@ -562,9 +514,7 @@ 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, @@ -578,28 +528,24 @@ function resolveGateway( 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<{ @@ -609,8 +555,7 @@ 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) { @@ -624,7 +569,7 @@ async function handleBroadcastAction( if (!resolved.ok) throw resolved.error; const sendResult = await runMessageAction({ ...input, - action: 'send', + action: "send", params: { ...params, channel: targetChannel, @@ -635,8 +580,7 @@ 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; @@ -650,10 +594,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), }; @@ -661,15 +605,13 @@ 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, @@ -683,19 +625,19 @@ async function handleSendAction( abortSignal, } = ctx; throwIfAborted(abortSignal); - const action: ChannelMessageActionName = 'send'; - const to = readStringParam(params, 'to', { required: true }); + const action: ChannelMessageActionName = "send"; + const to = readStringParam(params, "to", { required: true }); // Support media, path, and filePath parameters for attachments const mediaHint = - readStringParam(params, 'media', { trim: false }) ?? - readStringParam(params, 'path', { trim: false }) ?? - readStringParam(params, 'filePath', { trim: false }); - const hasCard = params.card != null && typeof params.card === 'object'; + readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "path", { trim: false }) ?? + readStringParam(params, "filePath", { trim: false }); + const hasCard = params.card != null && typeof params.card === "object"; let message = - readStringParam(params, 'message', { + readStringParam(params, "message", { required: !mediaHint && !hasCard, allowEmpty: true, - }) ?? ''; + }) ?? ""; const parsed = parseReplyDirectives(message); const mergedMediaUrls: string[] = []; @@ -730,16 +672,16 @@ async function handleSendAction( preferEmbeds: true, }); - const mediaUrl = readStringParam(params, 'media', { trim: false }); - const gifPlayback = readBooleanParam(params, 'gifPlayback') ?? false; - const bestEffort = readBooleanParam(params, 'bestEffort'); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; + const bestEffort = readBooleanParam(params, "bestEffort"); // Extract location parameters - const latitude = readNumberParam(params, 'latitude'); - const longitude = readNumberParam(params, 'longitude'); - const locationName = readStringParam(params, 'locationName'); - const locationAddress = readStringParam(params, 'locationAddress'); - const locationAccuracy = readNumberParam(params, 'locationAccuracy'); + 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: @@ -754,24 +696,15 @@ async function handleSendAction( 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.` - ); + 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.` - ); + 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.` - ); + if (locationAccuracy != null && (!Number.isFinite(locationAccuracy) || locationAccuracy < 0)) { + throw new Error(`Invalid location accuracy: ${locationAccuracy}. Must be a positive number.`); } location = { latitude, @@ -782,16 +715,14 @@ async function handleSendAction( }; } 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.' - ); + throw new Error("Both latitude and longitude are required for location messages."); } - const replyToId = readStringParam(params, 'replyTo'); - const threadId = readStringParam(params, 'threadId'); + 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 = @@ -817,11 +748,7 @@ async function handleSendAction( }); } const mirrorMediaUrls = - mergedMediaUrls.length > 0 - ? mergedMediaUrls - : mediaUrl - ? [mediaUrl] - : undefined; + mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; throwIfAborted(abortSignal); const send = await executeSendAction({ ctx: { @@ -854,7 +781,7 @@ async function handleSendAction( }); return { - kind: 'send', + kind: "send", channel, action, to, @@ -866,36 +793,24 @@ async function handleSendAction( }; } -async function handlePollAction( - 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, @@ -926,7 +841,7 @@ async function handlePollAction( }); return { - kind: 'poll', + kind: "poll", channel, action, to, @@ -938,30 +853,16 @@ async function handlePollAction( }; } -async function handlePluginAction( - 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< - ChannelMessageActionName, - 'send' | 'poll' | 'broadcast' - >; + const action = input.action as Exclude; if (dryRun) { return { - kind: 'action', + kind: "action", channel, action, - handledBy: 'dry-run', + handledBy: "dry-run", payload: { ok: true, dryRun: true, channel, action }, dryRun: true, }; @@ -978,15 +879,13 @@ async function handlePluginAction( dryRun, }); 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 { - kind: 'action', + kind: "action", channel, action, - handledBy: 'plugin', + handledBy: "plugin", payload: extractToolPayload(handled), toolResult: handled, dryRun, @@ -994,7 +893,7 @@ async function handlePluginAction( } export async function runMessageAction( - input: RunMessageActionParams + input: RunMessageActionParams, ): Promise { const cfg = input.cfg; const params = { ...input.params }; @@ -1007,16 +906,14 @@ 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; @@ -1033,9 +930,8 @@ 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; @@ -1043,12 +939,9 @@ 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; } @@ -1062,12 +955,11 @@ 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, @@ -1105,7 +997,7 @@ export async function runMessageAction( const gateway = resolveGateway(input); - if (action === 'send') { + if (action === "send") { return handleSendAction({ cfg, params, @@ -1120,7 +1012,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 e749bfa83..4b3f00c0d 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,28 +1,25 @@ -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; @@ -66,7 +63,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 }; @@ -93,7 +90,7 @@ export type MessagePollResult = { options: string[]; maxSelections: number; durationHours: number | null; - via: 'gateway'; + via: "gateway"; result?: { messageId: string; toJid?: string; @@ -109,7 +106,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, @@ -118,9 +115,7 @@ 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) @@ -132,7 +127,7 @@ export async function sendMessage( if (!plugin) { throw new Error(`Unknown channel: ${channel}`); } - const deliveryMode = plugin.outbound?.deliveryMode ?? 'direct'; + const deliveryMode = plugin.outbound?.deliveryMode ?? "direct"; const normalizedPayloads = normalizeReplyPayloadsForDelivery([ { text: params.content, @@ -143,10 +138,9 @@ export async function sendMessage( const mirrorText = normalizedPayloads .map((payload) => 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; @@ -154,21 +148,21 @@ export async function sendMessage( return { channel, to: params.to, - via: deliveryMode === 'gateway' ? 'gateway' : 'direct', + via: deliveryMode === "gateway" ? "gateway" : "direct", mediaUrl: primaryMediaUrl, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, dryRun: true, }; } - if (deliveryMode !== 'gateway') { - const outboundChannel = channel as Exclude; + 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; @@ -195,7 +189,7 @@ export async function sendMessage( return { channel, to: params.to, - via: 'direct', + via: "direct", mediaUrl: primaryMediaUrl, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, result: results.at(-1), @@ -206,7 +200,7 @@ export async function sendMessage( const result = await callGateway<{ messageId: string }>({ url: gateway.url, token: gateway.token, - method: 'send', + method: "send", params: { to: params.to, message: params.content, @@ -227,16 +221,14 @@ export async function sendMessage( return { channel, to: params.to, - via: 'gateway', + via: "gateway", mediaUrl: primaryMediaUrl, mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined, result, }; } -export async function sendPoll( - params: MessagePollParams -): Promise { +export async function sendPoll(params: MessagePollParams): Promise { const cfg = params.cfg ?? loadConfig(); const channel = params.channel?.trim() ? normalizeChannelId(params.channel) @@ -268,7 +260,7 @@ export async function sendPoll( options: normalized.options, maxSelections: normalized.maxSelections, durationHours: normalized.durationHours ?? null, - via: 'gateway', + via: "gateway", dryRun: true, }; } @@ -283,7 +275,7 @@ export async function sendPoll( }>({ url: gateway.url, token: gateway.token, - method: 'poll', + method: "poll", params: { to: params.to, question: normalized.question, @@ -306,7 +298,7 @@ export async function sendPoll( options: normalized.options, maxSelections: normalized.maxSelections, durationHours: normalized.durationHours ?? null, - via: 'gateway', + via: "gateway", result, }; } diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 9c362999a..866f94c85 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -1,18 +1,12 @@ -import type { AgentToolResult } from '@mariozechner/pi-agent-core'; -import { dispatchChannelMessageAction } from '../../channels/plugins/message-actions.js'; -import type { - ChannelId, - ChannelThreadingToolContext, -} from '../../channels/plugins/types.js'; -import type { ClawdbotConfig } from '../../config/config.js'; -import { appendAssistantMessageToSessionTranscript } from '../../config/sessions.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'; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; +import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; +import type { OutboundSendDeps } from "./deliver.js"; +import type { MessagePollResult, MessageSendResult } from "./message.js"; +import { sendMessage, sendPoll } from "./message.js"; export type OutboundGatewayContext = { url?: string; @@ -47,9 +41,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; @@ -65,8 +59,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; } } @@ -87,7 +81,7 @@ export async function executeSendAction(params: { accuracy?: number; }; }): Promise<{ - handledBy: 'plugin' | 'core'; + handledBy: "plugin" | "core"; payload: unknown; toolResult?: AgentToolResult; sendResult?: MessageSendResult; @@ -96,7 +90,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, @@ -119,7 +113,7 @@ export async function executeSendAction(params: { }); } return { - handledBy: 'plugin', + handledBy: "plugin", payload: extractToolPayload(handled), toolResult: handled, }; @@ -146,7 +140,7 @@ export async function executeSendAction(params: { }); return { - handledBy: 'core', + handledBy: "core", payload: result, sendResult: result, }; @@ -160,7 +154,7 @@ export async function executePollAction(params: { maxSelections: number; durationHours?: number; }): Promise<{ - handledBy: 'plugin' | 'core'; + handledBy: "plugin" | "core"; payload: unknown; toolResult?: AgentToolResult; pollResult?: MessagePollResult; @@ -168,7 +162,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, @@ -178,7 +172,7 @@ export async function executePollAction(params: { }); if (handled) { return { - handledBy: 'plugin', + handledBy: "plugin", payload: extractToolPayload(handled), toolResult: handled, }; @@ -198,7 +192,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 9f3558a23..4c3cd7b7b 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; @@ -20,7 +20,7 @@ export type ActiveWebListener = { name?: string; address?: string; accuracy?: number; - } + }, ) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendReaction: ( @@ -28,7 +28,7 @@ export type ActiveWebListener = { messageId: string, emoji: string, fromMe: boolean, - participant?: string + participant?: string, ) => Promise; sendComposingTo: (to: string) => Promise; close?: () => Promise; @@ -39,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): { @@ -50,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 }; @@ -59,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, @@ -84,9 +84,7 @@ 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 bacf99318..2a1ce69cb 100644 --- a/src/web/inbound/send-api.ts +++ b/src/web/inbound/send-api.ts @@ -1,15 +1,12 @@ -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; }) { @@ -26,7 +23,7 @@ export function createWebSendApi(params: { name?: string; address?: string; accuracy?: number; - } + }, ): Promise<{ messageId: string }> => { const jid = toWhatsappJid(to); let payload: AnyMessageContent; @@ -43,15 +40,15 @@ export function createWebSendApi(params: { }, }; } else if (mediaBuffer && mediaType) { - if (mediaType.startsWith('image/')) { + 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, @@ -62,7 +59,7 @@ export function createWebSendApi(params: { } else { payload = { document: mediaBuffer, - fileName: 'file', + fileName: "file", caption: text || undefined, mimetype: mediaType, }; @@ -73,19 +70,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, { @@ -96,14 +93,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 ( @@ -111,7 +108,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, { @@ -128,7 +125,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 3ab138b68..63ca88f2c 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,23 +1,19 @@ -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(() => { @@ -36,197 +32,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(sendComposingTo).toHaveBeenCalledWith("+1555"); expect(sendMessage).toHaveBeenCalledWith( - '+1555', - 'hi', + "+1555", + "hi", + undefined, 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, - 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', + "+1555", + "clip", buf, - 'video/mp4', + "video/mp4", + undefined, 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', + mediaUrl: "/tmp/anim.mp4", gifPlayback: true, }); expect(sendMessage).toHaveBeenLastCalledWith( - '+1555', - 'gif', + "+1555", + "gif", buf, - 'video/mp4', + "video/mp4", { gifPlayback: true, accountId: undefined, }, - 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', + "+1555", + "pic", buf, - 'image/jpeg', + "image/jpeg", + undefined, 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', + "+1555", + "doc", buf, - 'application/pdf', + "application/pdf", + undefined, undefined, - undefined ); }); - it('sends location via active listener', async () => { + it("sends location via active listener", async () => { const location = { latitude: 33.538368, longitude: -7.760028, - name: 'Home', - address: 'Some Address', + name: "Home", + address: "Some Address", accuracy: 12, }; - await sendMessageWhatsApp('+1555', 'pin', { + await sendMessageWhatsApp("+1555", "pin", { verbose: false, location, }); expect(sendMessage).toHaveBeenLastCalledWith( - '+1555', - 'pin', + "+1555", + "pin", undefined, undefined, undefined, - location + location, ); }); - it('sends polls via active listener', async () => { + 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 c235479ab..437ee836c 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,21 +1,16 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; -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'; +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, @@ -32,22 +27,23 @@ export async function sendMessageWhatsApp( 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, }); @@ -60,28 +56,25 @@ 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 ?? ""; } } 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' + `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; @@ -93,34 +86,19 @@ export async function sendMessageWhatsApp( } : undefined; const result = sendOptions - ? 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'; + ? 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)' : ''}${hasLocation ? ' (location)' : ''} (${durationMs}ms)` + `Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}${hasLocation ? " (location)" : ""} (${durationMs}ms)`, ); - logger.info({ jid, messageId, hasLocation }, '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; } @@ -135,12 +113,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, @@ -148,20 +126,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; } @@ -170,13 +148,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, }); @@ -191,19 +169,18 @@ 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; }