في السعودية وعموم الخليج، واتساب ليس قناة تسويقية. هو القناة. هو المكان الذي يسأل فيه العميل إن كانت القطعة متوفرة، وحيث تؤكد العيادة موعداً، وحيث يرسل المقاول عرض سعر. كل نشاط تجاري جاد هنا يعمل عليه بالفعل — غالباً عبر جوال شخصي يملكه موظف واحد ويأخذه معه إلى البيت في الخامسة مساءً.
الفجوة بين هذا الواقع وبين تكامل حقيقي هي المكان الذي تتعثر فيه أغلب المشاريع. ابحث عن "بوت واتساب" بالعربية وستجد أربعين منصة SaaS بدون كود تبيع اشتراكات شهرية، ولن تجد تقريباً أي شيء يشرح للمطوّر كيف تعمل واجهة Meta الفعلية. هذا الدرس هو الجزء الناقص: الـ Cloud API على مستوى الكود، مع وكيل ذكي حقيقي في الطرف الآخر.
ما الذي ستبنيه
وكيل واتساب جاهز للإنتاج يعمل على Next.js 15 بنظام App Router:
- نقطة ويب هوك يستطيع Meta التحقق منها وترفض الطلبات المزوّرة
- تحليل مُنمّط للرسائل الواردة، مع منع التكرار حتى لا تتحول إعادة المحاولة إلى ردود مكررة
- عميل إرسال للنصوص الحرة، وإشعارات القراءة، والقوالب المعتمدة
- تعامل صحيح مع نافذة خدمة العملاء ذات الـ24 ساعة
- وكيل ذكي يبدأ من العربية مبني على Claude، يفهم السياق الخليجي ويعرف متى يتوقف عن الكلام
- مسار واضح لتحويل المحادثة إلى موظف بشري
المتطلبات المسبقة
- Node.js 20+ ومشروع Next.js 15 يستخدم App Router
- حساب Meta Business بنشاط تجاري موثّق. في السعودية يعني ذلك السجل التجاري، وفي تونس البطاقة المهنية (patente). التوثيق يستغرق أياماً لا دقائق — ابدأه قبل كتابة أي كود.
- رقم جوال غير مفعّل حالياً على تطبيق واتساب العادي أو واتساب بيزنس. بمجرد انتقال الرقم إلى الـ Cloud API فإنه يغادر هذين التطبيقين.
- مفتاح Anthropic API لجزء الوكيل الذكي
- رابط HTTPS عام. Meta لن يستدعي نقطة HTTP ولا عنوان localhost. استخدم نفقاً مثل
ngrok http 3000أثناء التطوير.
ملاحظة عن التكلفة. يمنحك Meta عدداً مجانياً من المحادثات شهرياً، ثم يحاسبك لكل رسالة حسب الفئة ودولة المستقبل. أسعار السعودية وتونس مختلفة. راجع صفحة التسعير الحالية لسوقك قبل أن تعد أحداً برقم — فقد تغيّر التسعير مرتين خلال العامين الماضيين.
الخطوة 1: إعداد تطبيق Meta
في لوحة Meta for Developers، أنشئ تطبيقاً من نوع Business، ثم أضف إليه منتج WhatsApp. ستصل إلى صفحة بداية سريعة تمنحك أربع قيم. ضعها في .env.local فوراً ولا تكتبها في الكود أبداً:
# .env.local
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321098765
WHATSAPP_ACCESS_TOKEN=EAAJB...
WHATSAPP_APP_SECRET=a1b2c3d4e5f6...
WHATSAPP_VERIFY_TOKEN=اختر-نصاً-عشوائياً-طويلاً-بنفسك
ANTHROPIC_API_KEY=sk-ant-...قيمتان منها تستحقان الشرح.
WHATSAPP_VERIFY_TOKEN لا يصدره Meta. أنت من يخترعه، وتلصق القيمة نفسها في نموذج إعداد الويب هوك لدى Meta. وجوده يتيح لك — حين يستدعي Meta نقطتك للتحقق — أن تتأكد أن الاستدعاء جاء فعلاً من إعدادك أنت.
WHATSAPP_APP_SECRET تجده تحت App Settings → Basic. هو مفتاح HMAC الذي يوقّع به Meta كل حمولة ويب هوك. بدونه لا يمكنك التمييز بين ويب هوك حقيقي وبين أي شخص على الإنترنت وجد رابطك.
رمز الوصول المؤقت في صفحة البداية السريعة تنتهي صلاحيته خلال 24 ساعة. لأي شيء يتجاوز اختباراً أولياً، أنشئ System User تحت Business Settings، وامنحه حسابك على WhatsApp Business بصلاحية كاملة، ثم ولّد رمزاً دائماً. افعل ذلك مبكراً — أن تكتشف أن البوت توقّف ليلاً بسبب رمز تطوير هو يوم ثلاثاء سيئ.
أضف وحدة إعداد صغيرة حتى تفشل بقية الشيفرة بصوت عالٍ عند غياب متغيّر بدل أن ترسل طلبات إلى undefined:
// lib/whatsapp/config.ts
function required(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
export const WHATSAPP = {
graphVersion: 'v23.0',
phoneNumberId: required('WHATSAPP_PHONE_NUMBER_ID'),
accessToken: required('WHATSAPP_ACCESS_TOKEN'),
appSecret: required('WHATSAPP_APP_SECRET'),
verifyToken: required('WHATSAPP_VERIFY_TOKEN'),
} as const
export const GRAPH_BASE = `https://graph.facebook.com/${WHATSAPP.graphVersion}`الخطوة 2: مصافحة التحقق من الويب هوك
حين تحفظ رابط ويب هوك في لوحة Meta، يرسل Meta فوراً طلب GET بثلاثة معاملات: hub.mode وhub.verify_token وhub.challenge. عليك إعادة قيمة التحدي كنص صرف — وفقط إذا تطابق الرمز.
// app/api/whatsapp/webhook/route.ts
import { WHATSAPP } from '@/lib/whatsapp/config'
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const mode = params.get('hub.mode')
const token = params.get('hub.verify_token')
const challenge = params.get('hub.challenge')
if (mode === 'subscribe' && token === WHATSAPP.verifyToken && challenge) {
return new Response(challenge, {
status: 200,
headers: { 'content-type': 'text/plain' },
})
}
return new Response('Forbidden', { status: 403 })
}ثلاثة أخطاء تكسر هذه المصافحة، وكلها تنتج الرسالة غير المفيدة نفسها في لوحة Meta:
- إعادة JSON. يقارن Meta جسم الاستجابة الخام بنص التحدي.
Response.json(challenge)يلفّه بعلامتي اقتباس فيفشل. - اختلاف الشرطة المائلة في النهاية. يجب أن يطابق الرابط في اللوحة مسارك تماماً.
- النشر قبل التحقق. يجب أن تكون النقطة حيّة ومتاحة علناً في اللحظة التي تضغط فيها Save، لا بعدها.
بعد نجاح التحقق، اشترك في حقل messages ضمن إعدادات الويب هوك. لا يصلك شيء قبل ذلك.
الخطوة 3: التحقق من التوقيع
هذه هي الخطوة التي تتجاهلها أغلب الدروس، وهي الأهم. رابط الويب هوك لديك نقطة HTTPS عامة. أي شخص يكتشفها يستطيع إرسال "رسالة عميل" مزيّفة تجعل وكيلك الذكي يرد على غريب — أو أسوأ، يشغّل أي منطق أعمال يقف خلفه.
يوقّع Meta كل طلب POST بـ HMAC-SHA256 لجسم الطلب الخام، بمفتاح هو App Secret، في ترويسة X-Hub-Signature-256. عليك إعادة حسابه والمقارنة.
// lib/whatsapp/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { WHATSAPP } from './config'
export function isValidSignature(rawBody: string, header: string | null): boolean {
if (!header?.startsWith('sha256=')) return false
const expected = createHmac('sha256', WHATSAPP.appSecret)
.update(rawBody, 'utf8')
.digest('hex')
const received = header.slice('sha256='.length)
// فحص الطول أولاً: timingSafeEqual يرمي خطأً عند اختلاف أطوال المخازن.
if (received.length !== expected.length) return false
return timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))
}تفصيلتان هنا تحملان الوزن كله.
عليك حساب البصمة على الجسم الخام، بايت ببايت. إذا استدعيت await request.json() ثم أعدت تسلسل الكائن، فسيختلف ترتيب المفاتيح والمسافات عمّا وقّعه Meta، وستفشل كل التواقيع. اقرأ الجسم نصاً مرة واحدة، تحقق منه، ثم حلّل النص الذي بين يديك.
استخدم timingSafeEqual لا ===. المقارنة النصية العادية تعود فور عثورها على حرف مختلف، وهذا الفارق الزمني يكفي ليستعيد مهاجم توقيعاً صالحاً بايتاً بعد بايت. وحارس الطول قبلها ضروري لأن timingSafeEqual يرمي خطأً بدل أن يعيد false حين تختلف أحجام المخازن.
الخطوة 4: تحليل الرسائل الواردة
حمولة الويب هوك متداخلة بعمق وتحمل أكثر من رسائل العملاء. هذه رسالة نصية حقيقية، مختصرة:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "966500000000",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{
"profile": { "name": "اسم العميل" },
"wa_id": "966555555555"
}],
"messages": [{
"from": "966555555555",
"id": "wamid.HBgL...",
"timestamp": "1786000000",
"type": "text",
"text": { "body": "هل هذا متوفر لديكم؟" }
}]
}
}]
}]
}التمييز الحاسم: كائن value يحوي مصفوفة messages يعني رسالة عميل واردة. أما value الذي يحوي مصفوفة statuses فهو إشعار تسليم لشيء أرسلته أنت — أُرسل، وصل، قُرئ، أو فشل. إن لم تفصل بينهما، فسيحاول وكيلك بسرور أن يرد على إشعارات القراءة الخاصة به.
// lib/whatsapp/parse.ts
export type InboundMessage = {
wamid: string
from: string
profileName: string
text: string
timestamp: number
}
type WebhookPayload = {
entry?: Array<{
changes?: Array<{
value?: {
contacts?: Array<{ profile?: { name?: string }; wa_id?: string }>
messages?: Array<{
from: string
id: string
timestamp: string
type: string
text?: { body: string }
}>
}
}>
}>
}
export function extractMessages(payload: WebhookPayload): InboundMessage[] {
const out: InboundMessage[] = []
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
// غياب messages يعني أن هذا إشعار حالة، لا رسالة عميل.
if (!value?.messages) continue
const profileName = value.contacts?.[0]?.profile?.name ?? ''
for (const message of value.messages) {
if (message.type !== 'text' || !message.text) continue
out.push({
wamid: message.id,
from: message.from,
profileName,
text: message.text.body,
timestamp: Number(message.timestamp) * 1000,
})
}
}
}
return out
}نُرشّح هنا على type === 'text' من أجل الوضوح. النشر الحقيقي يرى أيضاً image وaudio وdocument وlocation وbutton وinteractive — والصوت تحديداً يستحق المعالجة في الأسواق العربية، حيث تكون الرسائل الصوتية غالباً الطريقة الافتراضية لتواصل العملاء. عالجها بالطريقة نفسها: تفرّع على type واستخرج الحمولة المناسبة.
منع التكرار
يعيد Meta إرسال الويب هوك إن لم تُرجع 2xx بسرعة. وتحمل تلك الإعادات المعرّف wamid نفسه. بدون منع تكرار، يتحول سؤال عميل واحد إلى ثلاثة ردود ذكية متطابقة وثلاث عمليات محاسبة.
كل معرّف رسالة فريد عالمياً، فاستخدمه مفتاحاً لمنع التكرار:
// lib/whatsapp/seen.ts
const seen = new Map<string, number>()
const TTL_MS = 10 * 60 * 1000
export function alreadyHandled(wamid: string): boolean {
const now = Date.now()
// تنظيف انتهازي حتى لا تنمو الخريطة بلا حدود.
for (const [key, at] of seen) {
if (now - at > TTL_MS) seen.delete(key)
}
if (seen.has(wamid)) return true
seen.set(wamid, now)
return false
}خريطة في الذاكرة كافية لنسخة واحدة. في اللحظة التي تشغّل فيها أكثر من نسخة — أي منصة serverless، أي نشر أفقي — انقل هذا إلى Redis أو قاعدة بيانات بقيد فريد على wamid. نسختان تحمل كل منهما خريطتها الخاصة لا تمنعان أي تكرار.
الخطوة 5: عميل الإرسال
الإرسال هو POST إلى /PHONE_NUMBER_ID/messages برمز حامل. وشكل الجسم يتوقف على نوع الرسالة.
// lib/whatsapp/send.ts
import { GRAPH_BASE, WHATSAPP } from './config'
async function call(body: Record<string, unknown>) {
const response = await fetch(`${GRAPH_BASE}/${WHATSAPP.phoneNumberId}/messages`, {
method: 'POST',
headers: {
authorization: `Bearer ${WHATSAPP.accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const detail = await response.text()
throw new Error(`WhatsApp send failed (${response.status}): ${detail}`)
}
return response.json() as Promise<{ messages: Array<{ id: string }> }>
}
export function sendText(to: string, body: string, previewUrl = false) {
return call({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { preview_url: previewUrl, body },
})
}
export function markAsRead(wamid: string) {
return call({
messaging_product: 'whatsapp',
status: 'read',
message_id: wamid,
})
}markAsRead صغيرة وتستحق العناء. ظهور العلامتين الزرقاوين خلال ثانية يخبر العميل أن نظاماً حقيقياً استقبل رسالته، وهذا يشتري لك الثواني القليلة التي يحتاجها النموذج للتفكير.
لاحظ حقل to: رقم دولي بلا + ولا مسافات ولا شرطات. 966555555555 وليس +966 55 555 5555. حقل from في الرسائل الواردة يأتي بهذه الصيغة أصلاً، فإعادته آمنة.
الخطوة 6: نافذة الـ24 ساعة والقوالب
هذه هي القاعدة التي تشكّل كل منتج على واتساب، وسوء فهمها هو السبب الأكثر شيوعاً لفشل الإطلاق في المراجعة.
لا تستطيع مراسلة العميل متى شئت. حين يرسل لك المستخدم رسالة، تُفتح نافذة خدمة عملاء مدتها 24 ساعة. داخل هذه النافذة يمكنك إرسال رسائل حرة — أي نص، أي محتوى. وكل رسالة جديدة من المستخدم تعيد ضبط المؤقت إلى 24 ساعة كاملة. وبمجرد إغلاقها تُرفض الرسائل الحرة، ولا يبقى أمامك إلا إرسال قالب معتمد مسبقاً.
بالنسبة لوكيل يعمل بالاستقبال، تكون هذه القاعدة غير مرئية غالباً: العميل راسلك، فالنافذة مفتوحة. لكنها تصبح مرئية في اللحظة التي يريد فيها النشاط إرسال تذكير بموعد، أو تحديث حالة طلب، أو متابعة عرض سعر.
تُقدَّم القوالب عبر WhatsApp Manager ويراجعها Meta، عادةً خلال ساعات. ولكل قالب اسم ورمز لغة ومتغيرات مرقّمة.
سجّل النسخة العربية برمز اللغة ar — بنص مكتوب بطبيعية لا مترجم آلياً:
مرحباً {{1}}، طلبك رقم {{2}} جاهز للاستلام من فرعنا. شكراً لثقتك بنا.
أرسله بالمعاملات بالترتيب:
// lib/whatsapp/send.ts (تتمة)
type TemplateParam = { type: 'text'; text: string }
export function sendTemplate(
to: string,
name: string,
languageCode: 'ar' | 'en' | 'fr',
params: string[] = [],
) {
const components =
params.length > 0
? [{
type: 'body',
parameters: params.map<TemplateParam>((text) => ({ type: 'text', text })),
}]
: undefined
return call({
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name,
language: { code: languageCode },
...(components ? { components } : {}),
},
})
}ثلاثة أمور تكلّف الفرق يوماً كاملاً لكل منها:
- المعاملات موضعية.
params[0]يملأ الموضع الأول وparams[1]يملأ الثاني. لا توجد متغيرات بأسماء. وأي اختلاف بين العدد الذي ترسله والعدد في القالب المعتمد يُرجع الخطأ132000. - رمز اللغة يجب أن يطابق القالب المعتمد تماماً. قالب اعتُمد بـ
arلا يمكن إرساله بـar_SA. هما قالبان مختلفان بالنسبة للواجهة. - العرض من اليمين إلى اليسار يتولاه واتساب لا أنت. لا تحقن محارف تحكم اتجاهية. لكن افحص كيف يُعرض قالب يحتوي نصه العربي على متغير لاتيني — رقم فاتورة مثل
INV-2026-0412داخل جملة عربية قد يعيد ترتيب نفسه بصرياً بطرق تفاجئك. أرسل لنفسك رسالة اختبار حقيقية قبل اعتماد النص.
الخطوة 7: توصيل الوكيل الذكي
الآن الجزء الممتع. نستخدم Claude بموجّه نظام مبني لسياق أعمال خليجي، والأهم أننا نحتفظ بحالة المحادثة لكل رقم جوال.
// lib/agent/whatsapp-agent.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
const SYSTEM_PROMPT = `أنت مساعد خدمة العملاء لنشاط تجاري في السعودية، وتردّ عبر واتساب.
اللغة: ردّ بلغة العميل نفسها. إن كتب بالعربية فردّ بعربية فصحى واضحة تُقرأ بطبيعية لدى جمهور خليجي. لا تترجم أسماء الأعلام ولا أسماء المنتجات ولا أرقام الطلبات.
التنسيق: واتساب محادثة لا صفحة ويب. اجعل الردود في جملتين أو ثلاث قصيرة. بلا عناوين markdown، بلا قوائم نقطية، بلا جداول. العميل الذي يقرأ على جواله يجب أن يجد الإجابة في السطر الأول.
النطاق: أجب عن الأسئلة المتعلقة بالمنتجات والأسعار والتوفر وساعات العمل والموقع. إن كنت لا تعرف شيئاً فقل ذلك بوضوح واعرض توصيل العميل بزميل. لا تخترع سعراً ولا موعد تسليم ولا كمية مخزون أبداً.
التحويل: إن كان العميل غاضباً، أو طلب التحدث إلى شخص، أو أثار أي أمر يتعلق باسترجاع مبلغ أو شكوى، فردّ بجملة تفهّم قصيرة واحدة ثم استدعِ أداة escalate_to_human.`
type Turn = { role: 'user' | 'assistant'; content: string }
const conversations = new Map<string, Turn[]>()
const MAX_TURNS = 20
export async function respond(from: string, message: string): Promise<string> {
const history = conversations.get(from) ?? []
const messages: Turn[] = [...history, { role: 'user', content: message }]
const response = await client.messages.create({
model: 'claude-opus-5',
max_tokens: 1024,
system: SYSTEM_PROMPT,
thinking: { type: 'adaptive' },
output_config: { effort: 'low' },
messages,
})
const reply = response.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim()
conversations.set(from, [
...messages,
{ role: 'assistant', content: reply },
].slice(-MAX_TURNS))
return reply || 'عذراً، لم أفهم رسالتك. هل يمكنك إعادة صياغتها؟'
}بعض الاختيارات المقصودة هنا.
effort: 'low' هو الصحيح لهذا النوع من العمل. ردود خدمة العملاء قصيرة والأسئلة نادراً ما تكون صعبة؛ والجهد المنخفض يمنحك إجابات سريعة ومحدودة النطاق بجزء يسير من الرموز. ارفعه إن كان على وكيلك أن يستنتج من كتالوج منتجات أو وثيقة سياسات.
التفكير التكيفي يبقى مفعّلاً. تكلفته ضئيلة عند الجهد المنخفض ويحسّن بوضوح حكم النموذج على متى لا يجيب — وهو أهم من الفصاحة في خدمة العملاء.
موجّه النظام يأمره بالاختصار. إن تُرك وحده، يكتب نموذج قادر رداً منظماً من ثلاث فقرات بعناوين. على واتساب يُقرأ ذلك كجدار نصّي ويتوقف العملاء عند السطر الثاني. تعليمات الطول والتنسيق الصريحة تؤدي عملاً حقيقياً هنا.
حالة المحادثة في الذاكرة مجدداً، وهذا مجدداً تسهيل لنسخة واحدة. احفظها بمفتاح رقم الجوال، وضع سياسة احتفاظ — فمحادثات واتساب تحتوي بيانات شخصية، ولدى نظام حماية البيانات السعودي (PDPL) والهيئة التونسية (INPDP) كلام في مدة الاحتفاظ بها.
الخطوة 8: التحويل إلى موظف بشري
وكيل ذكي لا يستطيع الاعتراف بالعجز أسوأ من عدم وجود وكيل أصلاً. مسار التصعيد هو ما يجعل العملاء يثقون بالجزء المؤتمت.
امنح النموذج أداة بدل الاعتماد على أن يكتب عبارة سحرية:
// lib/agent/tools.ts
export const escalateTool = {
name: 'escalate_to_human',
description:
'حوّل هذه المحادثة إلى زميل بشري. استدعِ هذه الأداة حين يطلب العميل صراحةً التحدث إلى شخص، أو يعبّر عن استياء، أو يثير أمر استرجاع مبلغ أو شكوى أو نزاعاً على حساب. لا تستدعها للأسئلة العادية التي يمكنك الإجابة عنها.',
input_schema: {
type: 'object' as const,
properties: {
reason: {
type: 'string' as const,
description: 'جملة قصيرة واحدة عن سبب حاجة هذه المحادثة إلى إنسان.',
},
urgency: {
type: 'string' as const,
enum: ['normal', 'high'],
},
},
required: ['reason', 'urgency'],
},
}لاحظ أن الوصف يذكر متى تُستدعى ومتى لا تُستدعى. نماذج Claude الحالية تتبع أوصاف الأدوات بدقة، ووصف يقول فقط "صعّد عند الحاجة" ينتج وكيلاً يصعّد باستمرار.
اقرن الأداة بفحص لساعات العمل حتى يكون الوعد الذي تقطعه صادقاً:
// lib/agent/hours.ts
// الرياض على UTC+3 طوال السنة — لا توقيت صيفي.
export function withinBusinessHours(now = new Date()): boolean {
const riyadhHour = (now.getUTCHours() + 3) % 24
const day = now.getUTCDay() // 0 الأحد ... 6 السبت
const isWeekend = day === 5 || day === 6 // الجمعة والسبت
return !isWeekend && riyadhHour >= 9 && riyadhHour < 18
}عطلة نهاية الأسبوع في الخليج هي الجمعة والسبت. إطلاق بوت يخبر عميلاً سعودياً مساء الخميس أن "فريقنا سيرد يوم الإثنين" خطأ صغير يُقرأ ككبير.
الخطوة 9: تجميع معالج POST
هنا يجتمع كل شيء — والترتيب أهم مما يبدو.
// app/api/whatsapp/webhook/route.ts (تتمة)
import { after } from 'next/server'
import { isValidSignature } from '@/lib/whatsapp/verify'
import { extractMessages } from '@/lib/whatsapp/parse'
import { alreadyHandled } from '@/lib/whatsapp/seen'
import { sendText, markAsRead } from '@/lib/whatsapp/send'
import { respond } from '@/lib/agent/whatsapp-agent'
export const runtime = 'nodejs'
export async function POST(request: Request) {
// 1. اقرأ الجسم نصاً مرة واحدة. حساب البصمة على كائن معاد التسلسل يفشل.
const raw = await request.text()
// 2. ارفض التزوير قبل أي عمل.
if (!isValidSignature(raw, request.headers.get('x-hub-signature-256'))) {
return new Response('Invalid signature', { status: 401 })
}
const messages = extractMessages(JSON.parse(raw))
// 3. نفّذ العمل البطيء بعد إرسال الاستجابة.
after(async () => {
for (const message of messages) {
if (alreadyHandled(message.wamid)) continue
try {
await markAsRead(message.wamid)
const reply = await respond(message.from, message.text)
await sendText(message.from, reply)
} catch (error) {
console.error('[whatsapp] handler failed', {
wamid: message.wamid,
error,
})
}
}
})
// 4. أكّد الاستلام فوراً.
return new Response(null, { status: 200 })
}القاعدة البنيوية: أكّد بسرعة، واعمل بعد ذلك. يتوقع Meta استجابة خلال ثوانٍ معدودة ويعيد المحاولة إن لم يحصل عليها. واستدعاء نموذج مع إرسال صادر يتجاوز هذه الميزانية بسهولة، وتنتج إعادة المحاولة رداً مكرراً فوق رد بطيء.
دالة after() في Next.js تشغّل رد النداء بعد دفع الاستجابة، وهذا بالضبط الشكل المطلوب. على منصات أخرى، ادفع الرسالة إلى طابور وأرجع 200 — النمط نفسه، تختلف الآلية فقط.
runtime = 'nodejs' مطلوب لا اختياري: دالة timingSafeEqual من node:crypto غير متاحة على بيئة Edge.
لاحظ أننا نبتلع الأخطاء داخل الحلقة بدل ترك رسالة واحدة سيئة تقتل الدفعة. فإرجاع استجابة غير 2xx من المعالج يخبر Meta بإعادة الحمولة كلها، بما فيها الرسائل التي أجبت عنها بالفعل.
اختبار التطبيق
تحقق من كل طبقة على حدة بدل اختبار السلسلة كاملة دفعة واحدة.
مصافحة التحقق — حاكِ ما يرسله Meta:
curl "https://your-domain.com/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=test123"
# المتوقع نصاً صرفاً: test123رفض التوقيع — أكّد رفض طلب غير موقّع:
curl -X POST https://your-domain.com/api/whatsapp/webhook \
-H 'content-type: application/json' \
-d '{"object":"whatsapp_business_account","entry":[]}'
# المتوقع: 401 Invalid signatureإن أعاد هذا 200 فإن فحص التوقيع لديك غير موصول، ونقطتك مفتوحة للإنترنت.
الإرسال الصادر — من الطرفية، متجاوزاً تطبيقك بالكامل:
curl -X POST "https://graph.facebook.com/v23.0/$WHATSAPP_PHONE_NUMBER_ID/messages" \
-H "Authorization: Bearer $WHATSAPP_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "966555555555",
"type": "text",
"text": { "body": "اختبار من الـ Cloud API" }
}'هذا يعزل مشاكل بيانات الاعتماد والصلاحيات عن أخطاء التطبيق. إن نجح الـ curl وفشل تطبيقك، فالمشكلة في شيفرتك لا في لوحة Meta.
من الطرف إلى الطرف — راسل رقم نشاطك من جوال حقيقي وراقب السجلات. أرسل بالعربية ثم بالإنجليزية ثم رسالة صوتية، وتأكد أن الوكيل يعالج كلاً منها كما أردت.
حل المشكلات
فشل التحقق من الويب هوك بلا خطأ مفيد. في تسعين بالمئة من الحالات تكون النقطة غير متاحة علناً، أو أعدتَ JSON بدل نص صرف. جرّب curl على رابط التحقق من خارج شبكتك أولاً.
كل فحوص التوقيع تفشل. غالباً تحسب البصمة على جسم معاد التسلسل. استدعِ await request.text() مرة واحدة، وتحقق من ذلك النص بالذات، ثم JSON.parse. وتأكد أيضاً أنك تستخدم App Secret لا رمز الوصول.
الخطأ 131047: رسالة إعادة تواصل. أُغلقت نافذة الـ24 ساعة. أرسل قالباً معتمداً بدل نص حر.
الخطأ 132000: عدم تطابق عدد المعاملات. عدد المتغيرات التي أرسلتها لا يطابق القالب المعتمد. عُدّ المواضع في النسخة التي اعتمدها Meta، لا في النسخة التي عندك.
الخطأ 100 مع "Unsupported post request." عادةً PHONE_NUMBER_ID خاطئ — كثيرون يلصقون معرّف حساب WhatsApp Business بدلاً منه. قيمتان مختلفتان تبدوان معقولتين بالقدر نفسه.
الرسائل تُرسل ولا تصل أبداً. راجع ويب هوكس statuses. حالة failed تحمل كائن خطأ يشرح السبب، وغالباً يكون أن المستقبل لم يراسل رقمك قط وأنك خارج النافذة.
ردود مكررة على رسالة عميل واحدة. معالجك بطيء وMeta يعيد المحاولة، أو منع التكرار لديك لكل نسخة بينما تشغّل عدة نسخ. أصلح زمن التأكيد أولاً، ثم انقل مخزن منع التكرار خارج الذاكرة.
الخطوات التالية
الامتدادات الطبيعية من هنا:
- الرسائل التفاعلية — الأزرار وقوائم الاختيار تقلّل غموض النص الحر كثيراً، وبالعربية تتجاوز اختلاف اللهجات تماماً. النقطة نفسها، مع
type: 'interactive'. - الرسائل الصوتية — فرّغ الصوت الوارد نصياً قبل تمريره للوكيل. في الأسواق الخليجية نسبة معتبرة من العملاء تفضّل الكلام على الكتابة.
- استخدام الأدوات مع أنظمتك الحقيقية — يصبح الوكيل مفيداً فعلاً حين يستطيع فحص مخزون حقيقي أو حالة طلب حقيقية. دليلنا عن ربط الذكاء الاصطناعي بأنظمة عملك القائمة يغطي طبقة التكامل هذه، وفخ الـ ERP يشرح لماذا يتفوق التكامل على الاستبدال.
- التنسيق منخفض الكود — إن كنت تفضّل تجميع هذا بصرياً بدل TypeScript، فدرسنا عن أتمتة الوكلاء المتعددين بـ n8n يبني تدفقات مماثلة داخل محرك سير عمل.
- الحفظ والامتثال — المحادثات بيانات شخصية. احسم مدة الاحتفاظ والتشفير والوصول قبل التوسع لا بعده.
وللصورة الاستراتيجية الأوسع لنشر الوكلاء في هذه المنطقة، راجع وكلاء الذكاء الاصطناعي لمؤسسات الشرق الأوسط وشمال أفريقيا.
الخلاصة
واجهة WhatsApp Cloud API ليست واجهة صعبة. هي نقطة REST موثّقة جيداً مع ويب هوك. ما يجعل مشاريع واتساب صعبة هو كل ما حولها: توثيق نشاط يستغرق أسبوعاً، ونافذة 24 ساعة تملي بنية مراسلتك كلها، وقوالب تحتاج اعتماداً قبل أن يخرج أول تذكير، وسطح محادثة عربي تعالجه الترجمة الآلية معالجة سيئة.
الشيفرة في هذا الدرس تغطي الأجزاء التي يخفيها عنك اشتراك SaaS — وهي بالضبط الأجزاء التي عليك امتلاكها حين يحتاج التكامل أن يصل إلى نظام مخزونك، أو أن يحترم التزامات إقامة البيانات لديك، أو أن يعالج لهجة لم يُدرَّب عليها نموذج المزوّد أصلاً.
إن كنت توازن بين البناء داخلياً وشراء منصة، فالسؤال الحاسم عادةً ليس التكلفة. السؤال هو هل تحتاج المحادثة أن تلامس أنظمة تسيطر عليها أنت. إن كانت كذلك، فستتحول المنصة إلى عنق زجاجة خلال ربع سنة.
تبني قناة واتساب لنشاط سعودي أو تونسي؟ تحدّث إلينا — سننظر في إعدادك الحالي ونخبرك بصراحة إن كان هذا تكاملاً من أسبوعين أم من شهرين، قبل أن يوقّع أحد أي شيء.