Merge pull request #32 from ArnasDon/fix/automations-actually-send-via-meta

fix(automations): actually send messages via Meta
This commit is contained in:
Arnas Donauskas 2026-04-17 20:37:13 +03:00 committed by GitHub
commit da809ced5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 222 additions and 35 deletions

View File

@ -353,17 +353,6 @@ async function processMessage(
) )
if (!contactOutcome) return if (!contactOutcome) return
const contactRecord = contactOutcome.contact 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 // Find or create conversation
const conversation = await findOrCreateConversation( const conversation = await findOrCreateConversation(
@ -430,11 +419,17 @@ async function processMessage(
// trigger installed in migration 003). // trigger installed in migration 003).
await flagBroadcastReplyIfAny(userId, contactRecord.id) await flagBroadcastReplyIfAny(userId, contactRecord.id)
// Fire any automations that watch inbound messages (new_message_received // Fire any automations that react to this webhook event. All dispatches
// + keyword_match — the engine filters by keyword internally). Kept // run here (not earlier) so the contact, conversation, and inbound
// fire-and-forget so a slow/failing automation never blocks the webhook. // 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 ?? '' 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({ runAutomationsForTrigger({
userId, userId,
triggerType, triggerType,

View File

@ -6,6 +6,7 @@ import type {
ConditionStepConfig, ConditionStepConfig,
KeywordMatchTriggerConfig, KeywordMatchTriggerConfig,
SendMessageStepConfig, SendMessageStepConfig,
SendTemplateStepConfig,
SendWebhookStepConfig, SendWebhookStepConfig,
TagStepConfig, TagStepConfig,
UpdateContactFieldStepConfig, UpdateContactFieldStepConfig,
@ -14,6 +15,7 @@ import type {
AssignConversationStepConfig, AssignConversationStepConfig,
} from '@/types' } from '@/types'
import { supabaseAdmin } from './admin-client' import { supabaseAdmin } from './admin-client'
import { engineSendText, engineSendTemplate } from './meta-send'
// ------------------------------------------------------------ // ------------------------------------------------------------
// Public API // Public API
@ -296,29 +298,38 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
case 'send_message': { case 'send_message': {
const cfg = step.step_config as SendMessageStepConfig const cfg = step.step_config as SendMessageStepConfig
if (!args.contactId) throw new Error('send_message needs a contact') if (!args.contactId) throw new Error('send_message needs a contact')
const { data: conv } = await db const text = interpolate(cfg.text, args)
.from('conversations') if (!text.trim()) throw new Error('send_message has empty text')
.select('id, user_id') const conversationId = await resolveConversationId(args)
.eq('user_id', args.automation.user_id) const { whatsapp_message_id } = await engineSendText({
.eq('contact_id', args.contactId) userId: args.automation.user_id,
.maybeSingle() conversationId,
if (!conv) throw new Error('no conversation for contact') contactId: args.contactId,
await db.from('messages').insert({ text,
conversation_id: conv.id,
sender_type: 'bot',
content_type: 'text',
content_text: interpolate(cfg.text, args),
status: 'sending',
}) })
return 'message queued' return `sent via Meta (${whatsapp_message_id})`
} }
case 'send_template': case 'send_template': {
// Template sending requires Meta API credentials; recorded but const cfg = step.step_config as SendTemplateStepConfig
// not actually dispatched here — the dedicated broadcasts flow if (!args.contactId) throw new Error('send_template needs a contact')
// owns Meta calls. Leave as a noop success so the automation if (!cfg.template_name) throw new Error('send_template needs template_name')
// progresses; real send-via-Meta is tracked as follow-up work. const conversationId = await resolveConversationId(args)
return 'template send recorded' 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': { case 'add_tag': {
const cfg = step.step_config as TagStepConfig const cfg = step.step_config as TagStepConfig
@ -425,6 +436,28 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
// Helpers // 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<string> {
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 { function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean {
if (automation.trigger_type !== 'keyword_match') return true if (automation.trigger_type !== 'keyword_match') return true
const cfg = automation.trigger_config as KeywordMatchTriggerConfig const cfg = automation.trigger_config as KeywordMatchTriggerConfig

View File

@ -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<string> => {
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 }
}