mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
9.3 KiB
TypeScript
309 lines
9.3 KiB
TypeScript
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<string> => {
|
|
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<string> => {
|
|
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 }
|
|
}
|