mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
517 lines
15 KiB
TypeScript
517 lines
15 KiB
TypeScript
// ============================================================
|
|
// Outbound message send — the core that both the dashboard's
|
|
// `/api/whatsapp/send` route and the public `/api/v1/messages`
|
|
// endpoint call.
|
|
//
|
|
// Given a conversation and message params, this:
|
|
// 1. validates the params for the message type,
|
|
// 2. loads the conversation + contact + WhatsApp config,
|
|
// 3. sends to Meta (with phone-variant retry + contact auto-fix),
|
|
// 4. persists the message + updates the conversation,
|
|
// 5. pauses any active Flow run for the contact (agent stepped in).
|
|
//
|
|
// It is transport-agnostic: it takes a `SupabaseClient` and an
|
|
// `accountId` and throws `SendMessageError` on failure. The callers
|
|
// own auth, rate-limiting, body parsing, and mapping the error to
|
|
// their respective response shapes (internal `{ error }` vs the v1
|
|
// envelope). Behaviour is identical to the original inline route —
|
|
// this is a straight extraction so the public endpoint can reuse it
|
|
// without duplicating ~250 lines of Meta plumbing.
|
|
// ============================================================
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
|
|
import {
|
|
sendTextMessage,
|
|
sendTemplateMessage,
|
|
sendMediaMessage,
|
|
sendInteractiveButtons,
|
|
sendInteractiveList,
|
|
type MediaKind,
|
|
} from '@/lib/whatsapp/meta-api';
|
|
import {
|
|
validateInteractivePayload,
|
|
interactivePayloadPreviewText,
|
|
type InteractiveMessagePayload,
|
|
} from '@/lib/whatsapp/interactive';
|
|
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption';
|
|
import { supabaseAdmin } from '@/lib/flows/admin-client';
|
|
import {
|
|
sanitizePhoneForMeta,
|
|
isValidE164,
|
|
phoneVariants,
|
|
isRecipientNotAllowedError,
|
|
} from '@/lib/whatsapp/phone-utils';
|
|
import type { MessageTemplate } from '@/types';
|
|
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard';
|
|
|
|
export const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const;
|
|
export const VALID_MESSAGE_TYPES = [
|
|
'text',
|
|
'template',
|
|
'interactive',
|
|
...MEDIA_KINDS,
|
|
] as const;
|
|
|
|
/**
|
|
* Typed failure with a machine `code` and a suggested HTTP `status`.
|
|
* Callers map it to their own response shape (`toErrorResponse` for
|
|
* the dashboard route, the v1 envelope for the public endpoint).
|
|
*/
|
|
export class SendMessageError extends Error {
|
|
readonly code: string;
|
|
readonly status: number;
|
|
constructor(code: string, message: string, status: number) {
|
|
super(message);
|
|
this.name = 'SendMessageError';
|
|
this.code = code;
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export interface SendMessageParams {
|
|
conversationId: string;
|
|
messageType: string;
|
|
contentText?: string | null;
|
|
mediaUrl?: string | null;
|
|
filename?: string | null;
|
|
templateName?: string | null;
|
|
templateLanguage?: string | null;
|
|
/** Legacy positional body params (only used if messageParams.body unset). */
|
|
templateParams?: string[];
|
|
/** Structured template params (header/body/buttons). */
|
|
templateMessageParams?: unknown;
|
|
/** Structured payload for `messageType === 'interactive'`. */
|
|
interactivePayload?: InteractiveMessagePayload | null;
|
|
replyToMessageId?: string | null;
|
|
}
|
|
|
|
export interface SendMessageResult {
|
|
/** Our `messages.id` (the persisted row). */
|
|
messageId: string;
|
|
/** Meta's `wamid` for the delivered message. */
|
|
whatsappMessageId: string;
|
|
}
|
|
|
|
/**
|
|
* Send a message in an existing conversation and persist it.
|
|
*
|
|
* `db` may be an RLS-scoped user client (dashboard) or the service-
|
|
* role client (public API) — every query is filtered by `accountId`
|
|
* either way, so tenancy holds regardless of which client is passed.
|
|
*/
|
|
/**
|
|
* Validate the message-shape params (type, required content, caption
|
|
* cap) independently of any DB state, throwing `SendMessageError` on a
|
|
* bad payload. Exported so a caller can reject a malformed request
|
|
* *before* it finds-or-creates a contact/conversation — otherwise an
|
|
* invalid payload leaves an orphan empty conversation behind. The send
|
|
* core calls this too, so validation can't be skipped.
|
|
*/
|
|
export function validateSendMessageParams(params: {
|
|
messageType: string;
|
|
contentText?: string | null;
|
|
mediaUrl?: string | null;
|
|
templateName?: string | null;
|
|
interactivePayload?: InteractiveMessagePayload | null;
|
|
}): void {
|
|
const { messageType, contentText, mediaUrl, templateName, interactivePayload } =
|
|
params;
|
|
|
|
if (!messageType) {
|
|
throw new SendMessageError('bad_request', 'message_type is required', 400);
|
|
}
|
|
|
|
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(messageType);
|
|
|
|
if (!(VALID_MESSAGE_TYPES as readonly string[]).includes(messageType)) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
`Unsupported message_type "${messageType}"`,
|
|
400
|
|
);
|
|
}
|
|
|
|
if (messageType === 'text' && !contentText) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'content_text is required for text messages',
|
|
400
|
|
);
|
|
}
|
|
|
|
if (messageType === 'template' && !templateName) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'template_name is required for template messages',
|
|
400
|
|
);
|
|
}
|
|
|
|
// Interactive: validate the full structured payload against Meta's
|
|
// limits up front so a bad payload 400s before we touch Meta.
|
|
if (messageType === 'interactive') {
|
|
const result = validateInteractivePayload(interactivePayload);
|
|
if (!result.ok) {
|
|
throw new SendMessageError('bad_request', result.error, 400);
|
|
}
|
|
}
|
|
|
|
if (isMediaKind && !mediaUrl) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
`media_url is required for ${messageType} messages`,
|
|
400
|
|
);
|
|
}
|
|
|
|
// Meta caps media captions at 1024 chars (audio carries none).
|
|
if (
|
|
isMediaKind &&
|
|
messageType !== 'audio' &&
|
|
typeof contentText === 'string' &&
|
|
contentText.length > 1024
|
|
) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'Caption exceeds the 1024-character limit',
|
|
400
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function sendMessageToConversation(
|
|
db: SupabaseClient,
|
|
accountId: string,
|
|
params: SendMessageParams
|
|
): Promise<SendMessageResult> {
|
|
const {
|
|
conversationId,
|
|
messageType,
|
|
contentText,
|
|
mediaUrl,
|
|
filename,
|
|
templateName,
|
|
templateLanguage,
|
|
templateParams,
|
|
templateMessageParams,
|
|
interactivePayload,
|
|
replyToMessageId,
|
|
} = params;
|
|
|
|
if (!conversationId) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'conversation_id is required',
|
|
400
|
|
);
|
|
}
|
|
|
|
validateSendMessageParams({
|
|
messageType,
|
|
contentText,
|
|
mediaUrl,
|
|
templateName,
|
|
interactivePayload,
|
|
});
|
|
|
|
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(messageType);
|
|
|
|
// Conversation + contact, account-scoped.
|
|
const { data: conversation, error: convError } = await db
|
|
.from('conversations')
|
|
.select('*, contact:contacts(*)')
|
|
.eq('id', conversationId)
|
|
.eq('account_id', accountId)
|
|
.single();
|
|
|
|
if (convError || !conversation) {
|
|
throw new SendMessageError('not_found', 'Conversation not found', 404);
|
|
}
|
|
|
|
const contact = conversation.contact;
|
|
if (!contact?.phone) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'Contact phone number not found',
|
|
400
|
|
);
|
|
}
|
|
|
|
const sanitizedPhone = sanitizePhoneForMeta(contact.phone);
|
|
if (!isValidE164(sanitizedPhone)) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'Invalid phone number format',
|
|
400
|
|
);
|
|
}
|
|
|
|
// WhatsApp config, account-scoped.
|
|
const { data: config, error: configError } = await db
|
|
.from('whatsapp_config')
|
|
.select('*')
|
|
.eq('account_id', accountId)
|
|
.single();
|
|
|
|
if (configError || !config) {
|
|
throw new SendMessageError(
|
|
'whatsapp_not_configured',
|
|
'WhatsApp not configured. Please set up your WhatsApp integration first.',
|
|
400
|
|
);
|
|
}
|
|
|
|
const accessToken = decrypt(config.access_token);
|
|
|
|
// Self-heal legacy CBC ciphertexts. Fire-and-forget; idempotent.
|
|
if (isLegacyFormat(config.access_token)) {
|
|
void db
|
|
.from('whatsapp_config')
|
|
.update({ access_token: encrypt(accessToken) })
|
|
.eq('id', config.id)
|
|
.then(({ error }: { error: { message: string } | null }) => {
|
|
if (error) {
|
|
console.warn(
|
|
'[send-message] access_token GCM upgrade failed:',
|
|
error.message
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Resolve the reply target to its Meta message_id. The parent must
|
|
// belong to this same conversation — otherwise a caller could quote
|
|
// messages they can't see by guessing UUIDs.
|
|
let contextMessageId: string | undefined;
|
|
if (replyToMessageId) {
|
|
const { data: parent, error: parentError } = await db
|
|
.from('messages')
|
|
.select('message_id, conversation_id')
|
|
.eq('id', replyToMessageId)
|
|
.eq('conversation_id', conversationId)
|
|
.maybeSingle();
|
|
|
|
if (parentError || !parent) {
|
|
throw new SendMessageError(
|
|
'bad_request',
|
|
'reply_to_message_id not found in this conversation',
|
|
400
|
|
);
|
|
}
|
|
if (!parent.message_id) {
|
|
console.warn(
|
|
'[send-message] reply target has no Meta message_id; sending without context'
|
|
);
|
|
} else {
|
|
contextMessageId = parent.message_id;
|
|
}
|
|
}
|
|
|
|
// Template row (for header + button components). isMessageTemplate
|
|
// guards against a malformed local row crashing the send-builder.
|
|
let templateRow: MessageTemplate | null = null;
|
|
if (messageType === 'template' && templateName) {
|
|
const { data } = await db
|
|
.from('message_templates')
|
|
.select('*')
|
|
.eq('account_id', accountId)
|
|
.eq('name', templateName)
|
|
.eq('language', templateLanguage || 'en_US')
|
|
.maybeSingle();
|
|
if (data && !isMessageTemplate(data)) {
|
|
throw new SendMessageError(
|
|
'template_malformed',
|
|
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it.',
|
|
500
|
|
);
|
|
}
|
|
templateRow = data ?? null;
|
|
}
|
|
|
|
const attempt = async (phone: string): Promise<string> => {
|
|
if (messageType === 'template') {
|
|
const result = await sendTemplateMessage({
|
|
phoneNumberId: config.phone_number_id,
|
|
accessToken,
|
|
to: phone,
|
|
templateName: templateName!,
|
|
language: templateLanguage || 'en_US',
|
|
template: templateRow ?? undefined,
|
|
messageParams: templateMessageParams ?? undefined,
|
|
params: templateParams || [],
|
|
contextMessageId,
|
|
});
|
|
return result.messageId;
|
|
}
|
|
if (isMediaKind) {
|
|
const result = await sendMediaMessage({
|
|
phoneNumberId: config.phone_number_id,
|
|
accessToken,
|
|
to: phone,
|
|
kind: messageType as MediaKind,
|
|
link: mediaUrl!,
|
|
caption: contentText || undefined,
|
|
filename: filename || undefined,
|
|
contextMessageId,
|
|
});
|
|
return result.messageId;
|
|
}
|
|
if (messageType === 'interactive') {
|
|
const p = interactivePayload!;
|
|
if (p.kind === 'buttons') {
|
|
const result = await sendInteractiveButtons({
|
|
phoneNumberId: config.phone_number_id,
|
|
accessToken,
|
|
to: phone,
|
|
bodyText: p.body,
|
|
headerText: p.header || undefined,
|
|
footerText: p.footer || undefined,
|
|
buttons: p.buttons,
|
|
contextMessageId,
|
|
});
|
|
return result.messageId;
|
|
}
|
|
const result = await sendInteractiveList({
|
|
phoneNumberId: config.phone_number_id,
|
|
accessToken,
|
|
to: phone,
|
|
bodyText: p.body,
|
|
buttonLabel: p.button_label,
|
|
headerText: p.header || undefined,
|
|
footerText: p.footer || undefined,
|
|
sections: p.sections,
|
|
contextMessageId,
|
|
});
|
|
return result.messageId;
|
|
}
|
|
const result = await sendTextMessage({
|
|
phoneNumberId: config.phone_number_id,
|
|
accessToken,
|
|
to: phone,
|
|
text: contentText!,
|
|
contextMessageId,
|
|
});
|
|
return result.messageId;
|
|
};
|
|
|
|
// Send via Meta — retry across phone-number variants if Meta rejects
|
|
// with "recipient not in allowed list"; persist a working variant
|
|
// back to the contact so the next send goes straight through.
|
|
let waMessageId = '';
|
|
let workingPhone = sanitizedPhone;
|
|
try {
|
|
const variants = phoneVariants(sanitizedPhone);
|
|
let lastError: unknown = null;
|
|
|
|
for (const variant of variants) {
|
|
try {
|
|
waMessageId = await attempt(variant);
|
|
workingPhone = variant;
|
|
lastError = null;
|
|
break;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (!isRecipientNotAllowedError(message)) {
|
|
throw err;
|
|
}
|
|
lastError = err;
|
|
console.warn(
|
|
`[send-message] variant "${variant}" rejected by Meta, trying next…`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (lastError) throw lastError;
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : 'Unknown Meta API error';
|
|
console.error('[send-message] Meta send failed for all variants:', message);
|
|
throw new SendMessageError('meta_error', `Meta API error: ${message}`, 502);
|
|
}
|
|
|
|
if (workingPhone !== sanitizedPhone) {
|
|
console.log(
|
|
`[send-message] Auto-corrected contact phone: ${sanitizedPhone} → ${workingPhone}`
|
|
);
|
|
await db
|
|
.from('contacts')
|
|
.update({ phone: workingPhone })
|
|
.eq('id', contact.id);
|
|
}
|
|
|
|
// Persist the sent message. Field names MUST match the messages
|
|
// schema (see 001_initial_schema.sql).
|
|
// Interactive messages persist the body as content_text (so the
|
|
// conversation-list preview reads sensibly) plus the full structured
|
|
// payload so the thread can re-render the buttons / rows.
|
|
const interactiveBody =
|
|
messageType === 'interactive' ? interactivePayload!.body : null;
|
|
|
|
const { data: messageRecord, error: msgError } = await db
|
|
.from('messages')
|
|
.insert({
|
|
conversation_id: conversationId,
|
|
sender_type: 'agent',
|
|
content_type: messageType,
|
|
content_text: interactiveBody ?? contentText ?? null,
|
|
media_url: mediaUrl || null,
|
|
template_name: templateName || null,
|
|
interactive_payload:
|
|
messageType === 'interactive' ? interactivePayload : null,
|
|
message_id: waMessageId,
|
|
status: 'sent',
|
|
reply_to_message_id: replyToMessageId || null,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (msgError) {
|
|
console.error('[send-message] error inserting sent message:', msgError);
|
|
throw new SendMessageError(
|
|
'db_error',
|
|
`Message sent to Meta but failed to save to DB: ${msgError.message}`,
|
|
500
|
|
);
|
|
}
|
|
|
|
const lastMessageText =
|
|
messageType === 'interactive'
|
|
? interactivePayloadPreviewText(interactivePayload!)
|
|
: contentText || `[${messageType}]`;
|
|
|
|
await db
|
|
.from('conversations')
|
|
.update({
|
|
last_message_text: lastMessageText,
|
|
last_message_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', conversationId);
|
|
|
|
// Pause any active Flow run for this contact — the agent stepping in
|
|
// is the strongest "yield, human is here" signal. Best-effort.
|
|
try {
|
|
const { error: pauseErr } = await supabaseAdmin()
|
|
.from('flow_runs')
|
|
.update({
|
|
status: 'paused_by_agent',
|
|
ended_at: new Date().toISOString(),
|
|
end_reason: 'agent_replied',
|
|
})
|
|
.eq('account_id', accountId)
|
|
.eq('contact_id', contact.id)
|
|
.eq('status', 'active');
|
|
if (pauseErr) {
|
|
console.error('[flows] pause-on-agent-send failed:', pauseErr.message);
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
'[flows] pause-on-agent-send threw:',
|
|
err instanceof Error ? err.message : err
|
|
);
|
|
}
|
|
|
|
return { messageId: messageRecord.id, whatsappMessageId: waMessageId };
|
|
}
|