From 0e75eb8d11ea0b08e270f108fb429e922f7c7d22 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 20:34:52 +0300 Subject: [PATCH] fix(automations): actually send messages via Meta send_message and send_template steps were inserting a row into messages with status='sending' but never calling Meta's WhatsApp API, so automation replies appeared in the inbox but never reached the phone. send_template was a plain noop ("template send recorded"). - New helper src/lib/automations/meta-send.ts mirrors the logic in /api/whatsapp/send (decrypt config, phone-variant retry, persist messages row with status='sent' and the real whatsapp message id). Kept as a separate helper to avoid touching the working manual-send route; the two can converge in a later refactor. - Engine's send_message / send_template now await the helper and surface Meta errors through the existing per-step failure path, so a bad token / invalid template shows up in automation_logs with status='failed' instead of silently "sending" forever. - Consolidated automation dispatches in the webhook to fire after the conversation is created. Previously new_contact_created raced the conversation insert, which could have made send_message fail to resolve a target conversation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/whatsapp/webhook/route.ts | 25 ++-- src/lib/automations/engine.ts | 73 ++++++++---- src/lib/automations/meta-send.ts | 159 ++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 src/lib/automations/meta-send.ts diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 0ae975e..f0569fa 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -353,17 +353,6 @@ async function processMessage( ) if (!contactOutcome) return const contactRecord = contactOutcome.contact - // Fire new_contact_created automations for genuinely-new contacts. - // Fire-and-forget — the engine never throws, and webhook latency - // must not be tied to automation execution. - if (contactOutcome.wasCreated) { - runAutomationsForTrigger({ - userId, - triggerType: 'new_contact_created', - contactId: contactRecord.id, - context: { message_text: message.text?.body }, - }).catch((err) => console.error('[automations] dispatch failed:', err)) - } // Find or create conversation const conversation = await findOrCreateConversation( @@ -430,11 +419,17 @@ async function processMessage( // trigger installed in migration 003). await flagBroadcastReplyIfAny(userId, contactRecord.id) - // Fire any automations that watch inbound messages (new_message_received - // + keyword_match — the engine filters by keyword internally). Kept - // fire-and-forget so a slow/failing automation never blocks the webhook. + // Fire any automations that react to this webhook event. All dispatches + // run here (not earlier) so the contact, conversation, and inbound + // message all exist before any step — including send_message — runs. + // Fire-and-forget: a slow or failing automation must not block the + // webhook's 200 OK response to Meta. const inboundText = contentText ?? message.text?.body ?? '' - for (const triggerType of ['new_message_received', 'keyword_match'] as const) { + const automationTriggers: ('new_contact_created' | 'new_message_received' | 'keyword_match')[] = + contactOutcome.wasCreated + ? ['new_contact_created', 'new_message_received', 'keyword_match'] + : ['new_message_received', 'keyword_match'] + for (const triggerType of automationTriggers) { runAutomationsForTrigger({ userId, triggerType, diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.ts index af7673d..1a131fa 100644 --- a/src/lib/automations/engine.ts +++ b/src/lib/automations/engine.ts @@ -6,6 +6,7 @@ import type { ConditionStepConfig, KeywordMatchTriggerConfig, SendMessageStepConfig, + SendTemplateStepConfig, SendWebhookStepConfig, TagStepConfig, UpdateContactFieldStepConfig, @@ -14,6 +15,7 @@ import type { AssignConversationStepConfig, } from '@/types' import { supabaseAdmin } from './admin-client' +import { engineSendText, engineSendTemplate } from './meta-send' // ------------------------------------------------------------ // Public API @@ -296,29 +298,38 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise case 'send_message': { const cfg = step.step_config as SendMessageStepConfig if (!args.contactId) throw new Error('send_message needs a contact') - const { data: conv } = await db - .from('conversations') - .select('id, user_id') - .eq('user_id', args.automation.user_id) - .eq('contact_id', args.contactId) - .maybeSingle() - if (!conv) throw new Error('no conversation for contact') - await db.from('messages').insert({ - conversation_id: conv.id, - sender_type: 'bot', - content_type: 'text', - content_text: interpolate(cfg.text, args), - status: 'sending', + const text = interpolate(cfg.text, args) + if (!text.trim()) throw new Error('send_message has empty text') + const conversationId = await resolveConversationId(args) + const { whatsapp_message_id } = await engineSendText({ + userId: args.automation.user_id, + conversationId, + contactId: args.contactId, + text, }) - return 'message queued' + return `sent via Meta (${whatsapp_message_id})` } - case 'send_template': - // Template sending requires Meta API credentials; recorded but - // not actually dispatched here — the dedicated broadcasts flow - // owns Meta calls. Leave as a noop success so the automation - // progresses; real send-via-Meta is tracked as follow-up work. - return 'template send recorded' + case 'send_template': { + const cfg = step.step_config as SendTemplateStepConfig + if (!args.contactId) throw new Error('send_template needs a contact') + if (!cfg.template_name) throw new Error('send_template needs template_name') + const conversationId = await resolveConversationId(args) + const params = cfg.variables + ? Object.keys(cfg.variables) + .sort() + .map((k) => String(cfg.variables![k])) + : [] + const { whatsapp_message_id } = await engineSendTemplate({ + userId: args.automation.user_id, + conversationId, + contactId: args.contactId, + templateName: cfg.template_name, + language: cfg.language, + params, + }) + return `template sent via Meta (${whatsapp_message_id})` + } case 'add_tag': { const cfg = step.step_config as TagStepConfig @@ -425,6 +436,28 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise // Helpers // ------------------------------------------------------------ +/** + * Pick the conversation a send-type step should use. Prefer the id the + * webhook handed us (it's the one that just got the inbound message); + * fall back to the contact's conversation for resumed/wait paths and + * manual engine POSTs. Throws if none exists — send steps have + * no meaningful target without a conversation. + */ +async function resolveConversationId(args: ExecuteArgs): Promise { + const fromCtx = args.context.conversation_id + if (fromCtx) return fromCtx + if (!args.contactId) throw new Error('cannot resolve conversation: no contact') + const { data, error } = await supabaseAdmin() + .from('conversations') + .select('id') + .eq('user_id', args.automation.user_id) + .eq('contact_id', args.contactId) + .maybeSingle() + if (error) throw new Error(`conversation lookup failed: ${error.message}`) + if (!data?.id) throw new Error('no conversation for contact') + return data.id as string +} + function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean { if (automation.trigger_type !== 'keyword_match') return true const cfg = automation.trigger_config as KeywordMatchTriggerConfig diff --git a/src/lib/automations/meta-send.ts b/src/lib/automations/meta-send.ts new file mode 100644 index 0000000..ffbc9f4 --- /dev/null +++ b/src/lib/automations/meta-send.ts @@ -0,0 +1,159 @@ +import { sendTextMessage, sendTemplateMessage } 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' + +// ------------------------------------------------------------ +// Automation-side Meta sender. +// +// Mirrors the logic in src/app/api/whatsapp/send/route.ts but uses +// the service-role client (engine has no cookies) and accepts the +// user / conversation / contact identifiers the engine already has +// on hand. Kept here (rather than refactoring the user-facing send +// route) to avoid risk to the working manual-send path — they can +// converge in a later refactor. +// ------------------------------------------------------------ + +interface SendTextArgs { + userId: string + conversationId: string + contactId: string + text: string +} + +interface SendTemplateArgs { + userId: string + conversationId: string + contactId: string + templateName: string + language?: string + params?: string[] +} + +export async function engineSendText(args: SendTextArgs): Promise<{ whatsapp_message_id: string }> { + return sendViaMeta({ ...args, kind: 'text' }) +} + +export async function engineSendTemplate( + args: SendTemplateArgs, +): Promise<{ whatsapp_message_id: string }> { + return sendViaMeta({ ...args, kind: 'template' }) +} + +type SendInput = + | (SendTextArgs & { kind: 'text' }) + | (SendTemplateArgs & { kind: 'template' }) + +async function sendViaMeta(input: SendInput): Promise<{ whatsapp_message_id: string }> { + const db = supabaseAdmin() + + const { data: contact, error: contactErr } = await db + .from('contacts') + .select('id, phone') + .eq('id', input.contactId) + .maybeSingle() + if (contactErr || !contact?.phone) { + throw new Error('contact phone missing') + } + + 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 === 'template') { + const r = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, + accessToken, + to: phone, + templateName: input.templateName, + language: input.language, + params: input.params, + }) + return r.messageId + } + const r = await sendTextMessage({ + phoneNumberId: config.phone_number_id, + accessToken, + to: phone, + text: input.text, + }) + return r.messageId + } + + // Same phone-variant retry as /api/whatsapp/send — Meta sandbox and + // numbers registered with/without a trunk 0 both require 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 sent message so it appears in the inbox with a real + // Meta message id. sender_type='bot' distinguishes automation sends + // from manual agent sends. + const content_type = input.kind === 'template' ? 'template' : 'text' + const content_text = input.kind === 'text' ? input.text : null + const template_name = input.kind === 'template' ? input.templateName : null + + const { error: msgErr } = await db.from('messages').insert({ + conversation_id: input.conversationId, + sender_type: 'bot', + content_type, + content_text, + template_name, + message_id: waMessageId, + status: 'sent', + }) + if (msgErr) { + // Meta already has the message; record the DB error but don't pretend + // the send failed. The engine wraps this in a log line. + throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`) + } + + await db + .from('conversations') + .update({ + last_message_text: + input.kind === 'template' ? `[template:${input.templateName}]` : input.text, + last_message_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', input.conversationId) + + return { whatsapp_message_id: waMessageId } +}