from __future__ import annotations from yuxi.channel.capabilities import ChannelCapabilities from yuxi.channel.errors import ClassifiedError, ErrorSeverity from yuxi.channel.extensions.base import BaseChannelPlugin from .agent_prompt import TencentSmsAgentPrompt from .config import TencentSmsConfigAdapter from .dedupe import TencentSmsDeduplicator from .errors import TencentSmsErrorCode, classify_sms_error, is_retryable as sms_is_retryable from .gateway import TencentSmsGatewayAdapter from .outbound import TencentSmsOutboundAdapter from .security import check_allowlist as sms_check_allowlist, resolve_dm_policy from .streaming import TencentSmsBlockStreamer from .templates import SmsTemplateRegistry from .webhook import get_webhook_handler class TencentSmsPlugin(BaseChannelPlugin): id = "tencent-sms" name = "腾讯云短信" order = 160 label = "腾讯云短信" aliases = ["tencent-sms", "腾讯短信", "tencentcloud-sms"] def __init__(self): self._config = TencentSmsConfigAdapter() self._gateway = TencentSmsGatewayAdapter() self._outbound = TencentSmsOutboundAdapter() self._template_registry = SmsTemplateRegistry() self._agent_prompt = TencentSmsAgentPrompt(self._template_registry) self._block_streamer = TencentSmsBlockStreamer() self._deduplicator = TencentSmsDeduplicator(max_size=10000, ttl_seconds=600) @property def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=["direct"], message_types=["text"], reactions=False, typing_indicator=False, threads=False, edit=False, unsend=False, reply=False, media=False, voice=False, native_commands=False, polls=False, streaming=True, streaming_mode="block", block_streaming=True, block_streaming_chunk_min_chars=60, block_streaming_chunk_max_chars=67, block_streaming_coalesce_idle_ms=800, ) # ── ConfigProtocol ──────────────────────────────────── def list_account_ids(self, config: dict) -> list[str]: return self._config.list_account_ids(config) async def resolve_account(self, account_id: str) -> dict: return await self._config.resolve_account(account_id) def is_configured(self, account: dict) -> bool: return self._config.is_configured(account) def is_enabled(self, account: dict) -> bool: return self._config.is_enabled(account) def disabled_reason(self, account: dict) -> str: return self._config.disabled_reason(account) def describe_account(self, account: dict) -> dict: return self._config.describe_account(account) def config_schema(self) -> dict: return self._config.config_schema() # ── GatewayProtocol ─────────────────────────────────── async def start(self, ctx) -> object: return await self._gateway.start(ctx) async def stop(self, ctx) -> None: await self._gateway.stop(ctx) # ── OutboundProtocol ────────────────────────────────── 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, ) -> None: await self._outbound.send_text( target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id, ) # ── StatusProtocol ──────────────────────────────────── async def probe(self, account: dict) -> bool: return await self._gateway.probe(account) # ── SecurityProtocol ────────────────────────────────── async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: account_id = next(iter(self._gateway._accounts), "default") account = self._gateway.get_account(account_id) if not account: return True try: sms_check_allowlist(account, peer_id) return True except PermissionError: return False def resolve_dm_policy(self) -> dict: account_id = next(iter(self._gateway._accounts), "default") account = self._gateway.get_account(account_id) if account: return {"mode": resolve_dm_policy(account), "allow_from": account.allow_from} return {"mode": "open", "allow_from": []} # ── AgentPromptProtocol ─────────────────────────────── @property def channel_format_instructions(self) -> str | None: return ( "You are connected to Tencent Cloud SMS (腾讯云短信). " "Messages are sent via carrier SMS, NOT instant messaging. " "ALL messages MUST use pre-approved SMS templates — you CANNOT send free text. " "Templates use {1}, {2}, ... {12} as variable placeholders. " "Tencent Cloud SMS limits: single text ≤ 70 chars (UCS-2) or 160 chars (GSM-7). " "Each variable counts toward the character limit. " "Keep responses within template constraints. " "Phone numbers are in E.164 format (+8618501234444). " "This channel is best for ONE-TIME notifications, NOT multi-turn conversations. " "Support opt-out keywords: T, TD, 退订, 取消, N, NO. " "Support help keywords: HELP, 帮助." ) def build_system_prompt(self, context) -> str | None: account_id = getattr(context, "account_id", "default") if context else "default" return self._agent_prompt.build_system_prompt(account_id) # ── StreamingProtocol ───────────────────────────────── @property def block_streaming_enabled(self) -> bool: return True @property def block_streaming_chunk_min_chars(self) -> int: return 60 @property def block_streaming_chunk_max_chars(self) -> int: return 67 @property def block_streaming_chunk_break_preference(self) -> str: return "sentence" def create_block_chunker(self) -> object: return self._block_streamer # ── ErrorHandlingProtocol ───────────────────────────── def classify_error(self, error: BaseException) -> ClassifiedError: sms_code = classify_sms_error(error) severity_map = { "rate_limited": ErrorSeverity.RATE_LIMITED, "network_error": ErrorSeverity.NETWORK, "service_unavailable": ErrorSeverity.RETRYABLE, "delivery_freq_limit": ErrorSeverity.RATE_LIMITED, } severity = severity_map.get(sms_code, ErrorSeverity.FATAL) return ClassifiedError( severity=severity, error_message=f"{sms_code}: {error}", original_error=error, ) def is_retryable(self, error: BaseException) -> bool: sms_code = classify_sms_error(error) try: return sms_is_retryable(TencentSmsErrorCode(sms_code)) except ValueError: return False # ── DedupeProtocol ──────────────────────────────────── def is_duplicate(self, key: str) -> bool: return self._deduplicator.check(key) def mark_seen(self, key: str) -> None: self._deduplicator.mark_seen(key) @property def ttl_seconds(self) -> int: return self._deduplicator.ttl_seconds @property def max_entries(self) -> int: return self._deduplicator.max_entries def reset(self) -> None: self._deduplicator.reset() # ── WebhookProtocol ─────────────────────────────────── async def handle_webhook( self, request: object, account_id: str | None = None, ) -> object: handler = get_webhook_handler() path = getattr(request, "url", "") if "reply-callback" in str(path): return await handler.handle_reply_callback(request) return await handler.handle_status_callback(request) def verify_signature(self, body: bytes, signature: str, secret: str) -> bool: import hashlib import hmac if not signature or not secret: return False expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) # ── InboundHandlerProtocol ──────────────────────────── async def handle_raw_event( self, event: dict, account: dict, ) -> object | None: return self.parse_to_unified(event, account.get("account_id", "default")) def parse_to_unified( self, raw_event: dict, account_id: str, ) -> object | None: event_type = raw_event.get("Type", -1) if event_type == 1: phone = raw_event.get("PhoneNumber", "") content = raw_event.get("ReplyContent", "") if not phone or not content: return None from yuxi.channel.message.models import PeerInfo, PeerKind, UnifiedMessage sender = PeerInfo(id=phone, kind=PeerKind.DIRECT, display_name=phone, raw={}) return UnifiedMessage( msg_id=f"sms-reply:{phone}:{raw_event.get('ReplyTime', '')}", channel_type="tencent-sms", account_id=account_id, content=content, sender=sender, raw=raw_event, ) return None def resolve_event_type(self, raw_event: dict) -> str: event_type = raw_event.get("Type", -1) if event_type == 0: return "delivery_status" if event_type == 1: return "message" return "unknown"