diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index 3091d12..6171d25 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { decrypt } from '@/lib/whatsapp/encryption' -import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils' +import { + sanitizePhoneForMeta, + isValidE164, + phoneVariants, + isRecipientNotAllowedError, +} from '@/lib/whatsapp/phone-utils' interface BroadcastResult { phone: string @@ -77,30 +82,55 @@ export async function POST(request: Request) { continue } - try { - const result = await sendTemplateMessage( - accessToken, - config.phone_number_id, - sanitized, - template_name, - template_params || [], - template_language || 'en_US' - ) + // Correct sendTemplateMessage signature is: + // (phoneNumberId, accessToken, to, templateName, language?, params?) + // Previously the args were passed in the wrong order (accessToken first, + // params before language), which made Meta reject every broadcast. + // + // Retry with phone variants on "not in allowed list" so numbers that + // differ only in a trunk-prefix 0 still reach recipients. + const variants = phoneVariants(sanitized) + let sentMessageId: string | null = null + let lastError: string | null = null + for (const variant of variants) { + try { + const result = await sendTemplateMessage( + config.phone_number_id, + accessToken, + variant, + template_name, + template_language || 'en_US', + template_params || [] + ) + sentMessageId = result.messageId + lastError = null + break + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + if (!isRecipientNotAllowedError(errorMessage)) { + lastError = errorMessage + break // unrelated error — stop retrying + } + lastError = errorMessage + // retry with next variant + } + } + + if (sentMessageId) { results.push({ phone, status: 'sent', - whatsapp_message_id: result.messageId, + whatsapp_message_id: sentMessageId, }) sentCount++ - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error' - console.error(`Failed to send broadcast to ${phone}:`, error) + } else { + console.error(`Failed to send broadcast to ${phone}:`, lastError) results.push({ phone, status: 'failed', - error: errorMessage, + error: lastError || 'Unknown error', }) failedCount++ } diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index b4cc04c..f8614fd 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { decrypt } from '@/lib/whatsapp/encryption' -import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils' +import { + sanitizePhoneForMeta, + isValidE164, + phoneVariants, + isRecipientNotAllowedError, +} from '@/lib/whatsapp/phone-utils' export async function POST(request: Request) { try { @@ -99,43 +104,81 @@ export async function POST(request: Request) { const accessToken = decrypt(config.access_token) - // Send via Meta API - // Function signatures are: - // sendTextMessage(phoneNumberId, accessToken, to, text) - // sendTemplateMessage(phoneNumberId, accessToken, to, templateName, language?, params?) - // The args were previously swapped (accessToken before phoneNumberId), - // causing Meta to reject every send. - let waMessageId: string + // Send via Meta API — retry with phone-number variants if Meta rejects + // with "recipient not in allowed list" (common in sandbox / when a + // number was registered with/without a trunk 0). If an alternate + // format succeeds, we persist it back to the contact row so the + // next send goes through on the first attempt. + let waMessageId = '' + let workingPhone = sanitizedPhone - try { + const attempt = async (phone: string): Promise => { if (message_type === 'template') { const result = await sendTemplateMessage( config.phone_number_id, accessToken, - sanitizedPhone, + phone, template_name, - undefined, // use default language (en_US) + undefined, // default language (en_US) template_params || [] ) - waMessageId = result.messageId - } else { - const result = await sendTextMessage( - config.phone_number_id, - accessToken, - sanitizedPhone, - content_text - ) - waMessageId = result.messageId + return result.messageId } + const result = await sendTextMessage( + config.phone_number_id, + accessToken, + phone, + content_text + ) + return result.messageId + } + + 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) + // Only retry when the failure is specifically that the + // recipient isn't in Meta's allowed list. Any other error + // (bad token, invalid template, etc.) bubbles up immediately. + if (!isRecipientNotAllowedError(message)) { + throw err + } + lastError = err + console.warn(`[whatsapp/send] 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('Meta API send failed:', message) + console.error('Meta API send failed for all variants:', message) return NextResponse.json( { error: `Meta API error: ${message}` }, { status: 502 } ) } + // If a non-original variant succeeded, update the contact so future + // sends go straight through. sanitizePhoneForMeta on workingPhone + // will yield workingPhone itself, so re-storing preserves it. + if (workingPhone !== sanitizedPhone) { + console.log( + `[whatsapp/send] Auto-corrected contact phone: ${sanitizedPhone} → ${workingPhone}` + ) + await supabase + .from('contacts') + .update({ phone: workingPhone }) + .eq('id', contact.id) + } + // Insert message into DB — field names MUST match the messages schema // (see supabase/migrations/001_initial_schema.sql): // conversation_id, sender_type, content_type, content_text, diff --git a/src/lib/whatsapp/phone-utils.ts b/src/lib/whatsapp/phone-utils.ts index 74a1f57..2e5da24 100644 --- a/src/lib/whatsapp/phone-utils.ts +++ b/src/lib/whatsapp/phone-utils.ts @@ -39,3 +39,66 @@ export function phonesMatch(phone1: string, phone2: string): boolean { export function isValidE164(phone: string): boolean { return /^\+?[1-9]\d{6,14}$/.test(phone) } + +/** + * Generate plausible phone number variants for retry when Meta's + * sandbox rejects a number with error #131030 ("not in allowed list"). + * + * Many countries use a "trunk prefix" 0 for domestic dialing that is + * meant to be dropped in international format (e.g. Lithuanian + * "+370 063 949 836" domestically → "+370 63 949 836" international). + * But some sandboxes register the number with the trunk 0 included, + * causing sends to the correct international format to fail. + * + * This helper yields up to 3 variants: + * 1. The original sanitized number (first attempt) + * 2. With a trunk 0 inserted after the country code + * 3. With a trunk 0 removed after the country code + * + * Country-code lengths of 1, 2, and 3 digits are tried because we + * don't know the user's country ahead of time. + * + * @param sanitized - digits-only phone number (from sanitizePhoneForMeta) + * @returns deduplicated list of variants, original first + */ +export function phoneVariants(sanitized: string): string[] { + if (!sanitized) return [] + const seen = new Set() + const push = (v: string) => { + if (v && !seen.has(v)) seen.add(v) + } + + // 1. Original + push(sanitized) + + // 2. Insert a 0 after each plausible country-code length + for (const ccLen of [1, 2, 3]) { + if (sanitized.length <= ccLen) continue + const cc = sanitized.slice(0, ccLen) + const rest = sanitized.slice(ccLen) + if (!rest.startsWith('0')) { + push(cc + '0' + rest) + } + } + + // 3. Remove a leading 0 after each plausible country-code length + for (const ccLen of [1, 2, 3]) { + if (sanitized.length <= ccLen + 1) continue + const cc = sanitized.slice(0, ccLen) + const rest = sanitized.slice(ccLen) + if (rest.startsWith('0')) { + push(cc + rest.slice(1)) + } + } + + return [...seen] +} + +/** + * Returns true when the Meta API error indicates the recipient + * phone number isn't in the allowed list (sandbox restriction). + * Detected via error code 131030 or the standard error text. + */ +export function isRecipientNotAllowedError(message: string): boolean { + return /131030|not in allowed list|not in the allowed list/i.test(message) +}