wacrm/src/lib/automations/meta-send.ts
Arnas Donauskas 78b42dfba9 feat: WhatsApp interactive messages (reply buttons + lists)
Add full support for WhatsApp Cloud API interactive messages across the
inbox, automations, and a new quick-replies feature. Extends the
interactive plumbing that previously existed only for the Flows engine.

Sending
- send-message core handles `interactive` (reply buttons ≤3, list ≤10
  rows) via the existing Meta senders; validated server-side before send.
- Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style
  preview + Meta char limits (button 20, list row 24, body 1024).
- Automation "Send Buttons" / "Send List" steps in the builder.
- Public v1 /messages accepts type=interactive.

Storage / round-trip
- migration 035: messages.interactive_payload JSONB + quick_replies table.
- Outbound interactive persists the structured payload (composer,
  automations, and Flows) so the thread re-renders the buttons/rows.

Receiving
- Bubble renders outbound interactive via the shared preview; inbound taps
  (button_reply/list_reply) keep the "chosen option" affordance.

Automations
- New `interactive_reply` trigger matches on tapped button/list IDs
  (exact), enabling chained multi-step menus.

Quick replies
- New account-scoped quick_replies (text or interactive snippets), managed
  in Settings and insertable from the composer; "Save as quick reply" in
  the builder.

Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/.
Includes unit tests for the validator, send-param validation, the
interactive_reply trigger match, and the new automation step/trigger
validation. typecheck + lint clean; full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:03:15 +03:00

225 lines
7.2 KiB
TypeScript

import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive'
import {
engineSendInteractiveButtons,
engineSendInteractiveList,
} from '@/lib/flows/meta-send'
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 {
/** Account-level tenancy key. Drives contact + whatsapp_config
* lookups so an automation authored by user A still sends through
* the WhatsApp number user B saved on the same account. */
accountId: string
/** Original author of the automation/flow — used for INSERT audit
* columns (messages.sender_id-ish) and for resolving the agent's
* identity in logs. Not consulted for tenancy. */
userId: string
conversationId: string
contactId: string
text: string
}
interface SendTemplateArgs {
accountId: string
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' })
}
interface SendInteractiveArgs {
accountId: string
userId: string
conversationId: string
contactId: string
payload: InteractiveMessagePayload
}
/**
* Send an interactive (reply-buttons or list) message from the
* automation engine.
*
* Delegates to the Flows interactive senders
* (`engineSendInteractiveButtons` / `engineSendInteractiveList`), which
* already own the account-scoped lookup, phone-variant retry, and the
* `messages` insert with `interactive_payload` + `sender_type='bot'`.
* Both engines want identical behaviour here, so there's one
* implementation rather than a second hand-rolled copy that could drift.
*/
export async function engineSendInteractive(
args: SendInteractiveArgs,
): Promise<{ whatsapp_message_id: string }> {
const { payload, accountId, userId, conversationId, contactId } = args
const common = { accountId, userId, conversationId, contactId }
if (payload.kind === 'buttons') {
return engineSendInteractiveButtons({
...common,
bodyText: payload.body,
headerText: payload.header,
footerText: payload.footer,
buttons: payload.buttons,
})
}
return engineSendInteractiveList({
...common,
bodyText: payload.body,
buttonLabel: payload.button_label,
headerText: payload.header,
footerText: payload.footer,
sections: payload.sections,
})
}
type SendInput =
| (SendTextArgs & { kind: 'text' })
| (SendTemplateArgs & { kind: 'template' })
async function sendViaMeta(input: SendInput): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
// Scope the contact + config lookups by account_id, not user_id.
// The engine uses the service-role client (bypassing RLS); without
// this filter, an authenticated user could fire their own
// automations against another tenant's contact UUID and send via
// their own WhatsApp config to that contact's phone. The 017
// migration moved both tables to account-scoped tenancy, so the
// check is the same defense-in-depth as before, just keyed on the
// new tenancy column.
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', input.contactId)
.eq('account_id', input.accountId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this account')
}
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('account_id', input.accountId)
.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 }
}