supersam/scripts/push_sender.py

240 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SuperSam — 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/manifest-192.png",
"badge": "/icons/manifest-192.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()