240 lines
9.2 KiB
Python
Executable File
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() |