mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
commit
641031fd5c
@ -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++
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user