'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);