Files
SmartUp/backend/app/services/webhook_service.py
T
liumangmang 7adc7c00ab Add remote browser pages and website sync
Enable managed remote browser custom pages with login autofill and add website sync workflows so external admin surfaces can be handled inside SmartUp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 15:43:58 +08:00

208 lines
6.4 KiB
Python

"""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,
)
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_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)