mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in allowed list") in two situations: 1. Sandbox: the recipient isn't registered as a test number. 2. The registered number format differs from what we sent, usually because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored as +370063949836 vs international +37063949836). Today that error bubbles up to the UI and the user has to manually edit the contact. This PR makes the send pipeline self-heal: on 131030 it retries with alternate phone-number formats, and if an alternate works it persists the corrected format back to the contact so future sends are a single API call. src/lib/whatsapp/phone-utils.ts - Added phoneVariants(sanitized) — yields original, then variants with a trunk 0 inserted/removed after each plausible country-code length (1-3 digits). Deduplicated. Original is always first so the fast path for correctly-formatted numbers is one attempt. - Added isRecipientNotAllowedError(message) helper — matches code 131030 or the English error text, so we only retry when the failure is actually about the recipient (other errors fail fast). src/app/api/whatsapp/send/route.ts - Reply send now iterates phoneVariants() and stops at the first that succeeds. If a non-original variant works, contacts.phone is updated to the working format. src/app/api/whatsapp/broadcast/route.ts - Same retry applied per recipient in the broadcast loop. - Also fixed a separate bug: broadcast was calling sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order. Correct signature is (phoneNumberId, accessToken, to, templateName, language?, params?). This was silently rejecting every broadcast. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a5d274e02
commit
e97d5b5064
@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
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 {
|
interface BroadcastResult {
|
||||||
phone: string
|
phone: string
|
||||||
@ -77,30 +82,55 @@ export async function POST(request: Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Correct sendTemplateMessage signature is:
|
||||||
const result = await sendTemplateMessage(
|
// (phoneNumberId, accessToken, to, templateName, language?, params?)
|
||||||
accessToken,
|
// Previously the args were passed in the wrong order (accessToken first,
|
||||||
config.phone_number_id,
|
// params before language), which made Meta reject every broadcast.
|
||||||
sanitized,
|
//
|
||||||
template_name,
|
// Retry with phone variants on "not in allowed list" so numbers that
|
||||||
template_params || [],
|
// differ only in a trunk-prefix 0 still reach recipients.
|
||||||
template_language || 'en_US'
|
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({
|
results.push({
|
||||||
phone,
|
phone,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
whatsapp_message_id: result.messageId,
|
whatsapp_message_id: sentMessageId,
|
||||||
})
|
})
|
||||||
sentCount++
|
sentCount++
|
||||||
} catch (error) {
|
} else {
|
||||||
const errorMessage =
|
console.error(`Failed to send broadcast to ${phone}:`, lastError)
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
console.error(`Failed to send broadcast to ${phone}:`, error)
|
|
||||||
results.push({
|
results.push({
|
||||||
phone,
|
phone,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: errorMessage,
|
error: lastError || 'Unknown error',
|
||||||
})
|
})
|
||||||
failedCount++
|
failedCount++
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -99,43 +104,81 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const accessToken = decrypt(config.access_token)
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
// Send via Meta API
|
// Send via Meta API — retry with phone-number variants if Meta rejects
|
||||||
// Function signatures are:
|
// with "recipient not in allowed list" (common in sandbox / when a
|
||||||
// sendTextMessage(phoneNumberId, accessToken, to, text)
|
// number was registered with/without a trunk 0). If an alternate
|
||||||
// sendTemplateMessage(phoneNumberId, accessToken, to, templateName, language?, params?)
|
// format succeeds, we persist it back to the contact row so the
|
||||||
// The args were previously swapped (accessToken before phoneNumberId),
|
// next send goes through on the first attempt.
|
||||||
// causing Meta to reject every send.
|
let waMessageId = ''
|
||||||
let waMessageId: string
|
let workingPhone = sanitizedPhone
|
||||||
|
|
||||||
try {
|
const attempt = async (phone: string): Promise<string> => {
|
||||||
if (message_type === 'template') {
|
if (message_type === 'template') {
|
||||||
const result = await sendTemplateMessage(
|
const result = await sendTemplateMessage(
|
||||||
config.phone_number_id,
|
config.phone_number_id,
|
||||||
accessToken,
|
accessToken,
|
||||||
sanitizedPhone,
|
phone,
|
||||||
template_name,
|
template_name,
|
||||||
undefined, // use default language (en_US)
|
undefined, // default language (en_US)
|
||||||
template_params || []
|
template_params || []
|
||||||
)
|
)
|
||||||
waMessageId = result.messageId
|
return result.messageId
|
||||||
} else {
|
|
||||||
const result = await sendTextMessage(
|
|
||||||
config.phone_number_id,
|
|
||||||
accessToken,
|
|
||||||
sanitizedPhone,
|
|
||||||
content_text
|
|
||||||
)
|
|
||||||
waMessageId = 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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown Meta API error'
|
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(
|
return NextResponse.json(
|
||||||
{ error: `Meta API error: ${message}` },
|
{ error: `Meta API error: ${message}` },
|
||||||
{ status: 502 }
|
{ 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
|
// Insert message into DB — field names MUST match the messages schema
|
||||||
// (see supabase/migrations/001_initial_schema.sql):
|
// (see supabase/migrations/001_initial_schema.sql):
|
||||||
// conversation_id, sender_type, content_type, content_text,
|
// conversation_id, sender_type, content_type, content_text,
|
||||||
|
|||||||
@ -39,3 +39,66 @@ export function phonesMatch(phone1: string, phone2: string): boolean {
|
|||||||
export function isValidE164(phone: string): boolean {
|
export function isValidE164(phone: string): boolean {
|
||||||
return /^\+?[1-9]\d{6,14}$/.test(phone)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user