Merge pull request #13 from ArnasDon/feat/phone-variant-retry

feat: auto-retry with phone variants on Meta 'not in allowed list' (#131030)
This commit is contained in:
Arnas Donauskas 2026-04-16 21:36:00 +03:00 committed by GitHub
commit 641031fd5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 173 additions and 37 deletions

View File

@ -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
}
// 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(
accessToken,
config.phone_number_id,
sanitized,
accessToken,
variant,
template_name,
template_params || [],
template_language || 'en_US'
template_language || 'en_US',
template_params || []
)
results.push({
phone,
status: 'sent',
whatsapp_message_id: result.messageId,
})
sentCount++
sentMessageId = result.messageId
lastError = null
break
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
console.error(`Failed to send broadcast to ${phone}:`, 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: sentMessageId,
})
sentCount++
} else {
console.error(`Failed to send broadcast to ${phone}:`, lastError)
results.push({
phone,
status: 'failed',
error: errorMessage,
error: lastError || 'Unknown error',
})
failedCount++
}

View File

@ -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<string> => {
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 {
return result.messageId
}
const result = await sendTextMessage(
config.phone_number_id,
accessToken,
sanitizedPhone,
phone,
content_text
)
waMessageId = result.messageId
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,

View File

@ -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<string>()
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)
}