ForcePilot/backend/package/yuxi/channel/extensions/tencent_sms/outbound.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

160 lines
6.0 KiB
Python

from __future__ import annotations
import asyncio
import logging
from .client import TencentSmsClient
from .compliance import check_marketing_time_window, is_unsubscribed
from .frequency import SmsFrequencyGuard
from .security import check_allowlist
from .templates import SmsTemplateRegistry
from .types import NON_RETRYABLE_CODES, SendResult, SmsScene, TencentSmsAccount
logger = logging.getLogger(__name__)
MAX_RETRIES = 3
BASE_DELAY = 1.0
class TencentSmsOutboundAdapter:
def __init__(self):
self._clients: dict[str, TencentSmsClient] = {}
self._accounts: dict[str, TencentSmsAccount] = {}
self._frequency = SmsFrequencyGuard()
self._template_registry = SmsTemplateRegistry()
def set_client(self, account_id: str, client: TencentSmsClient, account: TencentSmsAccount):
self._clients[account_id] = client
self._accounts[account_id] = account
for tpl in account.templates:
self._template_registry.register(account_id, tpl)
def _resolve_client(self, account_id: str | None) -> tuple[TencentSmsClient, TencentSmsAccount]:
aid = account_id or next(iter(self._accounts), "default")
client = self._clients.get(aid)
account = self._accounts.get(aid)
if not client or not account:
raise RuntimeError("\u6ca1\u6709\u53ef\u7528\u7684\u817e\u8baf\u4e91\u77ed\u4fe1\u5ba2\u6237\u7aef")
return client, account
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> SendResult:
logger.warning(
"Tencent SMS \u4e0d\u652f\u6301\u76f4\u63a5 send_text\uff0c\u5c1d\u8bd5\u4f7f\u7528 notification \u573a\u666f\u6a21\u677f\u53d1\u9001"
)
return await self.send_by_template(
phone_number=target_id,
scene=SmsScene.NOTIFICATION,
template_params=[content],
account_id=account_id,
session_context=thread_id or "",
)
async def send_by_template(
self,
phone_number: str,
scene: SmsScene,
template_params: list[str],
account_id: str | None = None,
session_context: str = "",
) -> SendResult:
client, account = self._resolve_client(account_id)
normalized = await _normalize_phone(phone_number, client)
if not normalized.startswith("+"):
normalized = f"+86{normalized}"
try:
check_allowlist(account, normalized)
except PermissionError as e:
return SendResult(success=False, phone_number=normalized, code="ALLOWLIST", message=str(e))
allowed, reason = await self._frequency.check_and_record(normalized, scene, account)
if not allowed:
return SendResult(success=False, phone_number=normalized, code="RATE_LIMITED", message=reason)
if is_unsubscribed(normalized):
return SendResult(
success=False,
phone_number=normalized,
code="UNSUBSCRIBED",
message="\u8be5\u53f7\u7801\u5df2\u9000\u8ba2",
)
if scene == SmsScene.MARKETING:
allowed, reason = check_marketing_time_window()
if not allowed:
return SendResult(success=False, phone_number=normalized, code="MARKETING_TIME_LIMIT", message=reason)
binding = self._template_registry.resolve(account_id or "default", scene)
if not binding:
return SendResult(
success=False,
phone_number=normalized,
code="NO_TEMPLATE",
message=f"\u573a\u666f {scene.value} \u672a\u914d\u7f6e\u6a21\u677f",
)
if len(template_params) != binding.param_count:
return SendResult(
success=False,
phone_number=normalized,
code="PARAM_MISMATCH",
message=f"\u6a21\u677f\u9700\u8981 {binding.param_count} \u4e2a\u53d8\u91cf\uff0c\u4f20\u5165 {len(template_params)} \u4e2a",
)
for attempt in range(MAX_RETRIES):
try:
results = await client.send_sms(
phone_numbers=[normalized],
template_id=binding.template_id,
template_params=template_params,
sign_name=binding.sign_name,
session_context=session_context,
extend_code=account.extend_code,
sender_id=account.sender_id,
)
result = results[0]
if result.success:
return result
if result.code in NON_RETRYABLE_CODES:
return result
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(BASE_DELAY * (2**attempt))
except Exception as e:
if attempt == MAX_RETRIES - 1:
raise
logger.warning("send_by_template retry %d: %s", attempt + 1, e)
await asyncio.sleep(BASE_DELAY * (2**attempt))
return SendResult(success=False, code="MAX_RETRIES", message="\u5df2\u8fbe\u6700\u5927\u91cd\u8bd5\u6b21\u6570")
async def _normalize_phone(phone: str, client: TencentSmsClient | None = None) -> str:
p = phone.strip().replace(" ", "").replace("-", "")
if not p.startswith("+"):
p = f"+86{p}"
if client and not p.startswith("+86"):
try:
info_list = await client.describe_phone_number_info([p])
if info_list:
info = info_list[0]
nation_code = info.get("nation_code", "")
subscriber = info.get("subscriber_number", "")
if nation_code and subscriber:
return f"+{nation_code}{subscriber}"
except Exception as e:
logger.debug(
"\u53f7\u7801\u4fe1\u606f\u67e5\u8be2\u5931\u8d25\uff0c\u4f7f\u7528\u539f\u59cb\u53f7\u7801: %s", e
)
return p