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

280 lines
10 KiB
Python

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"