Files
2026-08-09 16:56:13 +03:00

148 lines
6.5 KiB
PHP

<?php
/**
* sms/outbox_pull.php — نقطة سحب الرسائل الصادرة
* ─────────────────────────────────────────────────────────────
* ‏تطبيق الأندرويد على الهاتف الحامل لرقمنا يستدعي هذه النقطة كل بضع
* ‏ثوانٍ: يسحب ما ينتظر الإرسال، يرسله عبر شريحته، ثم يبلّغ بالنتيجة.
*
* ‏لماذا سحب لا دفع: الهاتف خلف شبكة الجوّال بلا عنوان ثابت ولا منفذ
* ‏مفتوح، فلا سبيل للسيرفر أن يبادره. والسحب يجعل انقطاع الهاتف حالة
* ‏طبيعية — تتراكم الرسائل في الطابور وتُرسل حين يعود، بلا فقدان.
*
* ‏العمليتان:
* GET/POST action=pull → يحجز رسائل وينقلها إلى 'sending'
* POST action=ack → يبلّغ بنجاح أو فشل ما أُرسل
*
* ‏المصادقة: نفس مفتاح webhook_sms — الهاتف واحد والمفتاح واحد.
*/
require_once __DIR__ . '/../core/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
// ── المصادقة ────────────────────────────────────────────────
$secretKeyPath = getenv('WEBHOOK_SECRET_KEY_PATH');
$secretKey = '';
if ($secretKeyPath && file_exists($secretKeyPath)) {
$secretKey = trim(file_get_contents($secretKeyPath));
}
if ($secretKey === '') {
$secretKey = getenv('WEBHOOK_SECRET_KEY') ?: '';
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($secretKey === '' || !hash_equals('Bearer ' . $secretKey, $authHeader)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
try {
$con = Database::get('main');
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'DB unavailable']);
exit;
}
$payload = json_decode(file_get_contents('php://input'), true) ?: [];
$action = $_GET['action'] ?? ($payload['action'] ?? 'pull');
$device = mb_substr((string) ($payload['device'] ?? $_GET['device'] ?? 'phone-1'), 0, 64);
// ‏عدد الرسائل في السحبة الواحدة. صغير عمداً: الهاتف يرسل بالتسلسل
// ‏عبر شريحة واحدة، وحجز خمسين رسالة يعني بقاء آخرها محجوزة دقائق.
const SMS_PULL_BATCH = 10;
// ‏رسالة بقيت 'sending' أطول من هذا يُفترض أن الهاتف مات قبل تبليغنا،
// ‏فنعيدها للطابور. الرقم أكبر بكثير من زمن إرسال دفعة كاملة.
const SMS_CLAIM_TIMEOUT_MINUTES = 10;
// ‏الحد الأقصى لمحاولات الرسالة الواحدة — رقم معطّل لن يستقبل مهما
// ‏كررنا، وإبقاؤه في الطابور يعطّل ما خلفه.
const SMS_MAX_ATTEMPTS = 3;
if ($action === 'ack') {
// ═══ التبليغ بالنتيجة ═══════════════════════════════════
$results = $payload['results'] ?? [];
if (!is_array($results)) $results = [];
$ok = 0; $bad = 0;
foreach ($results as $r) {
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) continue;
if (!empty($r['sent'])) {
$con->prepare("
UPDATE sms_outbox SET status='sent', sent_at=NOW()
WHERE id = ? AND status = 'sending'
")->execute([$id]);
$ok++;
} else {
// ‏فشل: نعيدها للطابور ما لم تستنفد محاولاتها.
$con->prepare("
UPDATE sms_outbox
SET status = IF(attempts >= " . SMS_MAX_ATTEMPTS . ", 'failed', 'pending'),
last_error = ?, claimed_by = NULL, claimed_at = NULL
WHERE id = ? AND status = 'sending'
")->execute([mb_substr((string) ($r['error'] ?? 'unknown'), 0, 255), $id]);
$bad++;
}
}
echo json_encode(['status' => 'success', 'acked' => $ok, 'failed' => $bad]);
exit;
}
// ═══ السحب ══════════════════════════════════════════════════
// ‏أولاً: تحرير ما عَلِق. هاتف انطفأ بعد الحجز وقبل الإرسال كان
// ‏سيحبس رسائله إلى الأبد.
try {
$con->prepare("
UPDATE sms_outbox
SET status='pending', claimed_by=NULL, claimed_at=NULL
WHERE status='sending'
AND claimed_at < NOW() - INTERVAL " . SMS_CLAIM_TIMEOUT_MINUTES . " MINUTE
")->execute();
} catch (PDOException $e) {
error_log('[sms/outbox] تعذّر تحرير العالق: ' . $e->getMessage());
}
// ‏الحجز ثم القراءة، لا العكس: قراءة أولاً ثم تحديث تسمح لهاتفين
// ‏(أو لسحبتين متتاليتين) بأخذ نفس الرسالة وإرسالها مرتين.
// ‏وسم فريد لكل سحبة، لا اسم الجهاز وحده: لو سحب الهاتف دفعةً ولم
// ‏يبلّغ عنها بعد، ثم سحب ثانيةً، لأعاد الاستعلام الدفعة الأولى معها
// ‏وأُرسلت رسائلها مرتين.
$claim = mb_substr($device . ':' . bin2hex(random_bytes(6)), 0, 64);
try {
$con->prepare("
UPDATE sms_outbox
SET status='sending', claimed_by=?, claimed_at=NOW(), attempts=attempts+1
WHERE status='pending'
ORDER BY id ASC
LIMIT " . SMS_PULL_BATCH
)->execute([$claim]);
$stmt = $con->prepare("
SELECT id, recipient, body, context, ref_id
FROM sms_outbox
WHERE status='sending' AND claimed_by=?
ORDER BY id ASC
LIMIT " . SMS_PULL_BATCH
);
$stmt->execute([$claim]);
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[sms/outbox] فشل السحب: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Query failed']);
exit;
}
echo json_encode([
'status' => 'success',
'count' => count($messages),
'messages' => $messages,
], JSON_UNESCAPED_UNICODE);