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

496 lines
19 KiB
Python

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