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: 类型定义
This commit is contained in:
Kris 2026-05-21 11:50:32 +08:00
parent 331daa1216
commit 944c9cdbb6
18 changed files with 2127 additions and 0 deletions

View File

@ -0,0 +1,4 @@
from yuxi.channel.extensions.tencent_sms.plugin import TencentSmsPlugin
from yuxi.channel.plugins.registry import ChannelPluginRegistry
ChannelPluginRegistry.register(TencentSmsPlugin())

View File

@ -0,0 +1,37 @@
from __future__ import annotations
from .templates import SmsTemplateRegistry
class TencentSmsAgentPrompt:
def __init__(self, registry: SmsTemplateRegistry):
self._registry = registry
def build_system_prompt(self, account_id: str) -> str:
templates = self._registry.list_all(account_id)
if not templates:
return ""
lines = []
for t in templates:
lines.append(
f"- \u573a\u666f: {t.scene.value} ({t.description or '\u65e0\u63cf\u8ff0'}) | "
f"\u6a21\u677fID: {t.template_id} | \u7b7e\u540d:\u3010{t.sign_name}\u3011| "
f"\u53d8\u91cf\u6570: {t.param_count} | "
f"\u683c\u5f0f\u793a\u4f8b: {t.template_format or '(\u672a\u63d0\u4f9b)'}"
)
prompt = (
"## \u817e\u8baf\u4e91\u77ed\u4fe1\u53d1\u9001\u6307\u5f15\n\n"
"\u4f60\u662f\u77ed\u4fe1\u53d1\u9001\u52a9\u624b\u3002\u6240\u6709\u77ed\u4fe1\u5185\u5bb9\u5fc5\u987b\u4f7f\u7528\u9884\u5ba1\u6a21\u677f\uff1a\n\n"
+ "\n".join(lines)
+ "\n\n**\u53d1\u9001\u89c4\u5219\uff1a**\n"
"- \u4e25\u683c\u4f7f\u7528\u4ee5\u4e0a\u6a21\u677f\uff0c\u586b\u5145\u6a21\u677f\u53d8\u91cf ({1}\u3001{2}...)\n"
"- \u9a8c\u8bc1\u7801\u573a\u666f\uff1a\u53d8\u91cf\u586b\u9a8c\u8bc1\u7801 + \u6709\u6548\u671f\u5206\u949f\u6570\n"
"- \u901a\u77e5\u573a\u666f\uff1a\u53d8\u91cf\u586b\u6807\u9898 + \u5185\u5bb9\n"
"- \u8425\u9500\u77ed\u4fe1\uff1a\u7ed3\u5c3e\u5fc5\u987b\u5305\u542b\u9000\u8ba2\u63d0\u793a\uff0c\u53d1\u9001\u65f6\u95f4\u4ec5\u9650 8:00-21:00\n"
"- \u53d8\u91cf\u4e2d\u7981\u6b62\u5305\u542b URL\n"
"- \u7981\u6b62\u5305\u542b\u91d1\u878d/\u6559\u80b2/\u623f\u4ea7\u7b49\u654f\u611f\u5173\u952e\u8bcd\n"
"- \u7528\u6237\u56de\u590d T/TD/\u9000\u8ba2 \u89c6\u4e3a\u9000\u8ba2\uff0c\u540e\u7eed\u4e0d\u518d\u53d1\u9001\n"
)
return prompt

View File

@ -0,0 +1,495 @@
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from .types import SendResult, TencentSmsAccount
logger = logging.getLogger(__name__)
_sms_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="sms-")
MAX_BATCH_SIZE = 200
class TencentSmsClient:
def __init__(self, account: TencentSmsAccount):
self._account = account
self._client = None
def _build_client(self):
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.sms.v20210111 import sms_client
cred = credential.Credential(self._account.secret_id, self._account.secret_key)
http_profile = HttpProfile()
http_profile.endpoint = self._account.endpoint
http_profile.reqTimeout = 30
client_profile = ClientProfile()
client_profile.httpProfile = http_profile
return sms_client.SmsClient(cred, self._account.region, client_profile)
def _get_client(self):
if self._client is None:
self._client = self._build_client()
return self._client
async def send_sms(
self,
phone_numbers: list[str],
template_id: str,
template_params: list[str],
sign_name: str = "",
session_context: str = "",
extend_code: str = "",
sender_id: str = "",
) -> list[SendResult]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.SendSmsRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.SignName = sign_name or self._account.sign_name
req.TemplateId = template_id
req.TemplateParamSet = template_params
req.PhoneNumberSet = phone_numbers
req.SessionContext = session_context
ext = extend_code or self._account.extend_code
if ext:
req.ExtendCode = ext
sid = sender_id or self._account.sender_id
if sid:
req.SenderId = sid
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().SendSms,
req,
)
except TencentCloudSDKException as e:
logger.error("SendSms SDK \u5f02\u5e38: code=%s, message=%s", e.code, e.message)
raise
results = []
for status in resp.SendStatusSet:
results.append(
SendResult(
success=status.Code == "Ok",
serial_no=getattr(status, "SerialNo", ""),
phone_number=getattr(status, "PhoneNumber", ""),
code=status.Code or "",
message=getattr(status, "Message", ""),
fee=getattr(status, "Fee", 0),
iso_code=getattr(status, "IsoCode", ""),
session_context=getattr(status, "SessionContext", session_context),
)
)
return results
async def send_single(
self,
phone_number: str,
template_id: str,
template_params: list[str],
sign_name: str = "",
session_context: str = "",
extend_code: str = "",
sender_id: str = "",
) -> SendResult:
results = await self.send_sms(
[phone_number], template_id, template_params, sign_name, session_context, extend_code, sender_id
)
return results[0]
async def pull_send_status(self, limit: int = 100) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.PullSmsSendStatusRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.Limit = min(limit, 100)
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().PullSmsSendStatus,
req,
)
except TencentCloudSDKException as e:
logger.error("PullSmsSendStatus SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "PullSmsSendStatusSet", []) or []:
result.append(
{
"serial_no": getattr(item, "SerialNo", ""),
"phone_number": getattr(item, "PhoneNumber", ""),
"report_status": getattr(item, "ReportStatus", ""),
"description": getattr(item, "Description", ""),
"user_receive_time": getattr(item, "UserReceiveTime", ""),
"session_context": getattr(item, "SessionContext", ""),
}
)
return result
async def pull_send_status_by_phone(
self, phone_number: str, begin_time: int = 0, end_time: int = 0, limit: int = 100
) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.PullSmsSendStatusByPhoneNumberRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.PhoneNumber = phone_number
req.SendDateTime = begin_time
req.EndDateTime = end_time
req.Limit = min(limit, 100)
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().PullSmsSendStatusByPhoneNumber,
req,
)
except TencentCloudSDKException as e:
logger.error("PullSmsSendStatusByPhoneNumber SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "PullSmsSendStatusSet", []) or []:
result.append(
{
"serial_no": getattr(item, "SerialNo", ""),
"phone_number": getattr(item, "PhoneNumber", ""),
"report_status": getattr(item, "ReportStatus", ""),
"description": getattr(item, "Description", ""),
"user_receive_time": getattr(item, "UserReceiveTime", ""),
"session_context": getattr(item, "SessionContext", ""),
}
)
return result
async def pull_reply_status_by_phone(
self, phone_number: str, begin_time: int = 0, end_time: int = 0, limit: int = 100
) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.PullSmsReplyStatusByPhoneNumberRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.PhoneNumber = phone_number
req.SendDateTime = begin_time
req.EndDateTime = end_time
req.Limit = min(limit, 100)
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().PullSmsReplyStatusByPhoneNumber,
req,
)
except TencentCloudSDKException as e:
logger.error("PullSmsReplyStatusByPhoneNumber SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "PullSmsReplyStatusSet", []) or []:
result.append(
{
"phone_number": getattr(item, "PhoneNumber", ""),
"reply_content": getattr(item, "ReplyContent", ""),
"reply_time": getattr(item, "ReplyTime", ""),
"reply_unix_time": getattr(item, "ReplyUnixTime", 0),
"extend_code": getattr(item, "ExtendCode", ""),
}
)
return result
async def pull_reply_status(self, limit: int = 100) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.PullSmsReplyStatusRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.Limit = min(limit, 100)
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().PullSmsReplyStatus,
req,
)
except TencentCloudSDKException as e:
logger.error("PullSmsReplyStatus SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "PullSmsReplyStatusSet", []) or []:
result.append(
{
"phone_number": getattr(item, "PhoneNumber", ""),
"reply_content": getattr(item, "ReplyContent", ""),
"reply_time": getattr(item, "ReplyTime", ""),
"reply_unix_time": getattr(item, "ReplyUnixTime", 0),
"extend_code": getattr(item, "ExtendCode", ""),
}
)
return result
async def get_send_statistics(self, begin_time: str, end_time: str, page_size: int = 10, page_no: int = 1) -> dict:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.SendStatusStatisticsRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.BeginTime = begin_time
req.EndTime = end_time
req.PageSize = page_size
req.PageNumber = page_no
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().SendStatusStatistics,
req,
)
except TencentCloudSDKException as e:
logger.error("SendStatusStatistics SDK \u5f02\u5e38: %s", e)
return {}
data = getattr(resp, "Data", None)
if not data:
return {}
return {
"total_count": getattr(data, "TotalCount", 0),
"send_count": getattr(data, "SendCount", 0),
"success_count": getattr(data, "SuccessCount", 0),
"fail_count": getattr(data, "FailCount", 0),
"details": [
{
"date": getattr(d, "Date", ""),
"send_count": getattr(d, "SendCount", 0),
"success_count": getattr(d, "SuccessCount", 0),
"fail_count": getattr(d, "FailCount", 0),
}
for d in getattr(data, "Details", []) or []
],
}
async def get_callback_statistics(
self, begin_time: str, end_time: str, page_size: int = 10, page_no: int = 1
) -> dict:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.CallbackStatusStatisticsRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.BeginTime = begin_time
req.EndTime = end_time
req.PageSize = page_size
req.PageNumber = page_no
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().CallbackStatusStatistics,
req,
)
except TencentCloudSDKException as e:
logger.error("CallbackStatusStatistics SDK \u5f02\u5e38: %s", e)
return {}
data = getattr(resp, "Data", None)
if not data:
return {}
return {
"total_count": getattr(data, "TotalCount", 0),
"callback_count": getattr(data, "CallbackCount", 0),
"success_count": getattr(data, "SuccessCount", 0),
"fail_count": getattr(data, "FailCount", 0),
"details": [
{
"date": getattr(d, "Date", ""),
"callback_count": getattr(d, "CallbackCount", 0),
"success_count": getattr(d, "SuccessCount", 0),
"fail_count": getattr(d, "FailCount", 0),
}
for d in getattr(data, "Details", []) or []
],
}
async def describe_phone_number_info(self, phone_numbers: list[str]) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.DescribePhoneNumberInfoRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.PhoneNumberSet = phone_numbers
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().DescribePhoneNumberInfo,
req,
)
except TencentCloudSDKException as e:
logger.error("DescribePhoneNumberInfo SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "PhoneNumberInfoSet", []) or []:
result.append(
{
"phone_number": getattr(item, "PhoneNumber", ""),
"nation_code": getattr(item, "NationCode", ""),
"subscriber_number": getattr(item, "SubscriberNumber", ""),
"iso_code": getattr(item, "IsoCode", ""),
"iso_name": getattr(item, "IsoName", ""),
}
)
return result
async def report_conversion(
self,
phone_number: str,
serial_no: str,
conversion_time: int = 0,
remark: str = "",
) -> bool:
import time
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.ReportConversionRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.PhoneNumber = phone_number
req.SerialNo = serial_no
req.ConversionTime = conversion_time or int(time.time())
req.Remark = remark
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().ReportConversion,
req,
)
return getattr(resp, "Result", False)
except TencentCloudSDKException as e:
logger.error("ReportConversion SDK \u5f02\u5e38: %s", e)
return False
async def describe_sms_sign_list(self, sign_name_list: list[str] | None = None) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.DescribeSmsSignListRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.SignNameList = sign_name_list or [self._account.sign_name]
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().DescribeSmsSignList,
req,
)
except TencentCloudSDKException as e:
logger.error("DescribeSmsSignList SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "DescribeSmsSignListStatusSet", []) or []:
result.append(
{
"sign_name": getattr(item, "SignName", ""),
"sign_type": getattr(item, "SignType", 0),
"document": getattr(item, "Document", ""),
"international": getattr(item, "International", 0),
"status_code": getattr(item, "StatusCode", 0),
"review_reply": getattr(item, "ReviewReply", ""),
"create_time": getattr(item, "CreateTime", ""),
}
)
return result
async def describe_sms_template_list(
self, template_id_set: list[str] | None = None, international: int = 0
) -> list[dict]:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models
req = models.DescribeSmsTemplateListRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.International = international
if template_id_set:
req.TemplateIdSet = [int(tid) for tid in template_id_set if tid.isdigit()]
loop = asyncio.get_running_loop()
try:
resp = await loop.run_in_executor(
_sms_executor,
self._get_client().DescribeSmsTemplateList,
req,
)
except TencentCloudSDKException as e:
logger.error("DescribeSmsTemplateList SDK \u5f02\u5e38: %s", e)
return []
result = []
for item in getattr(resp, "DescribeSmsTemplateListStatusSet", []) or []:
result.append(
{
"template_id": str(getattr(item, "TemplateId", "")),
"template_name": getattr(item, "TemplateName", ""),
"template_content": getattr(item, "TemplateContent", ""),
"template_type": getattr(item, "TemplateType", 0),
"international": getattr(item, "International", 0),
"status_code": getattr(item, "StatusCode", 0),
"review_reply": getattr(item, "ReviewReply", ""),
"create_time": getattr(item, "CreateTime", ""),
}
)
return result
async def probe(self) -> bool:
from tencentcloud.common import credential
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.sms.v20210111 import models, sms_client
try:
cred = credential.Credential(self._account.secret_id, self._account.secret_key)
loop = asyncio.get_running_loop()
def _check():
try:
client = sms_client.SmsClient(cred, self._account.region)
req = models.DescribeSmsSignListRequest()
req.SmsSdkAppId = self._account.sms_sdk_app_id
req.SignNameList = [self._account.sign_name]
client.DescribeSmsSignList(req)
return True
except TencentCloudSDKException as e:
if "AuthFailure" in (e.code or ""):
return False
return True
return await loop.run_in_executor(_sms_executor, _check)
except Exception as e:
logger.warning("probe exception: %s", e)
return False

View File

@ -0,0 +1,104 @@
from __future__ import annotations
import datetime
import json
import logging
import os
from pathlib import Path
from .types import HELP_KEYWORDS, OPT_IN_KEYWORDS, OPT_OUT_KEYWORDS
logger = logging.getLogger(__name__)
_unsubscribe_blocklist: set[str] = set()
_unsubscribe_file: Path | None = None
MARKETING_START_HOUR = 8
MARKETING_END_HOUR = 21
def _get_unsubscribe_file() -> Path:
global _unsubscribe_file
if _unsubscribe_file is None:
data_dir = Path(os.environ.get("YUXI_DATA_DIR", "data"))
_unsubscribe_file = data_dir / "tencent_sms_unsubscribe.json"
return _unsubscribe_file
def _load_blocklist() -> set[str]:
file_path = _get_unsubscribe_file()
try:
if file_path.exists():
data = json.loads(file_path.read_text(encoding="utf-8"))
return set(data) if isinstance(data, list) else set()
except (json.JSONDecodeError, OSError) as e:
logger.warning("\u52a0\u8f7d\u9000\u8ba2\u9ed1\u540d\u5355\u5931\u8d25: %s", e)
return set()
def _save_blocklist() -> None:
file_path = _get_unsubscribe_file()
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(
json.dumps(sorted(_unsubscribe_blocklist), ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as e:
logger.warning("\u4fdd\u5b58\u9000\u8ba2\u9ed1\u540d\u5355\u5931\u8d25: %s", e)
def init_blocklist() -> None:
global _unsubscribe_blocklist
_unsubscribe_blocklist = _load_blocklist()
logger.info("\u9000\u8ba2\u9ed1\u540d\u5355\u5df2\u52a0\u8f7d: %d \u6761", len(_unsubscribe_blocklist))
def check_marketing_time_window() -> tuple[bool, str]:
hour = datetime.datetime.now().hour
if MARKETING_START_HOUR <= hour < MARKETING_END_HOUR:
return True, "ok"
return (
False,
f"\u8425\u9500\u77ed\u4fe1\u4ec5\u5141\u8bb8 {MARKETING_START_HOUR}:00-{MARKETING_END_HOUR}:00 \u53d1\u9001",
)
def detect_opt_out(reply_content: str) -> tuple[bool, str | None]:
text = reply_content.strip().upper()
content_lower = reply_content.strip().lower()
if content_lower in OPT_OUT_KEYWORDS or text in ("T", "TD", "N", "NO"):
return (
True,
"\u60a8\u5df2\u6210\u529f\u9000\u8ba2\uff0c\u5c06\u4e0d\u518d\u6536\u5230\u672c\u53f7\u7801\u7684\u6d88\u606f\u3002\u56de\u590d START \u91cd\u65b0\u8ba2\u9605\u3002",
)
if content_lower in OPT_IN_KEYWORDS:
return True, "\u60a8\u5df2\u91cd\u65b0\u8ba2\u9605\uff0c\u6b22\u8fce\u56de\u6765\uff01"
if content_lower in HELP_KEYWORDS:
return (
True,
"\u6b22\u8fce\u4f7f\u7528 AI \u667a\u80fd\u52a9\u624b\u3002\u56de\u590d TD \u9000\u8ba2\u6d88\u606f\uff0c\u56de\u590d HELP \u83b7\u53d6\u5e2e\u52a9\u3002",
)
return False, None
def add_unsubscribe(phone: str):
_unsubscribe_blocklist.add(phone)
_save_blocklist()
logger.info("\u7528\u6237 %s \u5df2\u52a0\u5165\u9000\u8ba2\u9ed1\u540d\u5355", phone)
def remove_unsubscribe(phone: str):
_unsubscribe_blocklist.discard(phone)
_save_blocklist()
logger.info("\u7528\u6237 %s \u5df2\u79fb\u9664\u9000\u8ba2\u9ed1\u540d\u5355", phone)
def is_unsubscribed(phone: str) -> bool:
if not _unsubscribe_blocklist:
init_blocklist()
return phone in _unsubscribe_blocklist

View File

@ -0,0 +1,220 @@
from __future__ import annotations
import os
from .types import SmsScene, SmsTemplateConfig, TemplateType, TencentSmsAccount
class TencentSmsConfigAdapter:
ENV_MAP = {
"secret_id": "TENCENT_SMS_SECRET_ID",
"secret_key": "TENCENT_SMS_SECRET_KEY",
"sms_sdk_app_id": "TENCENT_SMS_SDK_APP_ID",
"sign_name": "TENCENT_SMS_SIGN_NAME",
"region": "TENCENT_SMS_REGION",
}
def __init__(self):
self._config: dict | None = None
def set_config(self, config: dict) -> None:
self._config = config
def list_account_ids(self, config: dict) -> list[str]:
accounts = config.get("accounts", [])
if not accounts:
sid = os.environ.get("TENCENT_SMS_SECRET_ID", "")
if sid:
return ["default"]
return []
return [a.get("account_id", str(i)) for i, a in enumerate(accounts)]
async def resolve_account(self, account_id: str) -> dict:
if self._config is None:
return {}
accounts = self._config.get("accounts", [])
if not accounts:
sid = os.environ.get("TENCENT_SMS_SECRET_ID", "")
if sid and account_id in ("default", "0", ""):
return {
"account_id": "default",
"secret_id": sid,
"secret_key": os.environ.get("TENCENT_SMS_SECRET_KEY", ""),
"sms_sdk_app_id": os.environ.get("TENCENT_SMS_SDK_APP_ID", ""),
"sign_name": os.environ.get("TENCENT_SMS_SIGN_NAME", ""),
"region": os.environ.get("TENCENT_SMS_REGION", "ap-guangzhou"),
}
return {}
for a in accounts:
if a.get("account_id", str(accounts.index(a))) == account_id:
return a
return {}
def _parse_account(self, account: dict) -> TencentSmsAccount:
secret_id = account.get("secret_id") or os.environ.get("TENCENT_SMS_SECRET_ID", "")
secret_key = account.get("secret_key") or os.environ.get("TENCENT_SMS_SECRET_KEY", "")
sms_sdk_app_id = account.get("sms_sdk_app_id") or os.environ.get("TENCENT_SMS_SDK_APP_ID", "")
sign_name = account.get("sign_name") or os.environ.get("TENCENT_SMS_SIGN_NAME", "")
region = account.get("region") or os.environ.get("TENCENT_SMS_REGION", "ap-guangzhou")
templates = []
for tpl in account.get("templates", []):
templates.append(
SmsTemplateConfig(
scene=SmsScene(tpl.get("scene", "notification")),
template_id=tpl.get("template_id", ""),
sign_name=tpl.get("sign_name", sign_name),
template_type=TemplateType(tpl.get("template_type", "0")),
param_count=tpl.get("param_count", 1),
max_daily=tpl.get("max_daily", 1000),
description=tpl.get("description", ""),
template_format=tpl.get("template_format", ""),
)
)
return TencentSmsAccount(
account_id=account.get("account_id", "default"),
secret_id=secret_id,
secret_key=secret_key,
sms_sdk_app_id=sms_sdk_app_id,
sign_name=sign_name,
region=region,
endpoint=account.get("endpoint", "sms.tencentcloudapi.com"),
callback_url=account.get("callback_url", "/webhook/tencent-sms/callback"),
reply_callback_url=account.get("reply_callback_url", "/webhook/tencent-sms/reply-callback"),
templates=templates,
dm_policy=account.get("dm_policy", "open"),
allow_from=account.get("allow_from", []),
daily_limit=account.get("daily_limit", 1000),
interval_seconds=account.get("interval_seconds", 30),
phone_daily_limit=account.get("phone_daily_limit", 10),
poll_interval_seconds=account.get("poll_interval_seconds", 120),
enabled=account.get("enabled", True),
extend_code=account.get("extend_code", ""),
sender_id=account.get("sender_id", ""),
allowed_callback_ips=account.get("allowed_callback_ips", []),
)
def is_configured(self, account: dict) -> bool:
ta = self._parse_account(account)
return ta.is_configured
def is_enabled(self, account: dict) -> bool:
ta = self._parse_account(account)
return ta.enabled and ta.is_configured
def disabled_reason(self, account: dict) -> str:
ta = self._parse_account(account)
if not ta.secret_id:
return "\u7f3a\u5c11 SecretId"
if not ta.secret_key:
return "\u7f3a\u5c11 SecretKey"
if not ta.sms_sdk_app_id:
return "\u7f3a\u5c11 SmsSdkAppId"
if not ta.sign_name:
return "\u672a\u914d\u7f6e\u77ed\u4fe1\u7b7e\u540d (SignName)"
if not ta.templates:
return "\u672a\u914d\u7f6e\u77ed\u4fe1\u6a21\u677f"
if not ta.enabled:
return "\u6e20\u9053\u5df2\u7981\u7528"
return ""
def describe_account(self, account: dict) -> dict:
ta = self._parse_account(account)
return {
"account_id": ta.account_id,
"sms_sdk_app_id": ta.sms_sdk_app_id,
"sign_name": ta.sign_name,
"region": ta.region,
"dm_policy": ta.dm_policy,
"template_count": len(ta.templates),
"template_scenes": [t.scene.value for t in ta.templates],
}
def config_schema(self) -> dict:
return {
"type": "object",
"properties": {
"secret_id": {
"type": "string",
"description": "\u817e\u8baf\u4e91 API SecretId\uff08CAM \u63a7\u5236\u53f0\u83b7\u53d6\uff09",
},
"secret_key": {
"type": "string",
"description": "\u817e\u8baf\u4e91 API SecretKey",
"format": "password",
},
"sms_sdk_app_id": {
"type": "string",
"description": "\u77ed\u4fe1\u5e94\u7528 ID\uff08\u63a7\u5236\u53f0\u5e94\u7528\u7ba1\u7406 -> SDK AppID\uff09",
},
"sign_name": {
"type": "string",
"description": "\u77ed\u4fe1\u7b7e\u540d\uff08\u5df2\u5ba1\u6838\u901a\u8fc7\uff0c\u4e0d\u542b\u3010\u3011\uff09",
},
"region": {
"type": "string",
"default": "ap-guangzhou",
"description": "API \u5730\u57df",
},
"templates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"scene": {
"type": "string",
"enum": ["verification", "notification", "alert", "marketing"],
},
"template_id": {"type": "string"},
"sign_name": {"type": "string"},
"param_count": {"type": "integer", "minimum": 1, "maximum": 12},
"description": {"type": "string"},
"template_format": {"type": "string"},
},
"required": ["scene", "template_id", "param_count"],
},
},
"dm_policy": {
"type": "string",
"enum": ["open", "pairing", "allowlist", "disabled"],
"default": "open",
},
"allow_from": {
"type": "array",
"items": {"type": "string"},
"description": "\u5141\u8bb8\u63a5\u6536\u77ed\u4fe1\u7684\u624b\u673a\u53f7\u767d\u540d\u5355\uff08E.164 \u683c\u5f0f\uff09",
},
"daily_limit": {
"type": "integer",
"minimum": 1,
"maximum": 100000,
"default": 1000,
},
"phone_daily_limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10,
},
"extend_code": {
"type": "string",
"description": "\u77ed\u4fe1\u7801\u53f7\u6269\u5c55\u53f7\uff08\u56fd\u9645/\u6e2f\u6fb3\u53f0\u77ed\u4fe1\u7528\uff09",
},
"sender_id": {
"type": "string",
"description": "\u56fd\u9645/\u6e2f\u6fb3\u53f0 Sender ID",
},
"allowed_callback_ips": {
"type": "array",
"items": {"type": "string"},
"description": "\u5141\u8bb8\u7684 Webhook \u56de\u8c03 IP \u767d\u540d\u5355",
},
"enabled": {"type": "boolean", "default": True},
},
"required": ["secret_id", "secret_key", "sms_sdk_app_id", "sign_name"],
}
def default_account_id(self, config: dict) -> str:
ids = self.list_account_ids(config)
return ids[0] if ids else "default"

View File

@ -0,0 +1,52 @@
from __future__ import annotations
import time
from collections import OrderedDict
class TencentSmsDeduplicator:
def __init__(self, max_size: int = 10000, ttl_seconds: int = 600):
self._cache: OrderedDict[str, float] = OrderedDict()
self._max_size = max_size
self._ttl_seconds = ttl_seconds
def _evict_expired(self, now: float) -> None:
expired = [k for k, ts in self._cache.items() if now - ts > self._ttl_seconds]
for k in expired:
del self._cache[k]
def _evict_oldest(self) -> None:
while len(self._cache) > self._max_size:
self._cache.popitem(last=False)
def is_duplicate(self, dedupe_key: str) -> bool:
now = time.monotonic()
self._evict_expired(now)
if dedupe_key in self._cache:
return True
self._cache[dedupe_key] = now
self._evict_oldest()
return False
def check(self, dedupe_key: str) -> bool:
now = time.monotonic()
self._evict_expired(now)
return dedupe_key in self._cache
def mark_seen(self, dedupe_key: str) -> None:
now = time.monotonic()
self._evict_expired(now)
if dedupe_key not in self._cache:
self._cache[dedupe_key] = now
self._evict_oldest()
@property
def ttl_seconds(self) -> int:
return self._ttl_seconds
@property
def max_entries(self) -> int:
return self._max_size
def reset(self) -> None:
self._cache.clear()

View File

@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from .dedupe import TencentSmsDeduplicator
from .types import DeliveryStatus
logger = logging.getLogger(__name__)
class SmsDeliveryTracker:
def __init__(self):
self._dedupe = TencentSmsDeduplicator(max_size=50000, ttl_seconds=86400)
self._store: dict[str, DeliveryStatus] = {}
def handle_status_callback(self, item: dict) -> bool:
serial_no = item.get("SerialNo", "")
report_status = item.get("ReportStatus", "")
key = f"status:{serial_no}:{report_status}"
if self._dedupe.is_duplicate(key):
return False
status = DeliveryStatus(
serial_no=serial_no,
phone_number=item.get("PhoneNumber", ""),
report_status=report_status,
description=item.get("Description", ""),
report_time=item.get("ReportTime", ""),
session_context=item.get("SessionContext", ""),
)
self._store[serial_no] = status
logger.info(
"\u9001\u8fbe\u72b6\u6001\u66f4\u65b0: serial=%s, status=%s, phone=%s",
serial_no,
report_status,
status.phone_number,
)
return True
def get_status(self, serial_no: str) -> DeliveryStatus | None:
return self._store.get(serial_no)

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from enum import StrEnum
from .types import NON_RETRYABLE_CODES
class TencentSmsErrorCode(StrEnum):
RATE_LIMITED = "rate_limited"
NETWORK_ERROR = "network_error"
SERVICE_UNAVAILABLE = "service_unavailable"
DELIVERY_FREQ_LIMIT = "delivery_freq_limit"
INVALID_TEMPLATE = "invalid_template"
INVALID_SIGN = "invalid_sign"
INVALID_PHONE = "invalid_phone"
BLACKLISTED = "blacklisted"
DAILY_LIMIT = "daily_limit"
PHONE_DAILY_LIMIT = "phone_daily_limit"
PHONE_HOURLY_LIMIT = "phone_hourly_limit"
PHONE_30S_LIMIT = "phone_30s_limit"
INSUFFICIENT_BALANCE = "insufficient_balance"
SDK_APPID_FAIL = "sdk_appid_fail"
def classify_sms_error(exception: Exception) -> TencentSmsErrorCode:
code = getattr(exception, "code", "") or ""
if "RequestLimitExceeded" in code:
return TencentSmsErrorCode.RATE_LIMITED
if code in ("InternalError", "ResourceUnavailable", "FailedOperation.ServiceError"):
return TencentSmsErrorCode.SERVICE_UNAVAILABLE
if isinstance(exception, OSError):
return TencentSmsErrorCode.NETWORK_ERROR
if code in NON_RETRYABLE_CODES:
return TencentSmsErrorCode(code.lower() if hasattr(TencentSmsErrorCode, code.lower()) else "network_error")
return TencentSmsErrorCode.NETWORK_ERROR
def is_retryable(error_code: TencentSmsErrorCode) -> bool:
return error_code in {
TencentSmsErrorCode.RATE_LIMITED,
TencentSmsErrorCode.NETWORK_ERROR,
TencentSmsErrorCode.SERVICE_UNAVAILABLE,
TencentSmsErrorCode.DELIVERY_FREQ_LIMIT,
}

View File

@ -0,0 +1,57 @@
from __future__ import annotations
import time
from collections import defaultdict
from .types import SmsScene, TencentSmsAccount
class SmsFrequencyGuard:
def __init__(self, window_seconds: int = 60):
self._window = window_seconds
self._records: dict[str, list[tuple[SmsScene, float]]] = defaultdict(list)
async def check_and_record(
self,
phone: str,
scene: SmsScene,
account: TencentSmsAccount,
) -> tuple[bool, str]:
now = time.time()
records = self._records[phone]
interval_check = [t for t, _ in records if now - t < account.interval_seconds]
if interval_check:
wait = account.interval_seconds - (now - interval_check[-1])
return False, f"\u53d1\u9001\u95f4\u9694\u4e0d\u8db3\uff0c\u8fd8\u9700\u7b49\u5f85 {wait:.0f}s"
today_start = now - (now % 86400)
today_records = [(t, s) for t, s in records if t >= today_start]
if len(today_records) >= account.phone_daily_limit:
return (
False,
f"\u8be5\u53f7\u7801\u5df2\u8fbe\u65e5\u53d1\u9001\u4e0a\u9650 {account.phone_daily_limit} \u6761",
)
hours_records = [(t, s) for t, s in today_records if now - t < 3600]
if len(hours_records) >= 5:
return False, f"\u8be5\u53f7\u7801 1 \u5c0f\u65f6\u5185\u5df2\u53d1\u9001 {len(hours_records)} \u6761"
window_records = [(t, s) for t, s in records if now - t < self._window]
if window_records and any(s == scene for _, s in window_records):
return False, "\u540c\u4e00\u573a\u666f 60s \u5185\u5df2\u53d1\u9001\u8fc7"
records.append((now, scene))
expired = [(t, s) for t, s in records if now - t > 86400]
for e in expired:
records.remove(e)
return True, "ok"
def remaining(self, phone: str, account: TencentSmsAccount) -> int:
now = time.time()
today_start = now - (now % 86400)
today_count = sum(1 for t, _ in self._records.get(phone, []) if t >= today_start)
return max(0, account.phone_daily_limit - today_count)
def reset(self, phone: str) -> None:
self._records.pop(phone, None)

View File

@ -0,0 +1,171 @@
from __future__ import annotations
import asyncio
import logging
from .client import TencentSmsClient
from .config import TencentSmsConfigAdapter
from .delivery import SmsDeliveryTracker
from .types import TencentSmsAccount
from .webhook import TencentSmsWebhookHandler, set_webhook_handler
logger = logging.getLogger("yuxi.channel.tencent-sms")
class TencentSmsGatewayAdapter:
def __init__(self):
self._config = TencentSmsConfigAdapter()
self._running = False
self._tasks: list[asyncio.Task] = []
self._accounts: dict[str, TencentSmsAccount] = {}
self._clients: dict[str, TencentSmsClient] = {}
self._delivery_tracker = SmsDeliveryTracker()
async def start(self, ctx) -> dict:
config = ctx.config if hasattr(ctx, "config") else {}
self._config.set_config(config)
account = self._config._parse_account(config)
if not account.is_configured:
raise ValueError(
f"腾讯云短信账户 {account.account_id} 未正确配置"
)
self._accounts[account.account_id] = account
client = TencentSmsClient(account)
self._clients[account.account_id] = client
probe_ok = await client.probe()
if not probe_ok:
logger.warning("腾讯云短信 Gateway probe 未通过")
if hasattr(ctx, "outbound") and ctx.outbound:
ctx.outbound.set_client(account.account_id, client, account)
webhook_handler = TencentSmsWebhookHandler(
allowed_ips=getattr(account, "allowed_callback_ips", None),
delivery_tracker=self._delivery_tracker,
)
set_webhook_handler(webhook_handler)
self._running = True
task = asyncio.create_task(self._periodic_pull(client, account))
self._tasks.append(task)
logger.info(
"腾讯云短信 Gateway 启动成功, account=%s, app_id=%s, sign=%s",
account.account_id,
account.sms_sdk_app_id,
account.sign_name,
)
return {
"running": True,
"account": account,
"client": client,
"accounts": self._accounts,
"clients": self._clients,
"delivery_tracker": self._delivery_tracker,
}
async def stop(self, ctx) -> None:
self._running = False
for task in self._tasks:
task.cancel()
self._tasks.clear()
self._accounts.clear()
self._clients.clear()
logger.info("腾讯云短信 Gateway 已停止")
def get_client(self, account_id: str = "default") -> TencentSmsClient | None:
return self._clients.get(account_id)
def get_account(self, account_id: str = "default") -> TencentSmsAccount | None:
return self._accounts.get(account_id)
async def probe(self, account: dict) -> bool:
try:
ta = self._config._parse_account(account)
client = TencentSmsClient(ta)
return await client.probe()
except Exception as e:
logger.warning("腾讯云短信 probe 失败: %s", e)
return False
async def _periodic_pull(
self,
client: TencentSmsClient,
account: TencentSmsAccount,
):
interval = max(account.poll_interval_seconds, 30)
while self._running:
try:
await self._pull_all_statuses(client)
await self._pull_all_replies(client)
except asyncio.CancelledError:
break
except Exception as e:
logger.exception("定时拉取任务异常: %s", e)
await asyncio.sleep(interval)
async def _pull_all_statuses(self, client: TencentSmsClient) -> int:
total_processed = 0
batch_count = 0
max_batches = 10
while batch_count < max_batches:
statuses = await client.pull_send_status(limit=100)
if not statuses:
break
logger.info("Pull 拉取到 %d 条送达状态", len(statuses))
for item in statuses:
self._delivery_tracker.handle_status_callback(
{
"SerialNo": item["serial_no"],
"ReportStatus": item["report_status"],
"PhoneNumber": item["phone_number"],
"Description": item["description"],
"ReportTime": item["user_receive_time"],
"SessionContext": item["session_context"],
}
)
total_processed += 1
batch_count += 1
if len(statuses) < 100:
break
if total_processed > 0:
logger.info("本轮共处理 %d 条送达状态", total_processed)
return total_processed
async def _pull_all_replies(self, client: TencentSmsClient) -> int:
total_processed = 0
batch_count = 0
max_batches = 10
while batch_count < max_batches:
replies = await client.pull_reply_status(limit=100)
if not replies:
break
logger.info("Pull 拉取到 %d 条上行回复", len(replies))
for item in replies:
logger.info(
"上行回复: phone=%s, content=%s, time=%s",
item["phone_number"],
item["reply_content"],
item["reply_time"],
)
total_processed += 1
batch_count += 1
if len(replies) < 100:
break
if total_processed > 0:
logger.info("本轮共处理 %d 条上行回复", total_processed)
return total_processed

View File

@ -0,0 +1,159 @@
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

View File

@ -0,0 +1,31 @@
{
"id": "tencent-sms",
"name": "腾讯云短信",
"version": "1.0.0",
"label": "腾讯云短信",
"aliases": ["tencent-sms", "腾讯短信", "tencentcloud-sms"],
"description": "腾讯云 SMS 渠道插件,支持模板化短信发送、送达状态追踪与上行回复处理",
"author": "ForcePilot Team",
"order": 160,
"dependencies": ["tencentcloud-sdk-python-sms>=3.1.92"],
"capabilities": {
"chat_types": ["direct"],
"message_types": ["text"],
"streaming": true,
"streaming_mode": "block",
"block_streaming": true,
"block_streaming_chunk_min_chars": 60,
"block_streaming_chunk_max_chars": 67,
"reactions": false,
"polls": false,
"reply": false,
"media": false,
"voice": false,
"edit": false,
"unsend": false,
"threads": false,
"native_commands": false,
"group_management": false
},
"python_requires": ">=3.12"
}

View File

@ -0,0 +1,280 @@
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"

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from .types import TencentSmsAccount
def check_allowlist(account: TencentSmsAccount, phone: str):
allow = account.allow_from
if not allow:
return
if phone not in allow:
raise PermissionError(f"\u53f7\u7801 {phone[:6]}*** \u4e0d\u5728\u767d\u540d\u5355\u4e2d")
def resolve_dm_policy(account: TencentSmsAccount) -> str:
return account.dm_policy or "open"

View File

@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import logging
from .types import SmsScene
logger = logging.getLogger(__name__)
MAX_SMS_SEGMENTS = 10
UCS2_CHARS_PER_SEGMENT = 67
class TencentSmsBlockStreamer:
def __init__(self, max_segments: int = MAX_SMS_SEGMENTS, chars_per_segment: int = UCS2_CHARS_PER_SEGMENT):
self._max_segments = max_segments
self._chars_per_segment = chars_per_segment
def chunk_text(self, text: str) -> list[str]:
if not text:
return []
segments: list[str] = []
remaining = text
while remaining and len(segments) < self._max_segments:
if len(remaining) <= self._chars_per_segment:
segments.append(remaining.strip())
break
cut = self._chars_per_segment
for delimiter in ["\u3002", "\n", "\uff01", "\uff1f", "\uff0c", " ", ",", "."]:
idx = remaining[: self._chars_per_segment].rfind(delimiter)
if idx > self._chars_per_segment // 2:
cut = idx + len(delimiter)
break
segment = remaining[:cut].strip()
if segment:
segments.append(segment)
remaining = remaining[cut:].strip()
if remaining and len(segments) < self._max_segments:
truncated = remaining[: self._chars_per_segment - 3] + "..."
segments.append(truncated)
return segments
async def stream_send(
self,
phone: str,
segments: list[str],
send_func,
interval_ms: int = 500,
):
for i, segment in enumerate(segments):
prefix = f"[{i + 1}/{len(segments)}] " if i > 0 else ""
content = f"{prefix}{segment}"
try:
result = await send_func(phone, content)
logger.debug(
"stream segment %d/%d: %s",
i + 1,
len(segments),
result.serial_no if result.success else f"FAIL:{result.code}",
)
except Exception as e:
logger.exception("stream segment %d/%d failed: %s", i + 1, len(segments), e)
if i < len(segments) - 1:
await asyncio.sleep(interval_ms / 1000.0)
async def stream_send_by_template(
self,
phone: str,
scene: SmsScene,
segments: list[str],
send_by_template_func,
interval_ms: int = 500,
):
for i, segment in enumerate(segments):
prefix = f"[{i + 1}/{len(segments)}] " if i > 0 else ""
content = f"{prefix}{segment}"
try:
result = await send_by_template_func(phone, scene, [content])
logger.debug(
"stream segment %d/%d: %s",
i + 1,
len(segments),
result.serial_no if result.success else f"FAIL:{result.code}",
)
except Exception as e:
logger.exception("stream segment %d/%d failed: %s", i + 1, len(segments), e)
if i < len(segments) - 1:
await asyncio.sleep(interval_ms / 1000.0)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from .types import SmsScene, SmsTemplateConfig
class SmsTemplateRegistry:
def __init__(self):
self._bindings: dict[str, dict[SmsScene, SmsTemplateConfig]] = {}
def register(self, account_id: str, template: SmsTemplateConfig):
scene_bindings = self._bindings.setdefault(account_id, {})
scene_bindings[template.scene] = template
def resolve(self, account_id: str, scene: SmsScene) -> SmsTemplateConfig | None:
return self._bindings.get(account_id, {}).get(scene)
def list_all(self, account_id: str) -> list[SmsTemplateConfig]:
return list(self._bindings.get(account_id, {}).values())
def build_agent_prompt(self, account_id: str) -> str:
templates = self.list_all(account_id)
if not templates:
return "\u5f53\u524d\u65e0\u53ef\u7528\u7684\u77ed\u4fe1\u6a21\u677f\u3002"
lines = []
for t in templates:
lines.append(
f"- \u573a\u666f:{t.scene.value} | \u6a21\u677fID:{t.template_id} | "
f"\u7b7e\u540d:\u3010{t.sign_name}\u3011| \u53d8\u91cf\u6570:{t.param_count} | {t.description or ''}"
)
return (
"\u53ef\u7528\u77ed\u4fe1\u6a21\u677f\uff1a\n"
+ "\n".join(lines)
+ "\n\n\u53d1\u9001\u77ed\u4fe1\u65f6\u8bf7\u9009\u62e9\u5bf9\u5e94\u573a\u666f\uff0c\u4e25\u683c\u6309\u53d8\u91cf\u4e2a\u6570\u586b\u5145 TemplateParamSet\u3002"
)

View File

@ -0,0 +1,163 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
class SmsScene(StrEnum):
VERIFICATION = "verification"
NOTIFICATION = "notification"
ALERT = "alert"
MARKETING = "marketing"
class SmsSendCode(StrEnum):
OK = "Ok"
PHONE_BLACKLIST = "FailedOperation.PhoneNumberInBlacklist"
SIGNATURE_INCORRECT = "FailedOperation.SignatureIncorrectOrUnapproved"
TEMPLATE_INCORRECT = "FailedOperation.TemplateIncorrectOrUnapproved"
INSUFFICIENT_BALANCE = "FailedOperation.InsufficientBalanceInSmsPackage"
SDK_APPID_FAIL = "UnauthorizedOperation.SmsSdkAppIdVerifyFail"
DAILY_LIMIT = "LimitExceeded.DailyLimit"
DELIVERY_FREQ_LIMIT = "LimitExceeded.DeliveryFrequencyLimit"
PHONE_DAILY_LIMIT = "LimitExceeded.PhoneNumberDailyLimit"
PHONE_HOURLY_LIMIT = "LimitExceeded.PhoneNumberOneHourLimit"
PHONE_30S_LIMIT = "LimitExceeded.PhoneNumberThirtySecondLimit"
INVALID_PHONE = "InvalidParameterValue.IncorrectPhoneNumber"
class ReportStatus(StrEnum):
SUCCESS = "SUCCESS"
FAIL = "FAIL"
class TemplateType(StrEnum):
COMMON = "0"
MARKETING = "1"
@dataclass
class SmsTemplateConfig:
scene: SmsScene
template_id: str
sign_name: str = ""
template_type: TemplateType = TemplateType.COMMON
param_count: int = 1
max_daily: int = 1000
description: str = ""
template_format: str = ""
@dataclass
class TencentSmsAccount:
account_id: str
secret_id: str = ""
secret_key: str = ""
sms_sdk_app_id: str = ""
sign_name: str = ""
region: str = "ap-guangzhou"
endpoint: str = "sms.tencentcloudapi.com"
callback_url: str = "/webhook/tencent-sms/callback"
reply_callback_url: str = "/webhook/tencent-sms/reply-callback"
templates: list[SmsTemplateConfig] = field(default_factory=list)
dm_policy: str = "open"
allow_from: list[str] = field(default_factory=list)
daily_limit: int = 1000
interval_seconds: int = 30
phone_daily_limit: int = 10
poll_interval_seconds: int = 120
enabled: bool = True
extend_code: str = ""
sender_id: str = ""
allowed_callback_ips: list[str] = field(default_factory=list)
@property
def is_configured(self) -> bool:
return bool(self.secret_id and self.secret_key and self.sms_sdk_app_id and self.sign_name)
@dataclass
class SendResult:
success: bool
serial_no: str = ""
phone_number: str = ""
code: str = ""
message: str = ""
fee: int = 0
iso_code: str = ""
session_context: str = ""
@dataclass
class BatchSendResult:
total: int = 0
succeeded: int = 0
failed: int = 0
results: list[SendResult] = field(default_factory=list)
@dataclass
class DeliveryStatus:
serial_no: str
phone_number: str
report_status: ReportStatus
description: str = ""
report_time: str = ""
user_receive_time: str = ""
fee: int = 0
session_context: str = ""
@dataclass
class ReplyRecord:
phone_number: str
reply_content: str
reply_time: str = ""
reply_unix_time: int = 0
extend_code: str = ""
country_code: str = "86"
NON_RETRYABLE_CODES: frozenset[str] = frozenset(
{
SmsSendCode.PHONE_BLACKLIST,
SmsSendCode.SIGNATURE_INCORRECT,
SmsSendCode.TEMPLATE_INCORRECT,
SmsSendCode.INSUFFICIENT_BALANCE,
SmsSendCode.SDK_APPID_FAIL,
SmsSendCode.DAILY_LIMIT,
SmsSendCode.PHONE_DAILY_LIMIT,
SmsSendCode.PHONE_HOURLY_LIMIT,
SmsSendCode.PHONE_30S_LIMIT,
SmsSendCode.INVALID_PHONE,
}
)
OPT_OUT_KEYWORDS: frozenset[str] = frozenset(
{
"t",
"td",
"\u9000\u8ba2",
"\u53d6\u6d88",
"n",
"no",
"qx",
}
)
HELP_KEYWORDS: frozenset[str] = frozenset(
{
"help",
"\u5e2e\u52a9",
}
)
OPT_IN_KEYWORDS: frozenset[str] = frozenset(
{
"start",
"\u8ba2\u9605",
"dy",
"y",
"yes",
}
)

View File

@ -0,0 +1,124 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Response
from .compliance import add_unsubscribe, detect_opt_out, remove_unsubscribe
from .dedupe import TencentSmsDeduplicator
from .delivery import SmsDeliveryTracker
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/tencent-sms", tags=["tencent-sms-webhook"])
class TencentSmsWebhookHandler:
def __init__(self, allowed_ips: list[str] | None = None, delivery_tracker: SmsDeliveryTracker | None = None):
self._allowed_ips = set(allowed_ips or [])
self._delivery_tracker = delivery_tracker
self._status_dedupe = TencentSmsDeduplicator(max_size=10000, ttl_seconds=3600)
self._reply_dedupe = TencentSmsDeduplicator(max_size=10000, ttl_seconds=3600)
async def handle_status_callback(self, request: Request) -> Response:
client_ip = request.client.host if request.client else ""
if self._allowed_ips and client_ip not in self._allowed_ips:
logger.warning("\u72b6\u6001\u56de\u8c03 IP \u4e0d\u5728\u767d\u540d\u5355: %s", client_ip)
return Response(status_code=403)
try:
body = await request.json()
except Exception as e:
logger.warning("\u72b6\u6001\u56de\u8c03 JSON \u89e3\u6790\u5931\u8d25: %s", e)
return Response(status_code=400)
items = body if isinstance(body, list) else [body]
for item in items:
if item.get("Type") != 0:
continue
serial_no = item.get("SerialNo", "")
report_status = item.get("ReportStatus", "")
dedupe_key = f"status:{serial_no}:{report_status}"
if self._status_dedupe.is_duplicate(dedupe_key):
continue
logger.info(
"\u9001\u8fbe\u72b6\u6001: serial=%s, status=%s, phone=%s, desc=%s",
serial_no,
report_status,
item.get("PhoneNumber", ""),
item.get("Description", ""),
)
if self._delivery_tracker:
self._delivery_tracker.handle_status_callback(
{
"SerialNo": serial_no,
"ReportStatus": report_status,
"PhoneNumber": item.get("PhoneNumber", ""),
"Description": item.get("Description", ""),
"ReportTime": item.get("ReportTime", ""),
"SessionContext": item.get("SessionContext", ""),
}
)
return Response(content='{"result":0,"errmsg":"OK"}', media_type="application/json")
async def handle_reply_callback(self, request: Request) -> Response:
client_ip = request.client.host if request.client else ""
if self._allowed_ips and client_ip not in self._allowed_ips:
logger.warning("\u56de\u590d\u56de\u8c03 IP \u4e0d\u5728\u767d\u540d\u5355: %s", client_ip)
return Response(status_code=403)
try:
body = await request.json()
except Exception as e:
logger.warning("\u56de\u590d\u56de\u8c03 JSON \u89e3\u6790\u5931\u8d25: %s", e)
return Response(status_code=400)
items = body if isinstance(body, list) else [body]
for item in items:
if item.get("Type") != 1:
continue
phone = item.get("PhoneNumber", "")
content = item.get("ReplyContent", "")
reply_time = item.get("ReplyTime", "")
dedupe_key = f"reply:{phone}:{reply_time}"
if self._reply_dedupe.is_duplicate(dedupe_key):
continue
is_opt, response_text = detect_opt_out(content)
if is_opt:
content_lower = content.strip().lower()
if content_lower in ("t", "td", "\u9000\u8ba2", "\u53d6\u6d88", "n", "no", "qx"):
add_unsubscribe(phone)
elif content_lower in ("start", "\u8ba2\u9605", "dy", "y", "yes"):
remove_unsubscribe(phone)
logger.info("Opt-out/opt-in: phone=%s, content=%s, response=%s", phone, content, response_text)
logger.info("\u4e0a\u884c\u56de\u590d: phone=%s, content=%s, time=%s", phone, content, reply_time)
return Response(content='{"result":0,"errmsg":"OK"}', media_type="application/json")
_webhook_handler: TencentSmsWebhookHandler | None = None
def set_webhook_handler(handler: TencentSmsWebhookHandler) -> None:
global _webhook_handler
_webhook_handler = handler
def get_webhook_handler() -> TencentSmsWebhookHandler:
if _webhook_handler is None:
return TencentSmsWebhookHandler()
return _webhook_handler
@router.post("/callback")
async def status_callback(request: Request):
return await get_webhook_handler().handle_status_callback(request)
@router.post("/reply-callback")
async def reply_callback(request: Request):
return await get_webhook_handler().handle_reply_callback(request)