Spaces:
Paused
Paused
| 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"; | |
| 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") { | |
| // 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 ?? ""; | |
| } 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; | |
| } | |
| } | |