148 lines
6.5 KiB
PHP
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);
|