ForcePilot/backend/package/yuxi/channel/extensions/tencent_sms/webhook.py
Kris 944c9cdbb6 feat(channel): 添加腾讯短信渠道扩展
新增腾讯短信(Tencent SMS)渠道扩展,支持在 Yuxi 平台中集成腾讯云短信渠道。

包含以下功能模块:
- client: 腾讯云短信 API 客户端封装
- plugin: 渠道插件核心
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- security: 安全校验
- dedupe: 消息去重
- delivery: 送达状态回调
- compliance: 合规管理
- frequency: 频率控制
- templates: 短信模板
- agent_prompt: Agent 提示词
- types: 类型定义
2026-05-21 11:50:32 +08:00

125 lines
5.0 KiB
Python

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)