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