mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
e8c952b129
commit
0e75eb8d11
@ -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,
|
||||
|
||||
@ -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<string>
|
||||
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<string>
|
||||
// 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 {
|
||||
if (automation.trigger_type !== 'keyword_match') return true
|
||||
const cfg = automation.trigger_config as KeywordMatchTriggerConfig
|
||||
|
||||
159
src/lib/automations/meta-send.ts
Normal file
159
src/lib/automations/meta-send.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user