| import { randomUUID } from "node:crypto"; |
|
|
| import { getChildLogger } from "../logging/logger.js"; |
| import { createSubsystemLogger } from "../logging/subsystem.js"; |
| import { normalizePollInput, type PollInput } from "../polls.js"; |
| import { toWhatsappJid } from "../utils.js"; |
| import { loadConfig } from "../config/config.js"; |
| import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; |
| import { convertMarkdownTables } from "../markdown/tables.js"; |
| import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; |
| import { loadWebMedia } from "./media.js"; |
|
|
| const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound"); |
|
|
| export async function sendMessageWhatsApp( |
| to: string, |
| body: string, |
| options: { |
| verbose: boolean; |
| mediaUrl?: string; |
| gifPlayback?: boolean; |
| accountId?: string; |
| }, |
| ): Promise<{ messageId: string; toJid: string }> { |
| let text = body; |
| const correlationId = randomUUID(); |
| const startedAt = Date.now(); |
| const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( |
| options.accountId, |
| ); |
| const cfg = loadConfig(); |
| const tableMode = resolveMarkdownTableMode({ |
| cfg, |
| channel: "whatsapp", |
| accountId: resolvedAccountId ?? options.accountId, |
| }); |
| text = convertMarkdownTables(text ?? "", tableMode); |
| const logger = getChildLogger({ |
| module: "web-outbound", |
| correlationId, |
| to, |
| }); |
| try { |
| const jid = toWhatsappJid(to); |
| let mediaBuffer: Buffer | undefined; |
| let mediaType: string | undefined; |
| if (options.mediaUrl) { |
| const media = await loadWebMedia(options.mediaUrl); |
| const caption = text || undefined; |
| mediaBuffer = media.buffer; |
| mediaType = media.contentType; |
| if (media.kind === "audio") { |
| |
| 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 ?? ""; |
| } else { |
| text = caption ?? ""; |
| } |
| } |
| outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); |
| logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); |
| await active.sendComposingTo(to); |
| const hasExplicitAccountId = Boolean(options.accountId?.trim()); |
| const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; |
| const sendOptions: ActiveWebSendOptions | undefined = |
| options.gifPlayback || accountId |
| ? { |
| ...(options.gifPlayback ? { gifPlayback: true } : {}), |
| accountId, |
| } |
| : undefined; |
| const result = sendOptions |
| ? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions) |
| : await active.sendMessage(to, text, mediaBuffer, mediaType); |
| const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; |
| const durationMs = Date.now() - startedAt; |
| outboundLog.info( |
| `Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, |
| ); |
| logger.info({ jid, messageId }, "sent message"); |
| return { messageId, toJid: jid }; |
| } catch (err) { |
| logger.error( |
| { err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, |
| "failed to send via web session", |
| ); |
| throw err; |
| } |
| } |
|
|
| export async function sendReactionWhatsApp( |
| chatJid: string, |
| messageId: string, |
| emoji: string, |
| options: { |
| verbose: boolean; |
| fromMe?: boolean; |
| participant?: string; |
| accountId?: string; |
| }, |
| ): Promise<void> { |
| const correlationId = randomUUID(); |
| const { listener: active } = requireActiveWebListener(options.accountId); |
| const logger = getChildLogger({ |
| module: "web-outbound", |
| correlationId, |
| chatJid, |
| messageId, |
| }); |
| try { |
| const jid = toWhatsappJid(chatJid); |
| outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); |
| logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); |
| await active.sendReaction( |
| chatJid, |
| messageId, |
| emoji, |
| options.fromMe ?? false, |
| options.participant, |
| ); |
| outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); |
| 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", |
| ); |
| throw err; |
| } |
| } |
|
|
| export async function sendPollWhatsApp( |
| to: string, |
| poll: PollInput, |
| 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", |
| correlationId, |
| to, |
| }); |
| try { |
| const jid = toWhatsappJid(to); |
| const normalized = normalizePollInput(poll, { maxOptions: 12 }); |
| outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); |
| logger.info( |
| { |
| jid, |
| question: normalized.question, |
| optionCount: normalized.options.length, |
| maxSelections: normalized.maxSelections, |
| }, |
| "sending poll", |
| ); |
| const result = await active.sendPoll(to, normalized); |
| 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"); |
| return { messageId, toJid: jid }; |
| } catch (err) { |
| logger.error( |
| { err: String(err), to, question: poll.question }, |
| "failed to send poll via web session", |
| ); |
| throw err; |
| } |
| } |
|
|