"""Send webhook notifications and write notification logs.""" from __future__ import annotations import json from datetime import datetime, timezone from typing import Any import httpx from sqlalchemy.orm import Session from app.models.webhook_config import WebhookConfig from app.models.notification_log import NotificationLog from app.utils.dingtalk import ( dingtalk_signed_url, format_dingtalk_rate_changed, format_dingtalk_website_rate_changed, format_dingtalk_status, format_dingtalk_balance_low, format_dingtalk_priority_changed, ) def _now_iso() -> str: return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") def _log( db: Session, webhook: WebhookConfig, event_type: str, payload: dict[str, Any], status: str, response_text: str, ) -> None: entry = NotificationLog( webhook_config_id=webhook.id, webhook_name=webhook.name, event_type=event_type, payload_json=json.dumps(payload, ensure_ascii=False), status=status, response_text=response_text[:2000] if response_text else None, ) db.add(entry) db.commit() def _send_generic(url: str, payload: dict[str, Any], timeout: float = 15.0) -> str: resp = httpx.post( url, json=payload, headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"}, timeout=timeout, ) resp.raise_for_status() return resp.text[:500] def _send_dingtalk(url: str, secret: str, payload: dict[str, Any], timeout: float = 15.0) -> str: signed = dingtalk_signed_url(url, secret) if secret else url resp = httpx.post( signed, json=payload, headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"}, timeout=timeout, ) resp.raise_for_status() result = resp.json() if result.get("errcode", 0) != 0: raise RuntimeError(f"DingTalk error: {resp.text[:300]}") return resp.text[:500] def send_rate_changed( db: Session, upstream_id: int, upstream_name: str, base_url: str, changes: list[dict[str, Any]], ) -> None: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.enabled == True) .all() ) changed_at = _now_iso() generic_payload = { "event": "upstream_rate_changed", "upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url}, "changed_at": changed_at, "changes": changes, } for wh in webhooks: events = json.loads(wh.events_json or "[]") if "upstream_rate_changed" not in events: continue try: if wh.type == "dingtalk": msg = format_dingtalk_rate_changed(upstream_name, changed_at, changes) resp_text = _send_dingtalk(wh.url, wh.secret, msg) else: resp_text = _send_generic(wh.url, generic_payload) _log(db, wh, "upstream_rate_changed", generic_payload, "success", resp_text) except Exception as exc: _log(db, wh, "upstream_rate_changed", generic_payload, "failed", str(exc)) def send_website_rate_changed( db: Session, website_id: int, website_name: str, base_url: str, binding_id: int, target_group_id: str, target_group_name: str, old_rate: Any, new_rate: Any, source_rates: list[dict[str, Any]], ) -> None: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.enabled == True) .all() ) changed_at = _now_iso() generic_payload = { "event": "website_rate_changed", "website": {"id": website_id, "name": website_name, "base_url": base_url}, "binding": {"id": binding_id}, "target_group": { "id": target_group_id, "name": target_group_name, "old_rate": old_rate, "new_rate": new_rate, }, "source_rates": source_rates, "changed_at": changed_at, } for wh in webhooks: events = json.loads(wh.events_json or "[]") if "website_rate_changed" not in events: continue try: if wh.type == "dingtalk": msg = format_dingtalk_website_rate_changed( website_name, target_group_name, changed_at, old_rate, new_rate ) resp_text = _send_dingtalk(wh.url, wh.secret, msg) else: resp_text = _send_generic(wh.url, generic_payload) _log(db, wh, "website_rate_changed", generic_payload, "success", resp_text) except Exception as exc: _log(db, wh, "website_rate_changed", generic_payload, "failed", str(exc)) def send_status_event( db: Session, upstream_id: int, upstream_name: str, base_url: str, event: str, error: str = "", ) -> None: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.enabled == True) .all() ) changed_at = _now_iso() generic_payload = { "event": event, "upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url}, "changed_at": changed_at, "error": error, } for wh in webhooks: events = json.loads(wh.events_json or "[]") if event not in events: continue try: if wh.type == "dingtalk": msg = format_dingtalk_status(upstream_name, event, changed_at, error) resp_text = _send_dingtalk(wh.url, wh.secret, msg) else: resp_text = _send_generic(wh.url, generic_payload) _log(db, wh, event, generic_payload, "success", resp_text) except Exception as exc: _log(db, wh, event, generic_payload, "failed", str(exc)) def send_balance_low( db: Session, upstream_id: int, upstream_name: str, base_url: str, balance: float, threshold: float, ) -> None: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.enabled == True) .all() ) event = "upstream_balance_low" changed_at = _now_iso() generic_payload = { "event": event, "upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url}, "balance": balance, "threshold": threshold, "changed_at": changed_at, } for wh in webhooks: events = json.loads(wh.events_json or "[]") if event not in events: continue try: if wh.type == "dingtalk": msg = format_dingtalk_balance_low(upstream_name, balance, threshold, changed_at) resp_text = _send_dingtalk(wh.url, wh.secret, msg) else: resp_text = _send_generic(wh.url, generic_payload) _log(db, wh, event, generic_payload, "success", resp_text) except Exception as exc: _log(db, wh, event, generic_payload, "failed", str(exc)) def send_account_priority_changed( db: Session, website_id: int, website_name: str, upstream_id: int, upstream_name: str, updates: list[dict], ) -> None: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.enabled == True) .all() ) event = "account_priority_changed" changed_at = _now_iso() success = sum(1 for u in updates if u.get("status") == "success") failed = sum(1 for u in updates if u.get("status") == "failed") skipped = sum(1 for u in updates if u.get("status") == "skipped") generic_payload = { "event": event, "website": {"id": website_id, "name": website_name}, "upstream": {"id": upstream_id, "name": upstream_name}, "changed_at": changed_at, "updates": updates, "summary": {"total": len(updates), "success": success, "failed": failed, "skipped": skipped}, } for wh in webhooks: events = json.loads(wh.events_json or "[]") if event not in events: continue try: if wh.type == "dingtalk": msg = format_dingtalk_priority_changed(website_name, upstream_name, changed_at, updates) resp_text = _send_dingtalk(wh.url, wh.secret, msg) else: resp_text = _send_generic(wh.url, generic_payload) _log(db, wh, event, generic_payload, "success", resp_text) except Exception as exc: _log(db, wh, event, generic_payload, "failed", str(exc)) def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]: payload = { "event": "test", "message": "SmartUp webhook test notification", "sent_at": _now_iso(), } try: if webhook.type == "dingtalk": msg = { "msgtype": "text", "text": {"content": "✅ SmartUp webhook 测试通知\n配置正常,连接成功。"}, } resp_text = _send_dingtalk(webhook.url, webhook.secret, msg) else: resp_text = _send_generic(webhook.url, payload) _log(db, webhook, "test", payload, "success", resp_text) return True, "发送成功" except Exception as exc: _log(db, webhook, "test", payload, "failed", str(exc)) return False, str(exc)