| 
 | 1 | +from quart import request  | 
 | 2 | +from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt  | 
 | 3 | +import base64  | 
 | 4 | +import binascii  | 
 | 5 | +import httpx  | 
 | 6 | +import traceback  | 
 | 7 | +from quart import Quart  | 
 | 8 | +import xml.etree.ElementTree as ET  | 
 | 9 | +from typing import Callable, Dict, Any  | 
 | 10 | +from .wecomcsevent import WecomCSEvent  | 
 | 11 | +from pkg.platform.types import events as platform_events, message as platform_message  | 
 | 12 | +import aiofiles  | 
 | 13 | + | 
 | 14 | + | 
 | 15 | +class WecomCSClient():  | 
 | 16 | +    def __init__(self,corpid:str,secret:str,token:str,EncodingAESKey:str):  | 
 | 17 | +        self.corpid = corpid  | 
 | 18 | +        self.secret = secret  | 
 | 19 | +        self.access_token_for_contacts =''  | 
 | 20 | +        self.token = token  | 
 | 21 | +        self.aes = EncodingAESKey  | 
 | 22 | +        self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'  | 
 | 23 | +        self.access_token = ''  | 
 | 24 | +        self.app = Quart(__name__)  | 
 | 25 | +        self.wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)  | 
 | 26 | +        self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'])  | 
 | 27 | +        self._message_handlers = {  | 
 | 28 | +            "example":[],  | 
 | 29 | +        }  | 
 | 30 | + | 
 | 31 | +    async def get_pic_url(self, media_id: str):  | 
 | 32 | +        if not await self.check_access_token():  | 
 | 33 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 34 | + | 
 | 35 | +        url = f"{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}"  | 
 | 36 | + | 
 | 37 | +        async with httpx.AsyncClient() as client:  | 
 | 38 | +            response = await client.get(url)  | 
 | 39 | +            if response.headers.get("Content-Type", "").startswith("application/json"):  | 
 | 40 | +                data = response.json()  | 
 | 41 | +                if data.get('errcode') in [40014, 42001]:  | 
 | 42 | +                    self.access_token = await self.get_access_token(self.secret)  | 
 | 43 | +                    return await self.get_pic_url(media_id)  | 
 | 44 | +                else:  | 
 | 45 | +                    raise Exception("Failed to get image: " + str(data))  | 
 | 46 | + | 
 | 47 | +            # 否则是图片,转成 base64  | 
 | 48 | +            image_bytes = response.content  | 
 | 49 | +            content_type = response.headers.get("Content-Type", "")  | 
 | 50 | +            base64_str = base64.b64encode(image_bytes).decode("utf-8")  | 
 | 51 | +            base64_str = f"data:{content_type};base64,{base64_str}"  | 
 | 52 | +            return base64_str  | 
 | 53 | + | 
 | 54 | + | 
 | 55 | +    #access——token操作  | 
 | 56 | +    async def check_access_token(self):  | 
 | 57 | +        return bool(self.access_token and self.access_token.strip())  | 
 | 58 | + | 
 | 59 | +    async def check_access_token_for_contacts(self):  | 
 | 60 | +        return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())  | 
 | 61 | + | 
 | 62 | +    async def get_access_token(self,secret):  | 
 | 63 | +        url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'  | 
 | 64 | +        async with httpx.AsyncClient() as client:  | 
 | 65 | +            response = await client.get(url)  | 
 | 66 | +            data = response.json()  | 
 | 67 | +            if 'access_token' in data:  | 
 | 68 | +                return data['access_token']  | 
 | 69 | +            else:  | 
 | 70 | +                raise Exception(f"未获取access token: {data}")  | 
 | 71 | +      | 
 | 72 | +    async def get_detailed_message_list(self,xml_msg:str):  | 
 | 73 | +        # 在本方法中解析消息,并且获得消息的具体内容  | 
 | 74 | +        root = ET.fromstring(xml_msg)  | 
 | 75 | +        token = root.find("Token").text  | 
 | 76 | +        open_kfid = root.find("OpenKfId").text  | 
 | 77 | +          | 
 | 78 | +        # if open_kfid in self.openkfid_list:  | 
 | 79 | +        #     return None  | 
 | 80 | +        # else:  | 
 | 81 | +        #     self.openkfid_list.append(open_kfid)  | 
 | 82 | +          | 
 | 83 | +        if not await self.check_access_token():  | 
 | 84 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 85 | +          | 
 | 86 | +        url = self.base_url+'/kf/sync_msg?access_token='+ self.access_token  | 
 | 87 | +        async with httpx.AsyncClient() as client:  | 
 | 88 | +            params = {  | 
 | 89 | +                "token": token,  | 
 | 90 | +                "voice_format": 0,  | 
 | 91 | +                "open_kfid": open_kfid,  | 
 | 92 | +            }  | 
 | 93 | +            response = await client.post(url,json=params)  | 
 | 94 | +            data = response.json()  | 
 | 95 | +            if data['errcode'] != 0:  | 
 | 96 | +                raise Exception("Failed to get message")  | 
 | 97 | +            if data['errcode'] == 40014 or data['errcode'] == 42001:  | 
 | 98 | +                self.access_token = await self.get_access_token(self.secret)  | 
 | 99 | +                return await self.get_detailed_message_list(xml_msg)  | 
 | 100 | +            last_msg_data = data['msg_list'][-1]  | 
 | 101 | +            open_kfid = last_msg_data.get("open_kfid")  | 
 | 102 | +            # 进行获取图片操作  | 
 | 103 | +            if last_msg_data.get("msgtype") == "image":  | 
 | 104 | +                media_id = last_msg_data.get("image").get("media_id")  | 
 | 105 | +                picurl = await self.get_pic_url(media_id)  | 
 | 106 | +                last_msg_data["picurl"] = picurl  | 
 | 107 | +            # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)  | 
 | 108 | +            return last_msg_data  | 
 | 109 | +      | 
 | 110 | +      | 
 | 111 | +    async def change_service_status(self,userid:str,openkfid:str,servicer:str):  | 
 | 112 | +        if not await self.check_access_token():  | 
 | 113 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 114 | +        url = self.base_url+"/kf/service_state/get?access_token="+self.access_token  | 
 | 115 | +        async with httpx.AsyncClient() as client:  | 
 | 116 | +            params = {  | 
 | 117 | +                "open_kfid" : openkfid,  | 
 | 118 | +                "external_userid" : userid,  | 
 | 119 | +                "service_state" : 1,  | 
 | 120 | +                "servicer_userid" : servicer,  | 
 | 121 | +            }  | 
 | 122 | +            response = await client.post(url,json=params)  | 
 | 123 | +            data = response.json()  | 
 | 124 | +            if data['errcode'] == 40014 or data['errcode'] == 42001:  | 
 | 125 | +                self.access_token = await self.get_access_token(self.secret)  | 
 | 126 | +                return await self.change_service_status(userid,openkfid)  | 
 | 127 | +            if data['errcode'] != 0:  | 
 | 128 | +                raise Exception("Failed to change service status: "+str(data))  | 
 | 129 | +              | 
 | 130 | + | 
 | 131 | +    async def send_image(self,user_id:str,agent_id:int,media_id:str):  | 
 | 132 | +        if not await self.check_access_token():  | 
 | 133 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 134 | +        url = self.base_url+'/media/upload?access_token='+self.access_token  | 
 | 135 | +        async with httpx.AsyncClient() as client:  | 
 | 136 | +            params = {  | 
 | 137 | +                "touser" : user_id,  | 
 | 138 | +                "toparty" : "",  | 
 | 139 | +                "totag":"",  | 
 | 140 | +                "agentid" : agent_id,  | 
 | 141 | +                "msgtype" : "image",  | 
 | 142 | +                "image" : {  | 
 | 143 | +                    "media_id" : media_id,  | 
 | 144 | +                },  | 
 | 145 | +                "safe":0,  | 
 | 146 | +                "enable_id_trans": 0,  | 
 | 147 | +                "enable_duplicate_check": 0,  | 
 | 148 | +                "duplicate_check_interval": 1800  | 
 | 149 | +            }  | 
 | 150 | +            try:  | 
 | 151 | +                response = await client.post(url,json=params)  | 
 | 152 | +                data = response.json()  | 
 | 153 | +            except Exception as e:  | 
 | 154 | +                raise Exception("Failed to send image: "+str(e))  | 
 | 155 | + | 
 | 156 | +            # 企业微信错误码40014和42001,代表accesstoken问题  | 
 | 157 | +            if data['errcode'] == 40014 or data['errcode'] == 42001:  | 
 | 158 | +                self.access_token = await self.get_access_token(self.secret)  | 
 | 159 | +                return await self.send_image(user_id,agent_id,media_id)  | 
 | 160 | + | 
 | 161 | +            if data['errcode'] != 0:  | 
 | 162 | +                raise Exception("Failed to send image: "+str(data))  | 
 | 163 | +              | 
 | 164 | + | 
 | 165 | +    async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str,content:str):  | 
 | 166 | +        if not await self.check_access_token():  | 
 | 167 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 168 | + | 
 | 169 | +        url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}"  | 
 | 170 | + | 
 | 171 | +        payload = {  | 
 | 172 | +            "touser": external_userid,  | 
 | 173 | +            "open_kfid": open_kfid,  | 
 | 174 | +            "msgid": msgid,  | 
 | 175 | +            "msgtype": "text",  | 
 | 176 | +            "text": {  | 
 | 177 | +                "content": content,  | 
 | 178 | +            }  | 
 | 179 | +        }  | 
 | 180 | + | 
 | 181 | +        async with httpx.AsyncClient() as client:  | 
 | 182 | +            response = await client.post(url, json=payload)  | 
 | 183 | + | 
 | 184 | +            data = response.json()  | 
 | 185 | +            if data.get("errcode") != 0:  | 
 | 186 | +                raise Exception(f"消息发送失败: {data}")  | 
 | 187 | +            return data  | 
 | 188 | + | 
 | 189 | + | 
 | 190 | +    async def handle_callback_request(self):  | 
 | 191 | +        """  | 
 | 192 | +        处理回调请求,包括 GET 验证和 POST 消息接收。  | 
 | 193 | +        """  | 
 | 194 | +        try:  | 
 | 195 | + | 
 | 196 | +            msg_signature = request.args.get("msg_signature")  | 
 | 197 | +            timestamp = request.args.get("timestamp")  | 
 | 198 | +            nonce = request.args.get("nonce")  | 
 | 199 | + | 
 | 200 | +            if request.method == "GET":  | 
 | 201 | +                echostr = request.args.get("echostr")  | 
 | 202 | +                ret, reply_echo_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)  | 
 | 203 | +                if ret != 0:  | 
 | 204 | +                    raise Exception(f"验证失败,错误码: {ret}")  | 
 | 205 | +                return reply_echo_str  | 
 | 206 | + | 
 | 207 | +            elif request.method == "POST":  | 
 | 208 | +                encrypt_msg = await request.data  | 
 | 209 | +                ret, xml_msg = self.wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)  | 
 | 210 | +                if ret != 0:  | 
 | 211 | +                    raise Exception(f"消息解密失败,错误码: {ret}")  | 
 | 212 | + | 
 | 213 | +                # 解析消息并处理  | 
 | 214 | +                message_data = await self.get_detailed_message_list(xml_msg)  | 
 | 215 | +                if message_data is not None:  | 
 | 216 | +                    event = WecomCSEvent.from_payload(message_data)   | 
 | 217 | +                    if event:  | 
 | 218 | +                        await self._handle_message(event)  | 
 | 219 | + | 
 | 220 | +                return "success"  | 
 | 221 | +        except Exception as e:  | 
 | 222 | +            traceback.print_exc()  | 
 | 223 | +            return f"Error processing request: {str(e)}", 400  | 
 | 224 | + | 
 | 225 | +    async def run_task(self, host: str, port: int, *args, **kwargs):  | 
 | 226 | +        """  | 
 | 227 | +        启动 Quart 应用。  | 
 | 228 | +        """  | 
 | 229 | +        await self.app.run_task(host=host, port=port, *args, **kwargs)  | 
 | 230 | + | 
 | 231 | +    def on_message(self, msg_type: str):  | 
 | 232 | +        """  | 
 | 233 | +        注册消息类型处理器。  | 
 | 234 | +        """  | 
 | 235 | +        def decorator(func: Callable[[WecomCSEvent], None]):  | 
 | 236 | +            if msg_type not in self._message_handlers:  | 
 | 237 | +                self._message_handlers[msg_type] = []  | 
 | 238 | +            self._message_handlers[msg_type].append(func)  | 
 | 239 | +            return func  | 
 | 240 | +        return decorator  | 
 | 241 | + | 
 | 242 | +    async def _handle_message(self, event: WecomCSEvent):  | 
 | 243 | +        """  | 
 | 244 | +        处理消息事件。  | 
 | 245 | +        """  | 
 | 246 | +        msg_type = event.type  | 
 | 247 | +        if msg_type in self._message_handlers:  | 
 | 248 | +            for handler in self._message_handlers[msg_type]:  | 
 | 249 | +                await handler(event)  | 
 | 250 | + | 
 | 251 | +      | 
 | 252 | +    @staticmethod  | 
 | 253 | +    async def get_image_type(image_bytes: bytes) -> str:  | 
 | 254 | +        """  | 
 | 255 | +        通过图片的magic numbers判断图片类型  | 
 | 256 | +        """  | 
 | 257 | +        magic_numbers = {  | 
 | 258 | +            b'\xFF\xD8\xFF': 'jpg',  | 
 | 259 | +            b'\x89\x50\x4E\x47': 'png',  | 
 | 260 | +            b'\x47\x49\x46': 'gif',  | 
 | 261 | +            b'\x42\x4D': 'bmp',  | 
 | 262 | +            b'\x00\x00\x01\x00': 'ico'  | 
 | 263 | +        }  | 
 | 264 | +      | 
 | 265 | +        for magic, ext in magic_numbers.items():  | 
 | 266 | +            if image_bytes.startswith(magic):  | 
 | 267 | +                return ext  | 
 | 268 | +        return 'jpg'  # 默认返回jpg  | 
 | 269 | +      | 
 | 270 | + | 
 | 271 | +    async def upload_to_work(self, image: platform_message.Image):  | 
 | 272 | +        """  | 
 | 273 | +        获取 media_id  | 
 | 274 | +        """  | 
 | 275 | +        if not await self.check_access_token():  | 
 | 276 | +            self.access_token = await self.get_access_token(self.secret)  | 
 | 277 | + | 
 | 278 | +        url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'  | 
 | 279 | +        file_bytes = None  | 
 | 280 | +        file_name = "uploaded_file.txt"  | 
 | 281 | + | 
 | 282 | +        # 获取文件的二进制数据  | 
 | 283 | +        if image.path:  | 
 | 284 | +            async with aiofiles.open(image.path, 'rb') as f:  | 
 | 285 | +                file_bytes = await f.read()  | 
 | 286 | +                file_name = image.path.split('/')[-1]  | 
 | 287 | +        elif image.url:  | 
 | 288 | +            file_bytes = await self.download_image_to_bytes(image.url)  | 
 | 289 | +            file_name = image.url.split('/')[-1]  | 
 | 290 | +        elif image.base64:  | 
 | 291 | +            try:  | 
 | 292 | +                base64_data = image.base64  | 
 | 293 | +                if ',' in base64_data:  | 
 | 294 | +                    base64_data = base64_data.split(',', 1)[1]  | 
 | 295 | +                padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0  | 
 | 296 | +                padded_base64 = base64_data + '=' * padding  | 
 | 297 | +                file_bytes = base64.b64decode(padded_base64)  | 
 | 298 | +            except binascii.Error as e:  | 
 | 299 | +                raise ValueError(f"Invalid base64 string: {str(e)}")  | 
 | 300 | +        else:  | 
 | 301 | +            raise ValueError("image对象出错")  | 
 | 302 | + | 
 | 303 | +        # 设置 multipart/form-data 格式的文件  | 
 | 304 | +        boundary = "-------------------------acebdf13572468"  | 
 | 305 | +        headers = {  | 
 | 306 | +            'Content-Type': f'multipart/form-data; boundary={boundary}'  | 
 | 307 | +        }  | 
 | 308 | +        body = (  | 
 | 309 | +            f"--{boundary}\r\n"  | 
 | 310 | +            f"Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\r\n"  | 
 | 311 | +            f"Content-Type: application/octet-stream\r\n\r\n"  | 
 | 312 | +        ).encode('utf-8') + file_bytes + f"\r\n--{boundary}--\r\n".encode('utf-8')  | 
 | 313 | + | 
 | 314 | +        # 上传文件  | 
 | 315 | +        async with httpx.AsyncClient() as client:  | 
 | 316 | +            response = await client.post(url, headers=headers, content=body)  | 
 | 317 | +            data = response.json()  | 
 | 318 | +            if data['errcode'] == 40014 or data['errcode'] == 42001:  | 
 | 319 | +                self.access_token = await self.get_access_token(self.secret)  | 
 | 320 | +                media_id = await self.upload_to_work(image)  | 
 | 321 | +            if data.get('errcode', 0) != 0:  | 
 | 322 | +                raise Exception("failed to upload file")  | 
 | 323 | + | 
 | 324 | +            media_id = data.get('media_id')  | 
 | 325 | +            return media_id  | 
 | 326 | + | 
 | 327 | +    async def download_image_to_bytes(self,url:str) -> bytes:  | 
 | 328 | +        async with httpx.AsyncClient() as client:  | 
 | 329 | +            response = await client.get(url)  | 
 | 330 | +            response.raise_for_status()  | 
 | 331 | +            return response.content  | 
 | 332 | + | 
 | 333 | +    #进行media_id的获取  | 
 | 334 | +    async def get_media_id(self, image: platform_message.Image):  | 
 | 335 | + | 
 | 336 | +        media_id = await self.upload_to_work(image=image)  | 
 | 337 | +        return media_id  | 
0 commit comments