新增腾讯短信(Tencent SMS)渠道扩展,支持在 Yuxi 平台中集成腾讯云短信渠道。 包含以下功能模块: - client: 腾讯云短信 API 客户端封装 - plugin: 渠道插件核心 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - security: 安全校验 - dedupe: 消息去重 - delivery: 送达状态回调 - compliance: 合规管理 - frequency: 频率控制 - templates: 短信模板 - agent_prompt: Agent 提示词 - types: 类型定义
125 lines
5.0 KiB
Python
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)
|