qrpa 1.1.79__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of qrpa might be problematic. Click here for more details.

qrpa/feishu_bot_app.py ADDED
@@ -0,0 +1,607 @@
1
+ # pip install lark-oapi -U
2
+ import json
3
+ import os
4
+ import uuid
5
+ from typing import Optional
6
+
7
+ import lark_oapi as lark
8
+ from lark_oapi.api.im.v1 import *
9
+
10
+
11
+ class FeishuBot:
12
+ """飞书机器人类,封装所有机器人相关功能"""
13
+
14
+ def __init__(self, config):
15
+ """
16
+ 初始化飞书机器人
17
+
18
+ Args:
19
+ config: 配置对象,包含应用ID、应用密钥和群组信息
20
+ """
21
+ self.config = config
22
+ self._client = None
23
+
24
+ @property
25
+ def client(self):
26
+ """获取飞书客户端,使用懒加载模式"""
27
+ if self._client is None:
28
+ self._client = lark.Client.builder() \
29
+ .app_id(self.config.feishu_bot.app_id) \
30
+ .app_secret(self.config.feishu_bot.app_secret) \
31
+ .log_level(lark.LogLevel.INFO) \
32
+ .build()
33
+ return self._client
34
+
35
+ def _get_chat_id(self, bot_name: str) -> Optional[str]:
36
+ """
37
+ 根据群组别名获取群组ID
38
+
39
+ Args:
40
+ bot_name: 群组别名
41
+
42
+ Returns:
43
+ 群组ID,如果别名不存在则返回None
44
+ """
45
+ return self.config.dict_feishu_group.get(bot_name)
46
+
47
+ def _handle_response_error(self, response, operation_name: str):
48
+ """
49
+ 处理API响应错误
50
+
51
+ Args:
52
+ response: API响应对象
53
+ operation_name: 操作名称,用于错误日志
54
+ """
55
+ if not response.success():
56
+ lark.logger.error(
57
+ f"{operation_name} failed, code: {response.code}, "
58
+ f"msg: {response.msg}, log_id: {response.get_log_id()}, "
59
+ f"resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}"
60
+ )
61
+ return True
62
+ return False
63
+
64
+ def send_text(self, content: str, bot_name: str = 'test') -> bool:
65
+ """
66
+ 发送文本消息
67
+
68
+ Args:
69
+ content: 文本内容
70
+ bot_name: 群组别名,默认为'test'
71
+
72
+ Returns:
73
+ 发送是否成功
74
+ """
75
+ chat_id = self._get_chat_id(bot_name)
76
+ if not chat_id:
77
+ lark.logger.error(f"未找到群组别名 '{bot_name}' 对应的群组ID")
78
+ return False
79
+
80
+ message_content = {"text": content}
81
+
82
+ # 构造请求对象
83
+ request: CreateMessageRequest = CreateMessageRequest.builder() \
84
+ .receive_id_type("chat_id") \
85
+ .request_body(CreateMessageRequestBody.builder()
86
+ .receive_id(chat_id)
87
+ .msg_type("text")
88
+ .content(json.dumps(message_content))
89
+ .uuid(str(uuid.uuid4()))
90
+ .build()) \
91
+ .build()
92
+
93
+ # 发起请求
94
+ response: CreateMessageResponse = self.client.im.v1.message.create(request)
95
+
96
+ # 处理失败返回
97
+ if self._handle_response_error(response, "send_text"):
98
+ return False
99
+
100
+ # 处理业务结果
101
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
102
+ return True
103
+
104
+ def send_image(self, file_path: str, bot_name: str = 'test') -> bool:
105
+ """
106
+ 发送图片消息
107
+
108
+ Args:
109
+ file_path: 图片文件路径
110
+ bot_name: 群组别名,默认为'test'
111
+
112
+ Returns:
113
+ 发送是否成功
114
+ """
115
+ # 先上传图片获取image_key
116
+ image_key = self.upload_image(file_path)
117
+ if not image_key:
118
+ return False
119
+
120
+ chat_id = self._get_chat_id(bot_name)
121
+ if not chat_id:
122
+ lark.logger.error(f"未找到群组别名 '{bot_name}' 对应的群组ID")
123
+ return False
124
+
125
+ message_content = {"image_key": image_key}
126
+
127
+ # 构造请求对象
128
+ request: CreateMessageRequest = CreateMessageRequest.builder() \
129
+ .receive_id_type("chat_id") \
130
+ .request_body(CreateMessageRequestBody.builder()
131
+ .receive_id(chat_id)
132
+ .msg_type("image")
133
+ .content(json.dumps(message_content))
134
+ .uuid(str(uuid.uuid4()))
135
+ .build()) \
136
+ .build()
137
+
138
+ # 发起请求
139
+ response: CreateMessageResponse = self.client.im.v1.message.create(request)
140
+
141
+ # 处理失败返回
142
+ if self._handle_response_error(response, "send_image"):
143
+ return False
144
+
145
+ # 处理业务结果
146
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
147
+ return True
148
+
149
+ def send_excel(self, file_path: str, bot_name: str = 'test') -> bool:
150
+ """
151
+ 发送Excel文件
152
+
153
+ Args:
154
+ file_path: Excel文件路径
155
+ bot_name: 群组别名,默认为'test'
156
+
157
+ Returns:
158
+ 发送是否成功
159
+ """
160
+ # 先上传文件获取file_key
161
+ file_key = self.upload_excel(file_path)
162
+ if not file_key:
163
+ return False
164
+
165
+ chat_id = self._get_chat_id(bot_name)
166
+ if not chat_id:
167
+ lark.logger.error(f"未找到群组别名 '{bot_name}' 对应的群组ID")
168
+ return False
169
+
170
+ message_content = {"file_key": file_key}
171
+
172
+ # 构造请求对象
173
+ request: CreateMessageRequest = CreateMessageRequest.builder() \
174
+ .receive_id_type("chat_id") \
175
+ .request_body(CreateMessageRequestBody.builder()
176
+ .receive_id(chat_id)
177
+ .msg_type("file")
178
+ .content(json.dumps(message_content))
179
+ .uuid(str(uuid.uuid4()))
180
+ .build()) \
181
+ .build()
182
+
183
+ # 发起请求
184
+ response: CreateMessageResponse = self.client.im.v1.message.create(request)
185
+
186
+ # 处理失败返回
187
+ if self._handle_response_error(response, "send_excel"):
188
+ return False
189
+
190
+ # 处理业务结果
191
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
192
+ return True
193
+
194
+ def upload_excel(self, file_path: str) -> Optional[str]:
195
+ """
196
+ 上传Excel文件
197
+
198
+ Args:
199
+ file_path: 文件路径
200
+
201
+ Returns:
202
+ 文件key,上传失败返回None
203
+ """
204
+ if not os.path.exists(file_path):
205
+ lark.logger.error(f"文件不存在: {file_path}")
206
+ return None
207
+
208
+ try:
209
+ with open(file_path, "rb") as file:
210
+ file_name = os.path.basename(file_path)
211
+ request: CreateFileRequest = CreateFileRequest.builder() \
212
+ .request_body(CreateFileRequestBody.builder()
213
+ .file_type("xls")
214
+ .file_name(file_name)
215
+ .file(file)
216
+ .build()) \
217
+ .build()
218
+
219
+ # 发起请求
220
+ response: CreateFileResponse = self.client.im.v1.file.create(request)
221
+
222
+ # 处理失败返回
223
+ if self._handle_response_error(response, "upload_excel"):
224
+ return None
225
+
226
+ # 处理业务结果
227
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
228
+ return response.data.file_key
229
+ except Exception as e:
230
+ lark.logger.error(f"上传Excel文件时发生错误: {e}")
231
+ return None
232
+
233
+ def upload_image(self, file_path: str) -> Optional[str]:
234
+ """
235
+ 上传图片文件
236
+
237
+ Args:
238
+ file_path: 图片文件路径
239
+
240
+ Returns:
241
+ 图片key,上传失败返回None
242
+ """
243
+ if not os.path.exists(file_path):
244
+ lark.logger.error(f"文件不存在: {file_path}")
245
+ return None
246
+
247
+ try:
248
+ with open(file_path, "rb") as file:
249
+ request: CreateImageRequest = CreateImageRequest.builder() \
250
+ .request_body(CreateImageRequestBody.builder()
251
+ .image_type("message")
252
+ .image(file)
253
+ .build()) \
254
+ .build()
255
+
256
+ # 发起请求
257
+ response: CreateImageResponse = self.client.im.v1.image.create(request)
258
+
259
+ # 处理失败返回
260
+ if self._handle_response_error(response, "upload_image"):
261
+ return None
262
+
263
+ # 处理业务结果
264
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
265
+ return response.data.image_key
266
+ except Exception as e:
267
+ lark.logger.error(f"上传图片文件时发生错误: {e}")
268
+ return None
269
+
270
+ def upload_image_from_url(self, image_url: str) -> Optional[str]:
271
+ """
272
+ 从URL下载图片并上传到飞书
273
+
274
+ Args:
275
+ image_url: 图片URL地址
276
+
277
+ Returns:
278
+ 图片key,上传失败返回None
279
+ """
280
+ import tempfile
281
+ import requests
282
+ import re
283
+
284
+ try:
285
+ # 下载图片
286
+ response = requests.get(image_url, timeout=30)
287
+ if response.status_code != 200:
288
+ lark.logger.error(f"下载图片失败, HTTP状态码: {response.status_code}, URL: {image_url}")
289
+ return None
290
+
291
+ # 从URL或Content-Type推断文件扩展名
292
+ content_type = response.headers.get('Content-Type', '')
293
+ if 'jpeg' in content_type or 'jpg' in content_type:
294
+ ext = '.jpg'
295
+ elif 'png' in content_type:
296
+ ext = '.png'
297
+ elif 'gif' in content_type:
298
+ ext = '.gif'
299
+ else:
300
+ # 从URL中提取扩展名
301
+ url_path = image_url.split('?')[0]
302
+ if url_path.endswith('.png'):
303
+ ext = '.png'
304
+ elif url_path.endswith('.gif'):
305
+ ext = '.gif'
306
+ else:
307
+ ext = '.jpg'
308
+
309
+ # 保存到临时文件
310
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_file:
311
+ tmp_file.write(response.content)
312
+ tmp_path = tmp_file.name
313
+
314
+ # 上传图片
315
+ image_key = self.upload_image(tmp_path)
316
+
317
+ # 删除临时文件
318
+ try:
319
+ os.remove(tmp_path)
320
+ except:
321
+ pass
322
+
323
+ return image_key
324
+
325
+ except Exception as e:
326
+ lark.logger.error(f"从URL上传图片时发生错误: {e}, URL: {image_url}")
327
+ return None
328
+
329
+ @staticmethod
330
+ def extract_image_url_from_html(html_content: str) -> Optional[str]:
331
+ """
332
+ 从HTML内容中提取第一个img标签的src属性
333
+
334
+ Args:
335
+ html_content: HTML字符串
336
+
337
+ Returns:
338
+ 图片URL,未找到返回None
339
+ """
340
+ import re
341
+
342
+ if not html_content:
343
+ return None
344
+
345
+ # 使用正则匹配 img 标签的 src 属性
346
+ pattern = r'<img[^>]+src=["\']([^"\']+)["\']'
347
+ match = re.search(pattern, html_content)
348
+
349
+ if match:
350
+ return match.group(1)
351
+
352
+ return None
353
+
354
+ def send_card(self, card_content: dict, bot_name: str = 'test', msg_uuid: str = None) -> bool:
355
+ """
356
+ 发送卡片消息(交互式消息)
357
+
358
+ Args:
359
+ card_content: 卡片内容,包含 config、elements、header 等字段的字典
360
+ bot_name: 群组别名,默认为'test'
361
+ msg_uuid: 消息唯一标识,用于幂等性,默认自动生成
362
+
363
+ Returns:
364
+ 发送是否成功
365
+ """
366
+ chat_id = self._get_chat_id(bot_name)
367
+ if not chat_id:
368
+ lark.logger.error(f"未找到群组别名 '{bot_name}' 对应的群组ID")
369
+ return False
370
+
371
+ if msg_uuid is None:
372
+ msg_uuid = str(uuid.uuid4())
373
+
374
+ # 构造请求对象
375
+ request: CreateMessageRequest = CreateMessageRequest.builder() \
376
+ .receive_id_type("chat_id") \
377
+ .request_body(CreateMessageRequestBody.builder()
378
+ .receive_id(chat_id)
379
+ .msg_type("interactive")
380
+ .content(json.dumps(card_content, ensure_ascii=False))
381
+ .uuid(msg_uuid)
382
+ .build()) \
383
+ .build()
384
+
385
+ # 发起请求
386
+ response: CreateMessageResponse = self.client.im.v1.message.create(request)
387
+
388
+ # 处理失败返回
389
+ if self._handle_response_error(response, "send_card"):
390
+ return False
391
+
392
+ # 处理业务结果
393
+ lark.logger.info(lark.JSON.marshal(response.data, indent=4))
394
+ return True
395
+
396
+ def build_shein_announcement_card(self, announcements: list, title: str = None) -> dict:
397
+ """
398
+ 构建希音公告卡片内容
399
+
400
+ Args:
401
+ announcements: 公告列表,每条包含 detail(公告详情)、img_key 等字段
402
+ title: 卡片标题,默认根据当前时间生成
403
+
404
+ Returns:
405
+ 卡片内容字典
406
+ """
407
+ from datetime import datetime, timedelta
408
+
409
+ if title is None:
410
+ yesterday = datetime.now() - timedelta(days=1)
411
+ title = f"希音公告【{yesterday.strftime('%Y年%m月%d日')}17时至发布时】"
412
+
413
+ elements = []
414
+
415
+ for item in announcements:
416
+ # 添加分隔线
417
+ elements.append({"tag": "hr"})
418
+
419
+ # 从 detail 中获取数据
420
+ detail = item.get('detail', {})
421
+ announcement_title = detail.get('title', '') or item.get('title', '')
422
+ start_time = detail.get('startTime', '') or item.get('startTime', '')
423
+ img_key = item.get('img_key', '')
424
+
425
+ # 获取 importantType、typeDesc、tagDesc
426
+ important_type = detail.get('importantType', '')
427
+ type_desc = detail.get('typeDesc', '')
428
+ tag_desc = detail.get('tagDesc', '')
429
+
430
+ # 构建类型信息行
431
+ type_info_parts = []
432
+ # importantType 为 1 时展示红色 (重要)
433
+ if str(important_type) == '1':
434
+ type_info_parts.append("<font color='red'>(重要)</font>")
435
+ if type_desc:
436
+ type_info_parts.append(type_desc)
437
+ if tag_desc:
438
+ type_info_parts.append(tag_desc)
439
+ type_info = ' | '.join(type_info_parts) if type_info_parts else ''
440
+
441
+ # 构建内容:标题(蓝色)+ 类型信息(灰色)+ 时间(灰色,单独一行)
442
+ content_parts = []
443
+ content_parts.append(f"<font color='blue'>**{announcement_title}**</font>")
444
+
445
+ if type_info:
446
+ content_parts.append(f"<font color='grey'>{type_info}</font>")
447
+ if start_time:
448
+ content_parts.append(f"<font color='grey'>{start_time}</font>")
449
+
450
+ content_text = "\n".join(content_parts)
451
+
452
+ element = {
453
+ "tag": "div",
454
+ "text": {
455
+ "content": content_text,
456
+ "tag": "lark_md"
457
+ }
458
+ }
459
+
460
+ # 如果有图片,添加 extra
461
+ if img_key:
462
+ element["extra"] = {
463
+ "alt": {
464
+ "content": "",
465
+ "tag": "plain_text"
466
+ },
467
+ "img_key": img_key,
468
+ "tag": "img"
469
+ }
470
+
471
+ elements.append(element)
472
+
473
+ return {
474
+ "config": {
475
+ "wide_screen_mode": True
476
+ },
477
+ "elements": elements,
478
+ "header": {
479
+ "template": "purple",
480
+ "title": {
481
+ "content": title,
482
+ "tag": "plain_text"
483
+ }
484
+ }
485
+ }
486
+
487
+ def build_shein_violation_card(self, penalty_data: dict, appeal_data: dict, title: str = None) -> dict:
488
+ """
489
+ 构建希音违规处罚与申诉卡片内容
490
+
491
+ Args:
492
+ penalty_data: 违规处罚数据,按店铺分组 {store_username: {'store_username': x, 'store_name': x, 'store_manager': x, 'data': [...], 'total': x}}
493
+ appeal_data: 违规申诉数据,按店铺分组 {store_username: {'store_username': x, 'store_name': x, 'store_manager': x, 'data': [...], 'total': x}}
494
+ title: 卡片标题,默认根据当前时间生成
495
+
496
+ Returns:
497
+ 卡片内容字典
498
+ """
499
+ from datetime import datetime, timedelta
500
+
501
+ if title is None:
502
+ yesterday = datetime.now() - timedelta(days=1)
503
+ title = f"希音违规处罚与申诉【{yesterday.strftime('%Y年%m月%d日')}17时至发布时】"
504
+
505
+ elements = []
506
+
507
+ # 处理违规处罚
508
+ if penalty_data:
509
+ elements.append({"tag": "hr"})
510
+
511
+ for store_username, store_info in penalty_data.items():
512
+ store_name = store_info.get('store_name', '')
513
+ store_manager = store_info.get('store_manager', '')
514
+ total = store_info.get('total', 0)
515
+ data_list = store_info.get('data', [])
516
+
517
+ # 店铺头部信息(一行展示,蓝色)
518
+ store_header = f"<font color='blue'>{store_username} {store_name}【{store_manager}】</font>"
519
+ elements.append({
520
+ "tag": "div",
521
+ "text": {
522
+ "tag": "lark_md",
523
+ "content": store_header
524
+ }
525
+ })
526
+
527
+ # 列出每条违规的详情(灰色小字)
528
+ for item in data_list:
529
+ violation_title = item.get('title', '')
530
+ description = item.get('description', '')
531
+ add_time = item.get('addTime', '')
532
+
533
+ item_content = f"<font color='grey'>**{violation_title}** {description} [{add_time}]</font>"
534
+ elements.append({
535
+ "tag": "div",
536
+ "text": {
537
+ "tag": "lark_md",
538
+ "content": item_content
539
+ }
540
+ })
541
+
542
+ # 处理违规申诉
543
+ if appeal_data:
544
+ elements.append({"tag": "hr"})
545
+ elements.append({
546
+ "tag": "div",
547
+ "text": {
548
+ "content": "**📝 违规申诉通知**",
549
+ "tag": "lark_md"
550
+ }
551
+ })
552
+
553
+ for store_username, store_info in appeal_data.items():
554
+ store_name = store_info.get('store_name', '')
555
+ store_manager = store_info.get('store_manager', '')
556
+ total = store_info.get('total', 0)
557
+ data_list = store_info.get('data', [])
558
+
559
+ # 店铺头部信息(一行展示,蓝色)
560
+ store_header = f"<font color='blue'>{store_username} {store_name}【{store_manager}】</font>"
561
+ elements.append({
562
+ "tag": "div",
563
+ "text": {
564
+ "tag": "lark_md",
565
+ "content": store_header
566
+ }
567
+ })
568
+
569
+ # 列出每条申诉的详情(灰色小字)
570
+ for item in data_list:
571
+ appeal_title = item.get('title', '')
572
+ description = item.get('description', '')
573
+ add_time = item.get('addTime', '')
574
+
575
+ item_content = f"<font color='grey'>**{appeal_title}** {description} [{add_time}]</font>"
576
+ elements.append({
577
+ "tag": "div",
578
+ "text": {
579
+ "tag": "lark_md",
580
+ "content": item_content
581
+ }
582
+ })
583
+
584
+ # 如果没有数据
585
+ if not elements:
586
+ elements.append({"tag": "hr"})
587
+ elements.append({
588
+ "tag": "div",
589
+ "text": {
590
+ "content": "暂无违规处罚与申诉通知",
591
+ "tag": "lark_md"
592
+ }
593
+ })
594
+
595
+ return {
596
+ "config": {
597
+ "wide_screen_mode": True
598
+ },
599
+ "elements": elements,
600
+ "header": {
601
+ "template": "red",
602
+ "title": {
603
+ "content": title,
604
+ "tag": "plain_text"
605
+ }
606
+ }
607
+ }