#!/usr/bin/env python3 """ СуперСам — Web Push Sender Отправляет push-уведомления подписанным клиентам. Каскад: push → 30 мин ждём → fallback на SMS. Usage: python3 push_sender.py --phone 7XXX --title "Заголовок" --body "Текст" --url https://dev.mkn8n.ru/delivery/xxx python3 push_sender.py --phone 7XXX --title "test" --body "test" --url https://dev.mkn8n.ru/ --order-group-id UUID """ import os import sys import json import logging import argparse from datetime import datetime, timezone import psycopg2 from psycopg2.extras import RealDictCursor try: from pywebpush import webpush, WebPushException except ImportError: print("ERROR: pywebpush not installed. Run: pip3 install pywebpush", file=sys.stderr) sys.exit(1) # ─── Конфигурация ──────────────────────────────────────────────────────────── # Supabase PostgreSQL container; override with DB_HOST in other environments. DB_HOST = os.environ.get("DB_HOST", "10.0.4.14") DB_PORT = os.environ.get("DB_PORT", "5432") DB_NAME = os.environ.get("DB_NAME", "postgres") DB_USER = os.environ.get("DB_USER", "supabase_admin") DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479") VAPID_SUBJECT = os.environ.get("VAPID_SUBJECT", "mailto:admin@supersam.ru") LOG_FILE = "/var/log/supersam-push.log" logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout), ], ) log = logging.getLogger("push_sender") # ─── БД ────────────────────────────────────────────────────────────────────── def get_db_conn(): return psycopg2.connect( host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS, ) def load_vapid_keys(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT key, value FROM app_settings WHERE key IN ('vapid_public_key', 'vapid_private_key')") rows = {r["key"]: r["value"] for r in cur.fetchall()} public_key = rows.get("vapid_public_key") private_key = rows.get("vapid_private_key") if not public_key or not private_key: raise RuntimeError("VAPID keys not found in app_settings table") return public_key, private_key def get_active_subscriptions(conn, phone_normalized): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT id, endpoint, p256dh, auth, phone_normalized FROM push_subscriptions WHERE phone_normalized = %s AND is_active = true ORDER BY created_at DESC """, (phone_normalized,)) return [dict(r) for r in cur.fetchall()] def insert_push_log(conn, **kwargs): with conn.cursor() as cur: cols = ", ".join(kwargs.keys()) placeholders = ", ".join(["%s"] * len(kwargs)) cur.execute(f"INSERT INTO push_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values())) log_id = cur.fetchone()[0] conn.commit() return log_id def deactivate_subscription(conn, sub_id): with conn.cursor() as cur: cur.execute("UPDATE push_subscriptions SET is_active = false WHERE id = %s", (sub_id,)) conn.commit() log.info(f"Subscription {sub_id} deactivated (expired/invalid)") def update_subscription_sent(conn, sub_id): with conn.cursor() as cur: cur.execute("UPDATE push_subscriptions SET last_sent_at = NOW() WHERE id = %s", (sub_id,)) conn.commit() # ─── Push отправка ──────────────────────────────────────────────────────────── def build_payload(title, body, url, order_group_id=None): return json.dumps({ "title": title, "body": body, "url": url, "order_group_id": order_group_id, "icon": "/icons/icon-512.png", "badge": "/icons/icon-512.png", "vibrate": [200, 100, 200], }) def send_push(phone_normalized, title, body, url, order_group_id=None, conn=None): """Отправляет push-уведомление всем активным подпискам клиента. Returns: { sent, failed, deactivated, total, details } """ own_conn = conn is None if own_conn: conn = get_db_conn() try: public_key, private_key = load_vapid_keys(conn) subscriptions = get_active_subscriptions(conn, phone_normalized) if not subscriptions: log.info(f"No active push subscriptions for {phone_normalized}") return {"sent": 0, "failed": 0, "deactivated": 0, "total": 0, "details": []} payload = build_payload(title, body, url, order_group_id) sent = 0 failed = 0 deactivated = 0 details = [] for sub in subscriptions: sub_id = str(sub["id"]) endpoint = sub["endpoint"] p256dh = sub["p256dh"] auth = sub["auth"] log.info(f"Sending push to {phone_normalized} via {endpoint[:60]}...") try: webpush( subscription_info={ "endpoint": endpoint, "keys": {"p256dh": p256dh, "auth": auth}, }, data=payload, vapid_private_key=private_key, vapid_claims={"sub": VAPID_SUBJECT}, ttl=86400, ) sent += 1 update_subscription_sent(conn, sub["id"]) insert_push_log(conn, subscription_id=sub["id"], order_group_id=order_group_id, phone_normalized=phone_normalized, title=title, body=body, url=url, status="sent", ) details.append({"sub_id": sub_id, "status": "sent"}) log.info(f"Push sent to {phone_normalized} (sub {sub_id})") except WebPushException as e: failed += 1 status_code = e.response.status_code if e.response else None error_msg = str(e)[:500] if status_code in (410, 404): deactivate_subscription(conn, sub["id"]) deactivated += 1 log.warning(f"Subscription {sub_id} expired ({status_code}), deactivated") else: log.error(f"Push failed for {phone_normalized} (sub {sub_id}): {error_msg}") insert_push_log(conn, subscription_id=sub["id"], order_group_id=order_group_id, phone_normalized=phone_normalized, title=title, body=body, url=url, status="failed", error_message=error_msg, ) details.append({"sub_id": sub_id, "status": "failed", "code": status_code, "error": error_msg}) except Exception as e: failed += 1 error_msg = str(e)[:500] log.error(f"Push unexpected error for {phone_normalized}: {error_msg}") insert_push_log(conn, subscription_id=sub["id"], order_group_id=order_group_id, phone_normalized=phone_normalized, title=title, body=body, url=url, status="failed", error_message=error_msg, ) details.append({"sub_id": sub_id, "status": "error", "error": error_msg}) return {"sent": sent, "failed": failed, "deactivated": deactivated, "total": len(subscriptions), "details": details} finally: if own_conn: conn.close() # ─── CLI ───────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="SuperSam Push Sender") parser.add_argument("--phone", required=True, help="Phone normalized (7XXXXXXXXXX)") parser.add_argument("--title", required=True, help="Notification title") parser.add_argument("--body", required=True, help="Notification body text") parser.add_argument("--url", required=True, help="URL to open on click") parser.add_argument("--order-group-id", default=None, help="Order group UUID") args = parser.parse_args() log.info(f"Sending push: phone={args.phone}, title={args.title}, url={args.url}") result = send_push(args.phone, args.title, args.body, args.url, args.order_group_id) log.info(f"Result: {result}") print(json.dumps(result, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()