Files
musadaq-saas/app/modules_app/whatsapp/webhook.php
2026-05-08 04:58:23 +03:00

260 lines
10 KiB
PHP

<?php
/**
* WhatsApp Bot Webhook
* POST /v1/whatsapp/webhook
*
* Receives incoming WhatsApp messages (text + images) via the proxy bot.
* Flow: User sends invoice image → Bot processes via AI → Returns extracted data.
*
* Supported commands:
* - Image/Document: Extracts invoice data via AI
* - "ربط [CODE]": Links WhatsApp number to Musadaq account
* - "حالتي" or "status": Returns account summary
* - "مساعدة" or "help": Returns command list
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\AI;
use App\Core\Encryption;
use App\Core\AuditLogger;
// No auth middleware — this is a webhook from the bot proxy
// Verify webhook secret instead
$webhookSecret = env('WHATSAPP_WEBHOOK_SECRET', '');
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
json_error('Unauthorized webhook', 401);
}
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
json_error('Invalid payload', 400);
}
$from = $body['from'] ?? ''; // Phone number (962XXXXXXXXX)
$text = $body['message']['text'] ?? '';
$imageUrl = $body['message']['image_url'] ?? null;
$imageData = $body['message']['image_base64'] ?? null;
$mimeType = $body['message']['mime_type'] ?? 'image/jpeg';
if (empty($from)) {
json_error('Missing sender number', 400);
}
$db = Database::getInstance();
$wa = new \App\Services\WhatsAppProxyService();
try {
// 1. Look up linked account by phone hash
$phoneClean = preg_replace('/[^0-9+]/', '', $from);
$phoneHash = hash('sha256', $phoneClean);
$stmt = $db->prepare("SELECT u.id, u.tenant_id, u.name, u.role FROM users u WHERE u.phone_hash = ? AND u.is_active = 1 LIMIT 1");
$stmt->execute([$phoneHash]);
$user = $stmt->fetch();
// 2. Handle commands
$textLower = mb_strtolower(trim($text));
// === LINK COMMAND ===
if (str_starts_with($textLower, 'ربط ') || str_starts_with($textLower, 'link ')) {
$code = trim(str_replace(['ربط', 'link'], '', $text));
handleLinkCommand($db, $wa, $from, $phoneHash, $code);
exit;
}
// === HELP COMMAND ===
if (in_array($textLower, ['مساعدة', 'help', '؟', '?'])) {
$wa->sendMessage($from, "🤖 *أوامر مُصادَق:*\n\n"
. "📸 أرسل صورة فاتورة → نستخرج البيانات بالـ AI\n"
. "🔗 ربط [الكود] → لربط رقمك بحسابك\n"
. "📊 حالتي → ملخص حسابك\n"
. "❓ مساعدة → هذه الرسالة\n\n"
. "للتسجيل: musadaq.intaleqapp.com");
json_success(null, 'Help sent');
exit;
}
// === ACCOUNT NOT LINKED ===
if (!$user) {
$wa->sendMessage($from, "👋 مرحباً!\n\n"
. "رقمك غير مربوط بحساب مُصادَق.\n"
. "لربط حسابك، أرسل: *ربط [الكود]*\n\n"
. "للحصول على الكود، افتح تطبيق مُصادَق → الإعدادات → ربط واتساب.\n\n"
. "أو سجّل حساب جديد: musadaq.intaleqapp.com");
json_success(null, 'Unlinked user guided');
exit;
}
$userName = Encryption::decrypt($user['name']) ?: 'المستخدم';
// === STATUS COMMAND ===
if (in_array($textLower, ['حالتي', 'status', 'حالة'])) {
handleStatusCommand($db, $wa, $from, $user, $userName);
exit;
}
// === IMAGE/INVOICE PROCESSING ===
if ($imageData || $imageUrl) {
handleInvoiceImage($db, $wa, $from, $user, $userName, $imageData, $imageUrl, $mimeType);
exit;
}
// === DEFAULT: Unknown text ===
$wa->sendMessage($from, "مرحباً {$userName} 👋\n\n"
. "لم أفهم طلبك. يمكنك:\n"
. "📸 إرسال صورة فاتورة لاستخراج البيانات\n"
. "📊 كتابة *حالتي* لملخص حسابك\n"
. "❓ كتابة *مساعدة* لقائمة الأوامر");
json_success(null, 'Default response sent');
} catch (\Throwable $e) {
error_log("[whatsapp/webhook] Error: " . $e->getMessage());
try {
$wa->sendMessage($from, "⚠️ حدث خطأ أثناء المعالجة. يرجى المحاولة مرة أخرى.");
} catch (\Throwable $ignore) {}
json_success(null, 'Error handled'); // Return 200 so the bot doesn't retry
}
// ═══════════════════════════════════════════
// HANDLER FUNCTIONS
// ═══════════════════════════════════════════
function handleLinkCommand($db, $wa, string $from, string $phoneHash, string $code): void
{
if (empty($code)) {
$wa->sendMessage($from, "❌ يرجى إرسال الكود. مثال: *ربط ABC123*");
json_success(null, 'Empty code');
return;
}
// Find user by link code
$stmt = $db->prepare("SELECT id, tenant_id FROM users WHERE whatsapp_link_code = ? AND is_active = 1 LIMIT 1");
$stmt->execute([strtoupper(trim($code))]);
$targetUser = $stmt->fetch();
if (!$targetUser) {
$wa->sendMessage($from, "❌ الكود غير صحيح. تأكد من الكود في تطبيق مُصادَق → الإعدادات → ربط واتساب.");
json_success(null, 'Invalid code');
return;
}
// Update user's phone hash
$updateStmt = $db->prepare("UPDATE users SET phone_hash = ?, whatsapp_linked = 1, whatsapp_link_code = NULL WHERE id = ?");
$updateStmt->execute([$phoneHash, $targetUser['id']]);
$wa->sendMessage($from, "✅ تم ربط رقمك بحسابك بنجاح! 🎉\n\n"
. "الآن يمكنك إرسال صور الفواتير مباشرة هنا وسنستخرج البيانات تلقائياً.");
json_success(null, 'Account linked');
}
function handleStatusCommand($db, $wa, string $from, array $user, string $userName): void
{
$tenantId = $user['tenant_id'];
// Get stats
$invoiceStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) as pending FROM invoices WHERE tenant_id = ?");
$invoiceStmt->execute([$tenantId]);
$stats = $invoiceStmt->fetch();
$subStmt = $db->prepare("SELECT plan_slug, invoices_used_this_month, max_invoices_per_month FROM subscriptions WHERE tenant_id = ?");
$subStmt->execute([$tenantId]);
$sub = $subStmt->fetch();
$plan = $sub['plan_slug'] ?? 'free';
$used = $sub['invoices_used_this_month'] ?? 0;
$max = $sub['max_invoices_per_month'] ?? 15;
$msg = "📊 *ملخص حسابك، {$userName}:*\n\n"
. "📋 إجمالي الفواتير: {$stats['total']}\n"
. "⏳ بانتظار المراجعة: {$stats['pending']}\n"
. "📦 الباقة: {$plan}\n"
. "🔢 الاستخدام: {$used}/{$max} فاتورة هذا الشهر\n\n"
. "🌐 لوحة التحكم: musadaq.intaleqapp.com";
$wa->sendMessage($from, $msg);
json_success(null, 'Status sent');
}
function handleInvoiceImage($db, $wa, string $from, array $user, string $userName, ?string $imageData, ?string $imageUrl, string $mimeType): void
{
$wa->sendMessage($from, "📸 استلمت الصورة! جارٍ استخراج البيانات بالذكاء الاصطناعي... ⏳");
// Get image data
if (!$imageData && $imageUrl) {
$imageContent = @file_get_contents($imageUrl);
if (!$imageContent) {
$wa->sendMessage($from, "❌ فشل تحميل الصورة. يرجى إرسالها مرة أخرى.");
json_success(null, 'Image download failed');
return;
}
$imageData = base64_encode($imageContent);
}
if (!$imageData) {
$wa->sendMessage($from, "❌ لم أتمكن من قراءة الصورة.");
json_success(null, 'No image data');
return;
}
// Run AI extraction
$extracted = AI::extractInvoiceData($imageData, $mimeType);
if (!$extracted) {
$wa->sendMessage($from, "⚠️ لم أتمكن من استخراج البيانات. تأكد أن الصورة واضحة وتحتوي على فاتورة.");
json_success(null, 'AI extraction failed');
return;
}
// Format response
$supplierName = $extracted['supplier']['name'] ?? 'غير محدد';
$invoiceNum = $extracted['invoice_number'] ?? '-';
$invoiceDate = $extracted['invoice_date'] ?? '-';
$subtotal = number_format((float)($extracted['subtotal'] ?? 0), 2);
$tax = number_format((float)($extracted['tax_amount'] ?? 0), 2);
$total = number_format((float)($extracted['grand_total'] ?? 0), 2);
$linesCount = count($extracted['lines'] ?? []);
$msg = "✅ *تم استخراج بيانات الفاتورة:*\n\n"
. "🏢 المورد: {$supplierName}\n"
. "🔢 رقم الفاتورة: {$invoiceNum}\n"
. "📅 التاريخ: {$invoiceDate}\n"
. "📦 البنود: {$linesCount}\n"
. "───────────────\n"
. "💰 المبلغ قبل الضريبة: {$subtotal} دينار\n"
. "🏛️ الضريبة: {$tax} دينار\n"
. "📊 *الإجمالي: {$total} دينار*\n\n";
// Add warnings if any
if (!empty($extracted['validation_warnings'])) {
$msg .= "⚠️ *تحذيرات:*\n";
foreach ($extracted['validation_warnings'] as $w) {
$msg .= "{$w}\n";
}
$msg .= "\n";
}
$msg .= "💡 لحفظ هذه الفاتورة رسمياً، ارفعها من تطبيق مُصادَق.";
$wa->sendMessage($from, $msg);
// Log the interaction
try {
AuditLogger::log('whatsapp.invoice_extracted', 'whatsapp', null, null, [
'from' => substr($from, 0, 6) . '****',
'invoice_number' => $invoiceNum,
'total' => $total,
], ['user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role']]);
} catch (\Throwable $e) {
// Non-critical
}
json_success(null, 'Invoice extracted via WhatsApp');
}