from __future__ import annotations import logging from fastapi import APIRouter, Request, Response from .compliance import add_unsubscribe, detect_opt_out, remove_unsubscribe from .dedupe import TencentSmsDeduplicator from .delivery import SmsDeliveryTracker logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/tencent-sms", tags=["tencent-sms-webhook"]) class TencentSmsWebhookHandler: def __init__(self, allowed_ips: list[str] | None = None, delivery_tracker: SmsDeliveryTracker | None = None): self._allowed_ips = set(allowed_ips or []) self._delivery_tracker = delivery_tracker self._status_dedupe = TencentSmsDeduplicator(max_size=10000, ttl_seconds=3600) self._reply_dedupe = TencentSmsDeduplicator(max_size=10000, ttl_seconds=3600) async def handle_status_callback(self, request: Request) -> Response: client_ip = request.client.host if request.client else "" if self._allowed_ips and client_ip not in self._allowed_ips: logger.warning("\u72b6\u6001\u56de\u8c03 IP \u4e0d\u5728\u767d\u540d\u5355: %s", client_ip) return Response(status_code=403) try: body = await request.json() except Exception as e: logger.warning("\u72b6\u6001\u56de\u8c03 JSON \u89e3\u6790\u5931\u8d25: %s", e) return Response(status_code=400) items = body if isinstance(body, list) else [body] for item in items: if item.get("Type") != 0: continue serial_no = item.get("SerialNo", "") report_status = item.get("ReportStatus", "") dedupe_key = f"status:{serial_no}:{report_status}" if self._status_dedupe.is_duplicate(dedupe_key): continue logger.info( "\u9001\u8fbe\u72b6\u6001: serial=%s, status=%s, phone=%s, desc=%s", serial_no, report_status, item.get("PhoneNumber", ""), item.get("Description", ""), ) if self._delivery_tracker: self._delivery_tracker.handle_status_callback( { "SerialNo": serial_no, "ReportStatus": report_status, "PhoneNumber": item.get("PhoneNumber", ""), "Description": item.get("Description", ""), "ReportTime": item.get("ReportTime", ""), "SessionContext": item.get("SessionContext", ""), } ) return Response(content='{"result":0,"errmsg":"OK"}', media_type="application/json") async def handle_reply_callback(self, request: Request) -> Response: client_ip = request.client.host if request.client else "" if self._allowed_ips and client_ip not in self._allowed_ips: logger.warning("\u56de\u590d\u56de\u8c03 IP \u4e0d\u5728\u767d\u540d\u5355: %s", client_ip) return Response(status_code=403) try: body = await request.json() except Exception as e: logger.warning("\u56de\u590d\u56de\u8c03 JSON \u89e3\u6790\u5931\u8d25: %s", e) return Response(status_code=400) items = body if isinstance(body, list) else [body] for item in items: if item.get("Type") != 1: continue phone = item.get("PhoneNumber", "") content = item.get("ReplyContent", "") reply_time = item.get("ReplyTime", "") dedupe_key = f"reply:{phone}:{reply_time}" if self._reply_dedupe.is_duplicate(dedupe_key): continue is_opt, response_text = detect_opt_out(content) if is_opt: content_lower = content.strip().lower() if content_lower in ("t", "td", "\u9000\u8ba2", "\u53d6\u6d88", "n", "no", "qx"): add_unsubscribe(phone) elif content_lower in ("start", "\u8ba2\u9605", "dy", "y", "yes"): remove_unsubscribe(phone) logger.info("Opt-out/opt-in: phone=%s, content=%s, response=%s", phone, content, response_text) logger.info("\u4e0a\u884c\u56de\u590d: phone=%s, content=%s, time=%s", phone, content, reply_time) return Response(content='{"result":0,"errmsg":"OK"}', media_type="application/json") _webhook_handler: TencentSmsWebhookHandler | None = None def set_webhook_handler(handler: TencentSmsWebhookHandler) -> None: global _webhook_handler _webhook_handler = handler def get_webhook_handler() -> TencentSmsWebhookHandler: if _webhook_handler is None: return TencentSmsWebhookHandler() return _webhook_handler @router.post("/callback") async def status_callback(request: Request): return await get_webhook_handler().handle_status_callback(request) @router.post("/reply-callback") async def reply_callback(request: Request): return await get_webhook_handler().handle_reply_callback(request)