Skip to content

Commit 6e3514c

Browse files
authored
feat: add support for wecom customer service (#1304)
1 parent 2782c8c commit 6e3514c

File tree

6 files changed

+754
-0
lines changed

6 files changed

+754
-0
lines changed

libs/wecom_customer_service_api/__init__.py

Whitespace-only changes.
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)