ForcePilot/backend/package/yuxi/channel/extensions/tencent_im/outbound.py
Kris 331daa1216 feat(channel): 添加腾讯 IM 渠道扩展
新增腾讯 IM(Tencent IM)渠道扩展,支持在 Yuxi 平台中集成腾讯即时通讯 IM 渠道。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- usersig: UserSig 生成
- dedupe: 消息去重
- status: 会话状态管理
- group: 群组管理
- types: 类型定义
2026-05-21 11:49:29 +08:00

403 lines
14 KiB
Python

import base64
import json
import logging
import random
from yuxi.channel.extensions.tencent_im.gateway import TencentIMError, TencentIMGateway
from yuxi.channel.extensions.tencent_im.types import OutboundResult
logger = logging.getLogger(__name__)
MAX_TEXT_LEN = 12 * 1024
class TencentIMOutbound:
def __init__(self, gateway: TencentIMGateway | None = None):
self._gateway = gateway
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
chat_type: str = "direct",
) -> OutboundResult:
if not content:
return OutboundResult(success=False, error="empty content")
if len(content) <= MAX_TEXT_LEN:
return await self._send_text_chunk(target_id, content, chat_type, reply_to_id=reply_to_id)
chunks = []
remaining = content
while remaining:
chunk = remaining[:MAX_TEXT_LEN]
remaining = remaining[MAX_TEXT_LEN:]
chunks.append(chunk)
last_result = None
for idx, chunk in enumerate(chunks):
last_result = await self._send_text_chunk(
target_id, chunk, chat_type, reply_to_id=reply_to_id if idx == 0 else None
)
if not last_result.success:
break
return last_result or OutboundResult(success=False, error="no chunks sent")
async def _send_text_chunk(
self,
target_id: str,
content: str,
chat_type: str = "direct",
reply_to_id: str | None = None,
) -> OutboundResult:
msg_body = [{"MsgType": "TIMTextElem", "MsgContent": {"Text": content}}]
cloud_custom_data = None
if reply_to_id:
cloud_custom_data = json.dumps({"reply_msg_id": reply_to_id})
return await self._send_message(target_id, msg_body, chat_type, cloud_custom_data=cloud_custom_data)
async def send_custom(
self,
target_id: str,
data: str,
desc: str = "",
ext: str = "",
*,
chat_type: str = "direct",
) -> OutboundResult:
msg_body = [
{
"MsgType": "TIMCustomElem",
"MsgContent": {"Data": data, "Desc": desc, "Ext": ext},
}
]
return await self._send_message(target_id, msg_body, chat_type)
async def send_image(
self,
target_id: str,
image_info: dict,
*,
chat_type: str = "direct",
) -> OutboundResult:
msg_body = [
{
"MsgType": "TIMImageElem",
"MsgContent": {
"UUID": image_info.get("uuid", ""),
"ImageFormat": image_info.get("format", 1),
"ImageInfoArray": image_info.get("images", []),
},
}
]
return await self._send_message(target_id, msg_body, chat_type)
async def send_voice(
self,
target_id: str,
voice_url: str,
duration: int = 0,
*,
chat_type: str = "direct",
) -> OutboundResult:
msg_body = [
{
"MsgType": "TIMSoundElem",
"MsgContent": {
"Url": voice_url,
"Size": 0,
"Second": duration,
"Download_Flag": 2,
},
}
]
return await self._send_message(target_id, msg_body, chat_type)
async def send_video(
self,
target_id: str,
video_url: str,
thumb_url: str = "",
duration: int = 0,
*,
chat_type: str = "direct",
) -> OutboundResult:
msg_body = [
{
"MsgType": "TIMVideoFileElem",
"MsgContent": {
"VideoUrl": video_url,
"VideoSize": 0,
"VideoSecond": duration,
"VideoFormat": video_url.rsplit(".", 1)[-1] if "." in video_url else "mp4",
"VideoDownloadFlag": 2,
"ThumbUrl": thumb_url,
"ThumbSize": 0,
"ThumbFormat": "jpg",
"ThumbDownloadFlag": 2,
},
}
]
return await self._send_message(target_id, msg_body, chat_type)
async def send_file(
self,
target_id: str,
file_url: str,
file_name: str = "",
file_size: int = 0,
*,
chat_type: str = "direct",
) -> OutboundResult:
msg_body = [
{
"MsgType": "TIMFileElem",
"MsgContent": {
"Url": file_url,
"UUID": file_name or file_url.rsplit("/", 1)[-1],
"FileSize": file_size,
"FileName": file_name or file_url.rsplit("/", 1)[-1],
"Download_Flag": 2,
},
}
]
return await self._send_message(target_id, msg_body, chat_type)
async def withdraw_message(
self,
target_id: str,
msg_key: str,
*,
chat_type: str = "direct",
) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
if chat_type == "group":
body = {
"GroupId": target_id,
"MsgSeqList": [{"MsgSeq": int(msg_key)}],
}
result = await self._gateway.call_api("group_open_http_svc/group_msg_recall", body)
else:
body = {
"From_Account": self._gateway._account.admin_userid,
"To_Account": target_id,
"MsgKey": msg_key,
}
result = await self._gateway.call_api("openim/admin_msgwithdraw", body)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
logger.error("TencentIM withdraw failed: code=%s info=%s", e.code, e.info)
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def set_portrait(
self, user_id: str, nickname: str | None = None, avatar_url: str | None = None
) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
profile_item = []
if nickname:
profile_item.append({"Tag": "Tag_Profile_IM_Nick", "Value": nickname})
if avatar_url:
profile_item.append({"Tag": "Tag_Profile_IM_Image", "Value": avatar_url})
if not profile_item:
return OutboundResult(success=False, error="no profile fields to set")
try:
result = await self._gateway.call_api(
"profile/portrait_set",
{
"From_Account": user_id,
"ProfileItem": profile_item,
},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def get_portrait(self, user_ids: list[str]) -> list[dict]:
if not self._gateway:
return []
try:
result = await self._gateway.call_api(
"profile/portrait_get",
{
"To_Account": user_ids,
"TagList": [
"Tag_Profile_IM_Nick",
"Tag_Profile_IM_Image",
],
},
)
return result.get("UserProfileItem", [])
except TencentIMError:
return []
async def account_import(self, user_id: str, nickname: str = "", face_url: str = "") -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
body = {"UserID": user_id}
if nickname:
body["Nick"] = nickname
if face_url:
body["FaceUrl"] = face_url
try:
result = await self._gateway.call_api("im_open_login_svc/account_import", body)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def multiaccount_import(self, user_ids: list[str]) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
result = await self._gateway.call_api(
"im_open_login_svc/multiaccount_import",
{"Accounts": user_ids},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def account_delete(self, user_ids: list[str]) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
result = await self._gateway.call_api(
"im_open_login_svc/account_delete",
{"DeleteItem": [{"UserID": uid} for uid in user_ids]},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def kick_user(self, user_id: str) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
result = await self._gateway.call_api(
"im_open_login_svc/kick",
{"UserID": user_id},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def query_online_status(self, user_ids: list[str]) -> list[dict]:
if not self._gateway:
return []
try:
result = await self._gateway.call_api(
"openim/query_online_status",
{"To_Account": user_ids},
)
return result.get("QueryResult", [])
except TencentIMError:
return []
async def set_msg_read(self, target_id: str) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
result = await self._gateway.call_api(
"openim/admin_set_msg_read",
{
"Report_Account": self._gateway._account.admin_userid,
"Peer_Account": target_id,
},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def modify_c2c_msg(self, target_id: str, msg_key: str, new_content: str) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
result = await self._gateway.call_api(
"openim/modify_c2c_msg",
{
"From_Account": self._gateway._account.admin_userid,
"To_Account": target_id,
"MsgKey": msg_key,
"MsgBody": [{"MsgType": "TIMTextElem", "MsgContent": {"Text": new_content}}],
},
)
return OutboundResult(success=True, raw_response=result)
except TencentIMError as e:
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def _send_message(
self,
target_id: str,
msg_body: list,
chat_type: str = "direct",
cloud_custom_data: str | None = None,
) -> OutboundResult:
if not self._gateway:
return OutboundResult(success=False, error="gateway not started")
try:
if chat_type == "group":
body = {
"GroupId": target_id,
"From_Account": self._gateway._account.admin_userid,
"MsgRandom": random.randint(0, 0xFFFFFFFF),
"MsgPriority": "Normal",
"MsgBody": msg_body,
}
else:
body = {
"SyncOtherMachine": 2,
"From_Account": self._gateway._account.admin_userid,
"To_Account": target_id,
"MsgLifeTime": 604800,
"MsgRandom": random.randint(0, 0xFFFFFFFF),
"MsgBody": msg_body,
}
if cloud_custom_data:
body["CloudCustomData"] = cloud_custom_data
if chat_type == "group":
result = await self._gateway.call_api("group_open_http_svc/send_group_msg", body)
else:
result = await self._gateway.call_api("openim/sendmsg", body)
return OutboundResult(
success=True,
message_id=str(result.get("MsgSeq", "")),
msg_key=str(result.get("MsgKey", "")),
raw_response=result,
)
except TencentIMError as e:
logger.error("TencentIM send failed: code=%s info=%s", e.code, e.info)
return OutboundResult(success=False, error=str(e), raw_response=e.raw)
async def upload_image(self, image_data: bytes, file_name: str = "image.png") -> str | None:
if not self._gateway:
logger.warning("TencentIM gateway not available for image upload")
return None
try:
result = await self._gateway.call_api(
"openpic/http_upload",
{
"ImageContent": base64.b64encode(image_data).decode("utf-8"),
"FileName": file_name,
},
)
download_url = result.get("Download_URL", "")
return download_url or None
except TencentIMError as e:
logger.error("TencentIM image upload failed: code=%s", e.code)
return None