新增腾讯 IM(Tencent IM)渠道扩展,支持在 Yuxi 平台中集成腾讯即时通讯 IM 渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - usersig: UserSig 生成 - dedupe: 消息去重 - status: 会话状态管理 - group: 群组管理 - types: 类型定义
403 lines
14 KiB
Python
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
|