mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #32 from ArnasDon/fix/automations-actually-send-via-meta
fix(automations): actually send messages via Meta
This commit is contained in:
commit
da809ced5f
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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