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