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