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