import { sendInteractiveButtons, sendInteractiveList, sendTextMessage, type InteractiveButton, type InteractiveListSection, } from '@/lib/whatsapp/meta-api' import { decrypt } from '@/lib/whatsapp/encryption' import { sanitizePhoneForMeta, isValidE164, phoneVariants, isRecipientNotAllowedError, } from '@/lib/whatsapp/phone-utils' import { supabaseAdmin } from './admin-client' // ------------------------------------------------------------ // Flows-side Meta sender (interactive variants). // // Mirrors src/lib/automations/meta-send.ts (engineSendText / // engineSendTemplate) but emits interactive button + list messages. // Kept separate from the automations file so the two engines don't // fight over each other's shape — once both stabilize, the // phone-variant retry + DB persistence are obvious extraction // candidates into a shared base. // // PR #1 ships this in isolation: callers don't exist yet. PR #2 // brings the flow runner online and wires it up. Shipping it now // keeps the foundation PR self-contained and unit-testable. // ------------------------------------------------------------ interface SendTextEngineArgs { userId: string conversationId: string contactId: string text: string } /** * Send a plain-text WhatsApp message from the Flows engine. * * Used by the runner's `send_message` and `collect_input` nodes — * both prompt the customer with text and either auto-advance (the * send_message case) or suspend awaiting a text reply (collect_input). * * Wraps the same phone-variant retry + DB persistence pattern as the * interactive senders; the duplication will be DRY'd into a shared * `engineSendBase` once the v2 features (templates with variables, * media sends) settle. */ export async function engineSendText( args: SendTextEngineArgs, ): Promise<{ whatsapp_message_id: string }> { const db = supabaseAdmin() const { data: contact, error: contactErr } = await db .from('contacts') .select('id, phone') .eq('id', args.contactId) .eq('user_id', args.userId) .maybeSingle() if (contactErr || !contact?.phone) { throw new Error('contact not found for this user') } const sanitized = sanitizePhoneForMeta(contact.phone) if (!isValidE164(sanitized)) { throw new Error(`contact phone invalid: ${contact.phone}`) } const { data: config, error: configErr } = await db .from('whatsapp_config') .select('*') .eq('user_id', args.userId) .single() if (configErr || !config) { throw new Error('WhatsApp not configured for this account') } const accessToken = decrypt(config.access_token) const attempt = async (phone: string): Promise => { const r = await sendTextMessage({ phoneNumberId: config.phone_number_id, accessToken, to: phone, text: args.text, }) return r.messageId } const variants = phoneVariants(sanitized) let workingPhone = sanitized let waMessageId = '' let lastError: unknown = null for (const v of variants) { try { waMessageId = await attempt(v) workingPhone = v lastError = null break } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (!isRecipientNotAllowedError(msg)) throw err lastError = err } } if (lastError) throw lastError if (workingPhone !== sanitized) { await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id) } const { error: msgErr } = await db.from('messages').insert({ conversation_id: args.conversationId, sender_type: 'bot', content_type: 'text', content_text: args.text, message_id: waMessageId, status: 'sent', }) if (msgErr) { throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`) } await db .from('conversations') .update({ last_message_text: args.text, last_message_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq('id', args.conversationId) return { whatsapp_message_id: waMessageId } } interface SendInteractiveButtonsEngineArgs { userId: string conversationId: string contactId: string bodyText: string buttons: InteractiveButton[] headerText?: string footerText?: string } interface SendInteractiveListEngineArgs { userId: string conversationId: string contactId: string bodyText: string buttonLabel: string sections: InteractiveListSection[] headerText?: string footerText?: string } /** * Send an interactive-button WhatsApp message from the Flows engine. * * Persists the outgoing message to `messages` with * `content_type='interactive'` and `sender_type='bot'` so the inbox * surfaces it with the "Button reply" affordance and the conversation * thread reflects the bot's prompt. * * Returns the Meta message id so the caller (engine) can stash it on * the `flow_runs.last_prompt_message_id` field for later reference. */ export async function engineSendInteractiveButtons( args: SendInteractiveButtonsEngineArgs, ): Promise<{ whatsapp_message_id: string }> { return sendInteractiveViaMeta({ ...args, kind: 'buttons' }) } /** * Send an interactive-list WhatsApp message from the Flows engine. * Used when the flow needs more than 3 options (Meta's button cap). */ export async function engineSendInteractiveList( args: SendInteractiveListEngineArgs, ): Promise<{ whatsapp_message_id: string }> { return sendInteractiveViaMeta({ ...args, kind: 'list' }) } type SendInput = | (SendInteractiveButtonsEngineArgs & { kind: 'buttons' }) | (SendInteractiveListEngineArgs & { kind: 'list' }) async function sendInteractiveViaMeta( input: SendInput, ): Promise<{ whatsapp_message_id: string }> { const db = supabaseAdmin() // Scope the contact lookup by user_id — same defense-in-depth // rationale as automations/meta-send.ts. Service-role client // bypasses RLS, so an attacker who could call into the engine // with a contact_id from another tenant would otherwise send // through their own WhatsApp config to a stranger's number. const { data: contact, error: contactErr } = await db .from('contacts') .select('id, phone') .eq('id', input.contactId) .eq('user_id', input.userId) .maybeSingle() if (contactErr || !contact?.phone) { throw new Error('contact not found for this user') } const sanitized = sanitizePhoneForMeta(contact.phone) if (!isValidE164(sanitized)) { throw new Error(`contact phone invalid: ${contact.phone}`) } const { data: config, error: configErr } = await db .from('whatsapp_config') .select('*') .eq('user_id', input.userId) .single() if (configErr || !config) { throw new Error('WhatsApp not configured for this account') } const accessToken = decrypt(config.access_token) const attempt = async (phone: string): Promise => { if (input.kind === 'buttons') { const r = await sendInteractiveButtons({ phoneNumberId: config.phone_number_id, accessToken, to: phone, bodyText: input.bodyText, buttons: input.buttons, headerText: input.headerText, footerText: input.footerText, }) return r.messageId } const r = await sendInteractiveList({ phoneNumberId: config.phone_number_id, accessToken, to: phone, bodyText: input.bodyText, buttonLabel: input.buttonLabel, sections: input.sections, headerText: input.headerText, footerText: input.footerText, }) return r.messageId } // Same phone-variant retry as automations/meta-send.ts. Numbers // registered with/without a trunk 0 + Meta's sandbox quirks all // need this to reliably land a message. const variants = phoneVariants(sanitized) let workingPhone = sanitized let waMessageId = '' let lastError: unknown = null for (const v of variants) { try { waMessageId = await attempt(v) workingPhone = v lastError = null break } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (!isRecipientNotAllowedError(msg)) throw err lastError = err } } if (lastError) throw lastError if (workingPhone !== sanitized) { await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id) } // Persist the bot's prompt to the messages table so it appears in // the inbox. content_type='interactive' is supported as of // migration 010; sender_type='bot' distinguishes flow sends from // manual agent sends (the conversation list preview will pick up // last_message_text as a sensible summary). // // We do NOT set interactive_reply_id here — that column is reserved // for the customer's tap on this message, populated by the webhook // when their reply arrives. const { error: msgErr } = await db.from('messages').insert({ conversation_id: input.conversationId, sender_type: 'bot', content_type: 'interactive', content_text: input.bodyText, message_id: waMessageId, status: 'sent', }) if (msgErr) { throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`) } await db .from('conversations') .update({ last_message_text: input.bodyText, last_message_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq('id', input.conversationId) return { whatsapp_message_id: waMessageId } }