mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
232 lines
6.8 KiB
TypeScript
232 lines
6.8 KiB
TypeScript
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,
|
|
phoneVariants,
|
|
isRecipientNotAllowedError,
|
|
} from '@/lib/whatsapp/phone-utils'
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const supabase = await createClient()
|
|
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabase.auth.getUser()
|
|
|
|
if (authError || !user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
)
|
|
}
|
|
|
|
const body = await request.json()
|
|
const {
|
|
conversation_id,
|
|
message_type,
|
|
content_text,
|
|
media_url,
|
|
template_name,
|
|
template_params,
|
|
} = body
|
|
|
|
if (!conversation_id || !message_type) {
|
|
return NextResponse.json(
|
|
{ error: 'conversation_id and message_type are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (message_type === 'text' && !content_text) {
|
|
return NextResponse.json(
|
|
{ error: 'content_text is required for text messages' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (message_type === 'template' && !template_name) {
|
|
return NextResponse.json(
|
|
{ error: 'template_name is required for template messages' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Fetch conversation and contact
|
|
const { data: conversation, error: convError } = await supabase
|
|
.from('conversations')
|
|
.select('*, contact:contacts(*)')
|
|
.eq('id', conversation_id)
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (convError || !conversation) {
|
|
return NextResponse.json(
|
|
{ error: 'Conversation not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
const contact = conversation.contact
|
|
if (!contact?.phone) {
|
|
return NextResponse.json(
|
|
{ error: 'Contact phone number not found' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Sanitize and validate phone
|
|
const sanitizedPhone = sanitizePhoneForMeta(contact.phone)
|
|
if (!isValidE164(sanitizedPhone)) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid phone number format' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Fetch and decrypt WhatsApp config
|
|
const { data: config, error: configError } = await supabase
|
|
.from('whatsapp_config')
|
|
.select('*')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (configError || !config) {
|
|
return NextResponse.json(
|
|
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const accessToken = decrypt(config.access_token)
|
|
|
|
// 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
|
|
|
|
const attempt = async (phone: string): Promise<string> => {
|
|
if (message_type === 'template') {
|
|
const result = await sendTemplateMessage(
|
|
config.phone_number_id,
|
|
accessToken,
|
|
phone,
|
|
template_name,
|
|
undefined, // default language (en_US)
|
|
template_params || []
|
|
)
|
|
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 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,
|
|
// media_url, template_name, message_id, status, created_at
|
|
const { data: messageRecord, error: msgError } = await supabase
|
|
.from('messages')
|
|
.insert({
|
|
conversation_id,
|
|
sender_type: 'agent',
|
|
content_type: message_type,
|
|
content_text: content_text || null,
|
|
media_url: media_url || null,
|
|
template_name: template_name || null,
|
|
message_id: waMessageId,
|
|
status: 'sent',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (msgError) {
|
|
console.error('Error inserting sent message:', msgError)
|
|
return NextResponse.json(
|
|
{ error: `Message sent to Meta but failed to save to DB: ${msgError.message}` },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Update conversation
|
|
await supabase
|
|
.from('conversations')
|
|
.update({
|
|
last_message_text: content_text || `[${message_type}]`,
|
|
last_message_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', conversation_id)
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message_id: messageRecord.id,
|
|
whatsapp_message_id: waMessageId,
|
|
})
|
|
} catch (error) {
|
|
console.error('Error in WhatsApp send POST:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to send message' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|