pytbox 0.0.1__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 pytbox might be problematic. Click here for more details.

@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+ import json
5
+ import uuid
6
+ import time
7
+ import requests
8
+ import shelve
9
+ from typing import TYPE_CHECKING, Literal, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from .client import BaseClient, FeishuResponse
13
+ from .helpers import pick
14
+ from requests_toolbelt import MultipartEncoder
15
+ from ..utils.response import ReturnResponse
16
+
17
+ class Endpoint:
18
+
19
+ def __init__(self, parent: "BaseClient") -> None:
20
+ self.parent = parent
21
+
22
+
23
+ class AuthEndpoint(Endpoint):
24
+
25
+ token_path = '/tmp/.feishu_token'
26
+
27
+ def save_token_to_file(self):
28
+ with shelve.open(self.token_path) as db:
29
+ db['token'] = self.refresh_access_token()
30
+ return True
31
+
32
+ def fetch_token_from_file(self):
33
+ with shelve.open(self.token_path) as db:
34
+ token = db.get('token')
35
+ return token
36
+
37
+ def get_tenant_access_token(self):
38
+ '''
39
+ _summary_
40
+
41
+ Returns:
42
+ _type_: _description_
43
+ '''
44
+ if os.environ.get('TENANT_ACCESS_TOKEN'):
45
+ return os.environ.get('TENANT_ACCESS_TOKEN')
46
+ else:
47
+ print('未找到token, 开始刷新')
48
+ resp = self.refresh_access_token()
49
+ if resp.tenant_access_token:
50
+ os.environ['TENANT_ACCESS_TOKEN'] = resp.tenant_access_token
51
+ return os.environ.get('TENANT_ACCESS_TOKEN')
52
+
53
+ def refresh_access_token(self):
54
+ payload = dict(
55
+ app_id=self.parent.app_id,
56
+ app_secret=self.parent.app_secret
57
+ )
58
+
59
+ token = requests.request(method='POST',
60
+ url=self.parent.options.base_url+'/auth/v3/tenant_access_token/internal',
61
+ json=payload, timeout=5).json()['tenant_access_token']
62
+
63
+ os.environ['TENANT_ACCESS_TOKEN'] = token
64
+ return token
65
+
66
+ class MessageEndpoint(Endpoint):
67
+
68
+ def send_text(self,
69
+ text: str,
70
+ receive_id: str):
71
+
72
+
73
+ format_message_content = json.dumps({ "text": text }, ensure_ascii=False)
74
+
75
+ payload = {
76
+ "content": format_message_content,
77
+ "msg_type": "text",
78
+ "receive_id": receive_id,
79
+ "uuid": str(uuid.uuid4())
80
+ }
81
+ receive_id_type = self.parent.extensions.parse_receive_id_type(receive_id=receive_id)
82
+
83
+ return self.parent.request(path=f'/im/v1/messages?receive_id_type={receive_id_type}',
84
+ method='POST',
85
+ body=payload)
86
+
87
+ def send_post(self,
88
+ receive_id: str=None,
89
+ message_id: str=None,
90
+ title: str=None,
91
+ content: list=None):
92
+ '''
93
+ 发送富文本消息
94
+
95
+ Args:
96
+ reveive_id (str): 必选参数, 接收消息的 id, 可以是 chat_id, 也可以是 openid, 代码会自动判断
97
+ message_id (str): 如果设置此参数, 表示会在原消息上回复消息
98
+ title: (str): 消息的标题
99
+ content: (list): 消息的内容, 示例格式如下
100
+ content = [
101
+ [
102
+ {"tag": "text", "text": "VPN: XXX:8443"}
103
+ ]
104
+ ]
105
+
106
+ Returns:
107
+ response (dict): 返回发送消息后的响应, 是一个大的 json, 还在考虑是否拆分一下
108
+ '''
109
+
110
+ message_content = {
111
+ "zh_cn": {
112
+ "title": title,
113
+ "content": content
114
+ }
115
+ }
116
+
117
+ format_message_content = json.dumps(message_content, ensure_ascii=False)
118
+
119
+ if receive_id:
120
+ receive_id_type = self.parent.extensions.parse_receive_id_type(receive_id=receive_id)
121
+ api = f'/im/v1/messages?receive_id_type={receive_id_type}'
122
+ payload = {
123
+ "content": format_message_content,
124
+ "msg_type": "post",
125
+ "receive_id": receive_id,
126
+ "uuid": str(uuid.uuid4())
127
+ }
128
+
129
+ elif message_id:
130
+ api = f'/im/v1/messages/{message_id}/reply'
131
+ payload = {
132
+ "content": format_message_content,
133
+ "msg_type": "post",
134
+ "uuid": str(uuid.uuid4())
135
+ }
136
+
137
+ return self.parent.request(path=f'/im/v1/messages?receive_id_type={receive_id_type}',
138
+ method='POST',
139
+ body=payload)
140
+
141
+ def send_card(self, template_id: str, template_variable: dict=None, receive_id: str=None):
142
+ '''
143
+ 目前主要使用的发送卡片消息的函数, 从名字可以看出, 这是第2代的发送消息卡片函数
144
+
145
+ Args:
146
+ template_id (str): 消息卡片的 id, 可以在飞书的消息卡片搭建工具中获得该 id
147
+ template_variable (dict): 消息卡片中的变量
148
+ receive_id: (str): 接收消息的 id, 可以填写 open_id、chat_id, 函数会自动检测
149
+
150
+ Returns:
151
+ response (dict): 返回发送消息后的响应, 是一个大的 json, 还在考虑是否拆分一下
152
+ '''
153
+ receive_id_type = self.parent.extensions.parse_receive_id_type(receive_id=receive_id)
154
+ content = {
155
+ "type":"template",
156
+ "data":{
157
+ "template_id": template_id,
158
+ "template_variable": template_variable
159
+ }
160
+ }
161
+
162
+ content = json.dumps(content, ensure_ascii=False)
163
+
164
+ payload = {
165
+ "content": content,
166
+ "msg_type": "interactive",
167
+ "receive_id": receive_id
168
+ }
169
+ return self.parent.request(path=f'/im/v1/messages?receive_id_type={receive_id_type}',
170
+ method='POST',
171
+ body=payload)
172
+
173
+ def send_file(self, file_name, file_path, receive_id):
174
+ receive_id_type = self.parent.extensions.parse_receive_id_type(receive_id=receive_id)
175
+ content = {
176
+ "file_key": self.parent.extensions.upload_file(file_name=file_name, file_path=file_path)
177
+ }
178
+ content = json.dumps(content, ensure_ascii=False)
179
+ payload = {
180
+ "content": content,
181
+ "msg_type": "file",
182
+ "receive_id": receive_id
183
+ }
184
+
185
+ return self.parent.request(path=f'/im/v1/messages?receive_id_type={receive_id_type}',
186
+ method='POST',
187
+ body=payload)
188
+
189
+ def get_history(self, chat_id: str=None, chat_type: Literal['chat', 'thread']='chat', start_time: int=int(time.time())-300, end_time: int=int(time.time()), last_minute: int=5, page_size: int=50):
190
+ '''
191
+ _summary_
192
+
193
+ Args:
194
+ chat_id (str, optional): _description_. Defaults to None.
195
+ chat_type (Literal['chat', 'thread'], optional): _description_. Defaults to 'chat'.
196
+ start_time (int, optional): _description_. Defaults to int(time.time())-300.
197
+ end_time (int, optional): _description_. Defaults to int(time.time()).
198
+ page_size (int, optional): _description_. Defaults to 50.
199
+
200
+ Returns:
201
+ _type_: _description_
202
+ '''
203
+ start_time = int(time.time()) - last_minute * 60
204
+ return self.parent.request(path=f'/im/v1/messages?container_id={chat_id}&container_id_type={chat_type}&end_time={end_time}&page_size={page_size}&sort_type=ByCreateTimeAsc&start_time={start_time}',
205
+ method='GET')
206
+
207
+ def reply(self, message_id, content):
208
+ content = {
209
+ "text": content
210
+ }
211
+ payload = {
212
+ "content": json.dumps(content, ensure_ascii=False),
213
+ "msg_type": "text",
214
+ "reply_in_thread": False,
215
+ "uuid": str(uuid.uuid4())
216
+ }
217
+ return self.parent.request(
218
+ path=f"https://open.feishu.cn/open-apis/im/v1/messages/{message_id}/reply",
219
+ method='POST',
220
+ body=payload
221
+ )
222
+
223
+ def forward(self, message_id, receive_id):
224
+ receive_id_type = self.parent.extensions.parse_receive_id_type(receive_id=receive_id)
225
+ payload = {
226
+ "receive_id": receive_id
227
+ }
228
+ return self.parent.request(
229
+ path=f"/im/v1/messages/{message_id}/forward?receive_id_type={receive_id_type}",
230
+ method='POST',
231
+ body=payload
232
+ )
233
+
234
+ def emoji(self, message_id, emoji_type: Literal['DONE', 'ERROR', 'SPITBLOOD', 'LIKE', 'LOVE', 'CARE', 'WOW', 'SAD', 'ANGRY', 'SILENT']) -> ReturnResponse:
235
+ '''
236
+ 表情文案说明: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
237
+
238
+ Args:
239
+ message_id (_type_): _description_
240
+ emoji_type (str): _description_
241
+
242
+ Returns:
243
+ _type_: _description_
244
+ '''
245
+ payload = {
246
+ "reaction_type": {
247
+ "emoji_type": emoji_type
248
+ }
249
+ }
250
+
251
+ r = self.parent.request(
252
+ path=f"im/v1/messages/{message_id}/reactions",
253
+ method='POST',
254
+ body=payload
255
+ )
256
+ if r.code == 0:
257
+ return ReturnResponse(code=0, message=f"{message_id} 回复 emoji [{emoji_type}] 成功")
258
+ else:
259
+ return ReturnResponse(code=1, message=f"{message_id} 回复 emoji [{emoji_type}] 失败")
260
+
261
+ class BitableEndpoint(Endpoint):
262
+
263
+ def list_records(self, app_token, table_id, field_names: list=None, automatic_fields: bool=False, filter_conditions: list=None, conjunction: Literal['and', 'or']='and', sort_field_name: str=None, view_id: str=None):
264
+ '''
265
+ 如果是多维表格中的表格, 需要先获取 app_token
266
+ https://open.feishu.cn/document/server-docs/docs/wiki-v2/space-node/get_node?appId=cli_a1ae749cd7f9100d
267
+
268
+ 参考文档: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/search
269
+
270
+ Args:
271
+ app_token (_type_): obj_token
272
+ table_id (_type_): _description_
273
+ filter_conditions (_type_): https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/record-filter-guide
274
+ '''
275
+ payload = {
276
+ "automatic_fields": automatic_fields,
277
+ "field_names": field_names,
278
+ "filter": {
279
+ "conditions": filter_conditions,
280
+ "conjunction": conjunction
281
+ },
282
+ "view_id": view_id
283
+ }
284
+
285
+ if sort_field_name:
286
+ payload['sort'] = [
287
+ {
288
+ "desc": True,
289
+ "field_name": sort_field_name
290
+ }
291
+ ]
292
+
293
+ records = self.parent.request(path=f'/bitable/v1/apps/{app_token}/tables/{table_id}/records/search', method='POST', body=payload)
294
+ if records.code == 0:
295
+ new_dict = {}
296
+ for item in records.data['items']:
297
+ for key, value in item['fields'].items():
298
+ if isinstance(value, list):
299
+ try:
300
+ value = value[0].get('text')
301
+ except AttributeError:
302
+ pass
303
+ new_dict[key] = value
304
+ yield new_dict
305
+ elif records.code != 0:
306
+ pass
307
+
308
+ def add_record(self, app_token, table_id, fields):
309
+ payload = {
310
+ "fields": fields
311
+ }
312
+ return self.parent.request(path=f'/bitable/v1/apps/{app_token}/tables/{table_id}/records',
313
+ method='POST',
314
+ body=payload)
315
+
316
+ def query_record(self, app_token: str=None, table_id: str=None, automatic_fields: bool=False, field_names: list=None, filter_conditions: list=None, conjunction: Literal['and', 'or']='and', sort_field_name: str=None, view_id: str=None):
317
+ '''
318
+ https://open.feishu.cn/api-explorer/cli_a1ae749cd7f9100d?apiName=search&from=op_doc_tab&project=bitable&resource=app.table.record&version=v1
319
+ Args:
320
+ app_token (_type_): _description_
321
+ table_id (_type_): _description_
322
+ view_id (_type_): _description_
323
+ automatic_fields (_type_): _description_
324
+ field_names (_type_): _description_
325
+ filter_conditions (_type_): [
326
+ {
327
+ "field_name": "职位",
328
+ "operator": "is",
329
+ "value": [
330
+ "初级销售员"
331
+ ]
332
+ },
333
+ {
334
+ "field_name": "销售额",
335
+ "operator": "isGreater",
336
+ "value": [
337
+ "10000.0"
338
+ ]
339
+ }
340
+ ],
341
+ conjunction (_type_): _description_
342
+ sort_field_name (_type_): _description_
343
+ view_id (_type_): _description_
344
+
345
+ Returns:
346
+ _type_: _description_
347
+ '''
348
+ payload = {
349
+ "automatic_fields": automatic_fields,
350
+ "field_names": field_names,
351
+ "filter": {
352
+ "conditions": filter_conditions,
353
+ "conjunction": conjunction
354
+ },
355
+ "view_id": view_id
356
+ }
357
+ if sort_field_name:
358
+ payload['sort'] = [
359
+ {
360
+ "desc": True,
361
+ "field_name": sort_field_name
362
+ }
363
+ ]
364
+ return self.parent.request(path=f'/bitable/v1/apps/{app_token}/tables/{table_id}/records/search',
365
+ method='POST',
366
+ body=payload)
367
+
368
+ def query_record_id(self,
369
+ app_token: str=None,
370
+ table_id: str=None, filter_field_name: str=None, filter_value: str=None) -> str | None:
371
+ '''
372
+ 用于单向或双向关联
373
+
374
+ Args:
375
+ app_token (str, optional): _description_. Defaults to None.
376
+ table_id (str, optional): _description_. Defaults to None.
377
+ filter_field_name (str, optional): _description_. Defaults to None.
378
+ filter_value (str, optional): _description_. Defaults to None.
379
+
380
+ Returns:
381
+ str | None: _description_
382
+ '''
383
+ payload = {
384
+ "automatic_fields": False,
385
+ "filter": {
386
+ "conditions": [
387
+ {
388
+ "field_name": filter_field_name,
389
+ "operator": "is",
390
+ "value": [filter_value]
391
+ }
392
+ ],
393
+ "conjunction": "and"
394
+ },
395
+ }
396
+ res = self.parent.request(path=f'/bitable/v1/apps/{app_token}/tables/{table_id}/records/search',
397
+ method='POST',
398
+ body=payload)
399
+ if res.code == 0:
400
+ try:
401
+ return res.data['items'][0]['record_id']
402
+ except IndexError:
403
+ return None
404
+ else:
405
+ return None
406
+
407
+ def add_and_update_record(self,
408
+ app_token: str=None,
409
+ table_id: str=None,
410
+ record_id: str=None,
411
+ fields: dict=None,
412
+ filter_field_name: str=None,
413
+ filter_value: str=None) -> ReturnResponse:
414
+ '''
415
+ _summary_
416
+
417
+ Args:
418
+ app_token (_type_): _description_
419
+ table_id (_type_): _description_
420
+ record_id (_type_): _description_
421
+ fields (_type_): _description_
422
+
423
+ Returns:
424
+ ReturnResponse: _description_
425
+ '''
426
+ record_id = self.query_record_id(app_token, table_id, filter_field_name, filter_value)
427
+
428
+ if record_id:
429
+ payload = {
430
+ "fields": {k: v for k, v in fields.items() if v is not None}
431
+ }
432
+ resp = self.parent.request(path=f'/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}',
433
+ method='PUT',
434
+ body=payload)
435
+ return ReturnResponse(code=resp.code, message=f"记录已存在, 进行更新", data=resp.data)
436
+ else:
437
+ resp = self.add_record(app_token, table_id, fields)
438
+ return ReturnResponse(code=resp.code, message=f"记录不存在, 进行创建", data=resp.data)
439
+
440
+ def query_name_by_record_id(self, app_token: str=None, table_id: str=None, field_names: list=None, record_id: str='', name: str=''):
441
+ response = self.query_record(app_token=app_token, table_id=table_id, field_names=field_names)
442
+ if response.code == 0:
443
+ for item in response.data['items']:
444
+ if item['record_id'] == record_id:
445
+ # print(item['fields'])
446
+ return self.parent.extensions.parse_bitable_data(item['fields'], name)
447
+ # ss
448
+ else:
449
+ return None
450
+
451
+ class DocsEndpoint(Endpoint):
452
+
453
+ def rename_doc_title(self, space_id, node_token, title):
454
+ payload = {
455
+ "title": title
456
+ }
457
+ return self.parent.request(path=f'/wiki/v2/spaces/{space_id}/nodes/{node_token}/update_title',
458
+ method='POST',
459
+ body=payload)
460
+
461
+ def create_doc(self, space_id: str, parent_node_token: str, title: str):
462
+ '''
463
+ 在知识库中创建文档
464
+
465
+ Args:
466
+ space_id (_type_): 知识库的 id
467
+ parent_node_token (_type_): 父节点 token, 通过浏览器的链接可以获取, 例如 https://tyun.feishu.cn/wiki/J4tjweM5xiCBADk1zo7c6wXOnHO
468
+
469
+ Returns:
470
+ _type_: document.id: res.data['node']['obj_token']
471
+ '''
472
+ payload = {
473
+ "node_type": "origin",
474
+ "obj_type": "docx",
475
+ "parent_node_token": parent_node_token
476
+ }
477
+ res = self.parent.request(path=f'/wiki/v2/spaces/{space_id}/nodes',
478
+ method='POST',
479
+ body=payload)
480
+ if res.code == 0:
481
+ self.rename_doc_title(space_id=space_id, node_token=res.data['node']['node_token'], title=title)
482
+ return res
483
+ else:
484
+ return res
485
+
486
+ def create_block(self, document_id: str=None, block_id: str=None, client_token: str=None, payload: dict={}):
487
+ '''
488
+ _summary_
489
+
490
+ Args:
491
+ document_id (str, optional): _description_. Defaults to None.
492
+ block_id (str, optional): _description_. Defaults to None.
493
+ client_token (str, optional): _description_. Defaults to None.
494
+ children (list, optional): _description_. Defaults to None.
495
+
496
+ Returns:
497
+ _type_: _description_
498
+ '''
499
+ if payload.get('children_id'):
500
+ # 创建嵌套块, 参考文档
501
+ # https://open.feishu.cn/api-explorer/cli_a1ae749cd7f9100d?apiName=create&from=op_doc_tab&project=docx&resource=document.block.descendant&version=v1
502
+ return self.parent.request(path=f'/docx/v1/documents/{document_id}/blocks/{block_id}/descendant',
503
+ method='POST',
504
+ body=payload)
505
+ else:
506
+ return self.parent.request(path=f'/docx/v1/documents/{document_id}/blocks/{block_id}/children',
507
+ method='POST',
508
+ body=payload)
509
+
510
+ def create_block_children(self, document_id: str=None, block_id: str=None, payload: dict=None):
511
+ return self.parent.request(path=f'/docx/v1/documents/{document_id}/blocks/{block_id}/children',
512
+ method='POST',
513
+ body=payload)
514
+
515
+ def update_block(self, document_id: str=None, block_id: str=None, replace_image_token: str=None, image_width: int=100, image_height: int=100, image_align: int=2):
516
+ payload = {}
517
+ if replace_image_token:
518
+ payload['replace_image'] = {
519
+ 'token': replace_image_token,
520
+ 'width': image_width,
521
+ 'height': image_height,
522
+ 'align': image_align
523
+ }
524
+ return self.parent.request(path=f'/docx/v1/documents/{document_id}/blocks/{block_id}',
525
+ method='PATCH',
526
+ body=payload)
527
+
528
+ class CalendarEndpoint(Endpoint):
529
+ def get_events(self,
530
+ calendar_id: str='feishu.cn_dQ4cLmSfGa1QSWqv3EvpLf@group.calendar.feishu.cn',
531
+ start_time: int=int(time.time()) - 30*24*60*60,
532
+ end_time: int=int(time.time()),
533
+ page_size: int=500,
534
+ anchor_time: int=None
535
+ ):
536
+ if anchor_time:
537
+ anchor_time = f'&anchor_time={anchor_time}'
538
+ else:
539
+ anchor_time = ''
540
+ return self.parent.request(path=f'/calendar/v4/calendars/{calendar_id}/events?anchor_time={start_time}&end_time={end_time}&page_size={page_size}&start_time={start_time}',
541
+ method='GET')
542
+
543
+
544
+ class ExtensionsEndpoint(Endpoint):
545
+ def parse_receive_id_type(self, receive_id):
546
+ if receive_id.startswith('ou'):
547
+ receive_id_type = 'open_id'
548
+ elif receive_id.startswith('oc'):
549
+ receive_id_type = 'chat_id'
550
+ else:
551
+ raise ValueError('No such named receive_id')
552
+ return receive_id_type
553
+
554
+ def upload_file(self, file_name, file_path):
555
+
556
+ files = {
557
+ 'file_type': ('', 'stream'),
558
+ 'file_name': ('', file_name),
559
+ 'file': open(file_path, 'rb')
560
+ }
561
+
562
+ return self.parent.request(path='/im/v1/files',
563
+ method='POST',
564
+ files=files).data['file_key']
565
+
566
+ def upload_image(self, image_path):
567
+ import requests
568
+ from requests_toolbelt import MultipartEncoder
569
+
570
+ url = "https://open.feishu.cn/open-apis/im/v1/images"
571
+
572
+ form = {
573
+ 'image_type': 'message',
574
+ 'image': (open(image_path, 'rb'))
575
+ } # 需要替换具体的path
576
+
577
+ multi_form = MultipartEncoder(form)
578
+ if self.parent.auth.fetch_token_from_file():
579
+ token = self.parent.auth.fetch_token_from_file()
580
+ else:
581
+ self.parent.auth.save_token_to_file()
582
+ token = self.parent.auth.fetch_token_from_file()
583
+ headers = {
584
+ 'Authorization': f'Bearer {token}', ## 获取tenant_access_token, 需要替换为实际的token
585
+ }
586
+ headers['Content-Type'] = multi_form.content_type
587
+ response = requests.request("POST", url, headers=headers, data=multi_form)
588
+ response_json = response.json()
589
+ if response_json['code'] == 0:
590
+ return response_json['data']['image_key']
591
+
592
+
593
+ def build_block_heading(self, content, heading_level: Literal[1, 2, 3, 4]):
594
+ return {
595
+ "index": 0,
596
+ "children": [
597
+ {
598
+ "block_type": heading_level + 2,
599
+ f"heading{heading_level}": {
600
+ "elements": [
601
+ {
602
+ "text_run": {
603
+ "content": content
604
+ }
605
+ }
606
+ ]
607
+ },
608
+ "style": {}
609
+ }
610
+ ]
611
+ }
612
+
613
+ def build_block_element(self, content: str=None, background_color: int=None, text_color: int=None):
614
+ element = {
615
+ "text_run": {
616
+ "content": content,
617
+ "text_element_style": {}
618
+ }
619
+ }
620
+
621
+ if background_color:
622
+ element['text_run']['text_element_style']['background_color'] = background_color
623
+
624
+ if text_color:
625
+ element['text_run']['text_element_style']['text_color'] = text_color
626
+
627
+ return element
628
+
629
+ def build_block_text(self, elements: list=None) -> dict:
630
+ '''
631
+ 构建飞书文档文本块。
632
+
633
+ Args:
634
+ elements (list, optional): 请使用 build_block_element 函数构建元素
635
+
636
+ Returns:
637
+ dict: 飞书文档文本块
638
+ '''
639
+ return {
640
+ "index": 0,
641
+ "children": [
642
+ {
643
+ "block_type": 2,
644
+ "text": {
645
+ "elements": elements,
646
+ "style": {}
647
+ }
648
+ }
649
+ ]
650
+ }
651
+
652
+ def build_block_bullet(self, content_list: list = None, background_color: int=None, text_color: int=None) -> dict:
653
+ """
654
+ 构建飞书文档项目符号列表块。
655
+
656
+ Args:
657
+ content_list (list, optional): 内容列表,将批量添加到 children 中
658
+
659
+ Returns:
660
+ dict: 飞书文档项目符号列表块
661
+ """
662
+ children = []
663
+
664
+ for content in content_list:
665
+ children.append({
666
+ "block_type": 12,
667
+ "bullet": {
668
+ "elements": [
669
+ self.build_block_element(content=content, background_color=background_color, text_color=text_color)
670
+ ]
671
+ }
672
+ })
673
+
674
+ return {
675
+ "index": 0,
676
+ "children": children
677
+ }
678
+
679
+ def build_block_callout(self, content: str=None, background_color: int=1, border_color: int=2, text_color: int=5, emoji_id: str='grinning', bold: bool=False):
680
+ '''
681
+ _summary_
682
+
683
+ Args:
684
+ content (str, optional): _description_. Defaults to None.
685
+ background_color (int, optional): _description_. Defaults to 1.
686
+ border_color (int, optional): _description_. Defaults to 2.
687
+ text_color (int, optional): _description_. Defaults to 5.
688
+ emoji_id (str, optional): _description_. Defaults to 'grinning'.
689
+ bold (bool, optional): _description_. Defaults to False.
690
+
691
+ Returns:
692
+ _type_: _description_
693
+ '''
694
+ return {
695
+ "index": 0,
696
+ "children_id": [
697
+ "callout1",
698
+ ],
699
+ "descendants": [
700
+ {
701
+ "block_id": "callout1",
702
+ "block_type": 19,
703
+ "callout": {
704
+ "background_color": background_color,
705
+ "border_color": border_color,
706
+ "text_color": text_color,
707
+ "emoji_id": emoji_id
708
+ },
709
+ "children": [
710
+ "text1",
711
+ ]
712
+ },
713
+ {
714
+ "block_id": "text1",
715
+ "block_type": 2,
716
+ "text": {
717
+ "elements": [
718
+ {
719
+ "text_run": {
720
+ "content": content,
721
+ "text_element_style": {
722
+ "bold": bold
723
+ }
724
+ }
725
+ }
726
+ ]
727
+ }
728
+ }
729
+ ]
730
+ }
731
+
732
+ def build_block_table(self, rows: int=1, columns: int=1, column_width: list=[], data=None):
733
+ """
734
+ 构建飞书文档表格块
735
+ 参考文档: https://open.feishu.cn/document/docs/docs/faq
736
+
737
+ Args:
738
+ rows: 表格行数
739
+ columns: 表格列数
740
+ data: 表格数据,可以是二维列表[[cell1, cell2], [cell3, cell4]]
741
+ 或者单元格块ID的列表
742
+
743
+ Returns:
744
+ dict: 符合飞书文档API要求的表格结构
745
+ """
746
+ # 生成表格ID和单元格ID
747
+ table_id = f"table_{uuid.uuid4().hex[:8]}"
748
+ cell_ids = []
749
+ cell_blocks = []
750
+
751
+ # if data:
752
+ # # 在data列表末尾添加一条新数据
753
+ # data.append(['sss'] * columns) # 添加一个空行
754
+
755
+ # print(data)
756
+
757
+ # 生成单元格ID和块
758
+ for row in range(rows):
759
+ row_cells = []
760
+ for col in range(columns):
761
+ cell_id = f"cell_{row}_{col}_{uuid.uuid4().hex[:4]}"
762
+ row_cells.append(cell_id)
763
+
764
+ # 获取单元格内容
765
+ cell_content = ""
766
+ if data and len(data) > row and isinstance(data[row], (list, tuple)) and len(data[row]) > col:
767
+ cell_content = data[row][col]
768
+
769
+ # 创建单元格内容块ID
770
+ content_id = f"content_{cell_id}"
771
+
772
+ # 创建单元格块
773
+ cell_block = {
774
+ "block_id": cell_id,
775
+ "block_type": 32, # 表格单元格
776
+ "table_cell": {},
777
+ "children": [content_id]
778
+ }
779
+
780
+ # 创建单元格内容块
781
+ content_block = {
782
+ "block_id": content_id,
783
+ "block_type": 2, # 文本块
784
+ "text": {
785
+ "elements": [
786
+ {
787
+ "text_run": {
788
+ "content": str(cell_content) if cell_content else ""
789
+ }
790
+ }
791
+ ],
792
+ "style": {
793
+ "bold": True,
794
+ "align": 2
795
+ }
796
+ },
797
+ "children": []
798
+ }
799
+
800
+ cell_blocks.append(cell_block)
801
+ cell_blocks.append(content_block)
802
+
803
+ cell_ids.extend(row_cells)
804
+
805
+ # 创建表格主块
806
+ table_block = {
807
+ "block_id": table_id,
808
+ "block_type": 31, # 表格
809
+ "table": {
810
+ "property": {
811
+ "row_size": rows,
812
+ "column_size": columns,
813
+ "header_row": True,
814
+ "column_width": column_width
815
+ }
816
+ },
817
+ "children": cell_ids
818
+ }
819
+
820
+ # 构建完整结构
821
+ result = {
822
+ "index": 0,
823
+ "children_id": [table_id],
824
+ "descendants": [table_block] + cell_blocks
825
+ }
826
+ # print(result)
827
+ return result
828
+
829
+ def build_bitable_text(self, text: str=None):
830
+ return {"title": text}
831
+
832
+ def build_block_image(self, file_path, percent: int=100, image_align: int=2):
833
+
834
+ from PIL import Image
835
+ with Image.open(file_path) as img:
836
+ width, height = img.size
837
+ image_width = int(width * percent / 100)
838
+ image_height = int(height * percent / 100)
839
+
840
+ return {
841
+ "index": 0,
842
+ "children": [
843
+ {
844
+ "block_type": 27,
845
+ "image": {}
846
+ }
847
+ ],
848
+ "file_path": file_path,
849
+ "image_width": image_width,
850
+ "image_height": image_height,
851
+ "image_align": image_align
852
+ }
853
+
854
+ def upload_media(self, file_path: str, block_id: str):
855
+ file_size = os.path.getsize(file_path)
856
+ url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
857
+ form = {'file_name': 'demo.jpeg',
858
+ 'parent_type': 'docx_image',
859
+ 'parent_node': block_id,
860
+ 'size': str(file_size),
861
+ 'file': (open(file_path, 'rb'))}
862
+ multi_form = MultipartEncoder(form)
863
+ headers = {
864
+ 'Authorization': f'Bearer {self.parent.auth.fetch_token_from_file()}', ## 获取tenant_access_token, 需要替换为实际的token
865
+ }
866
+ headers['Content-Type'] = multi_form.content_type
867
+ response = requests.request("POST", url, headers=headers, data=multi_form)
868
+ return response.json()
869
+
870
+ def create_block(self, blocks, document_id):
871
+ # 交换blocks中元素的顺序
872
+ blocks.reverse()
873
+
874
+ for block in blocks:
875
+ time.sleep(1)
876
+ try:
877
+ if block['children'][0]['block_type'] != 27:
878
+ self.parent.docs.create_block(
879
+ document_id=document_id,
880
+ block_id=document_id,
881
+ payload=block
882
+ )
883
+
884
+ elif block['children'][0]['block_type'] == 27:
885
+ block_id = self.parent.docs.create_block(
886
+ document_id=document_id,
887
+ block_id=document_id,
888
+ payload=block
889
+ ).data['children'][0]['block_id']
890
+
891
+ file_token = self.upload_media(
892
+ file_path=block['file_path'],
893
+ block_id=block_id
894
+ )['data']['file_token']
895
+
896
+ res = self.parent.docs.update_block(
897
+ document_id=document_id,
898
+ block_id=block_id,
899
+ replace_image_token=file_token,
900
+ image_width=block['image_width'],
901
+ image_height=block['image_height'],
902
+ image_align=block['image_align']
903
+ )
904
+ return res
905
+ except KeyError:
906
+ res = self.parent.docs.create_block(
907
+ document_id=document_id,
908
+ block_id=document_id,
909
+ payload=block
910
+ )
911
+ return res
912
+ except IndexError:
913
+ print(block)
914
+
915
+ def parse_bitable_data(self, fields, name):
916
+ final_data = None
917
+
918
+ if fields.get(name) != None:
919
+ if isinstance(fields[name], list):
920
+ if fields[name][0].get('type') == 'text':
921
+ final_data = fields[name][0].get('text')
922
+ elif fields[name][0].get('type') == 'url':
923
+ try:
924
+ text_2nd = fields[name][1].get('text')
925
+ final_data = fields[name][0].get('text') + text_2nd
926
+ except IndexError:
927
+ final_data = fields[name][0].get('text')
928
+
929
+ elif fields[name][0].get('type') == 1:
930
+ final_data = fields[name][0].get('value')[0]['text']
931
+
932
+ elif isinstance(fields[name], int):
933
+ # 将时间戳转换为时间字符串格式
934
+ final_data = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(fields[name] / 1000 ))
935
+
936
+ elif isinstance(fields[name], dict):
937
+ if fields[name].get('type') == 1:
938
+ final_data = fields[name].get('value')[0]['text']
939
+ elif fields[name].get('type') == 3:
940
+ final_data = fields[name].get('value')[0]
941
+ elif fields[name].get('type') == 5:
942
+ final_data = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(fields[name]['value'][0] / 1000 ))
943
+ else:
944
+ final_data = '待补充'
945
+ if isinstance(final_data, str):
946
+ return final_data
947
+ else:
948
+ return fields[name]
949
+
950
+ def get_user_info(self, email: str=None, mobile: str=None, get: Literal['open_id', 'all']='all') -> ReturnResponse:
951
+ payload = {
952
+ "include_resigned": True,
953
+ }
954
+ if email:
955
+ payload['emails'] = [email]
956
+ user_input = email
957
+
958
+ if mobile:
959
+ payload['mobiles'] = [mobile]
960
+ user_input = mobile
961
+
962
+ response = self.parent.request(path='/contact/v3/users/batch_get_id',
963
+ method='POST',
964
+ body=payload)
965
+ if response.code == 0:
966
+ if get == 'open_id':
967
+ return ReturnResponse(code=0, message=f'根据用户输入的 {user_input}, 获取用户信息成功', data=response.data['user_list'][0]['user_id'])
968
+ else:
969
+ return ReturnResponse(code=response.code, message=f"获取时失败, 报错请见 data 字段", data=response.data)
970
+
971
+ def format_rich_text(self, text: str, color: Literal['red', 'green', 'yellow', 'blue'], bold: bool=False):
972
+ if bold:
973
+ text = f"**{text}**"
974
+
975
+ if color:
976
+ text = f"<font color='{color}'>{text}</font>"
977
+
978
+ return text
979
+
980
+ def convert_str_to_dict(self, text: str):
981
+ return json.loads(text)
982
+
983
+ def parse_message_card_elements(self, elements: list | dict) -> str:
984
+ """
985
+ 递归解析飞书消息卡片 elements,收集所有 tag 为 'text' 的文本并拼接返回。
986
+
987
+ 此方法兼容以下结构:
988
+ - 二维列表:例如 [[{...}, {...}]]
989
+ - 多层嵌套:字典中包含 'elements'、'content'、'children' 等容器键
990
+ - 忽略未知/非 text 标签,例如 'unknown'
991
+
992
+ Args:
993
+ elements (list | dict): 飞书消息卡片的 elements 字段,可能是列表或字典。
994
+
995
+ Returns:
996
+ str: 拼接后的文本内容。
997
+ """
998
+
999
+ texts: list[str] = []
1000
+
1001
+ def walk(node: Any) -> None:
1002
+ if node is None:
1003
+ return
1004
+ if isinstance(node, dict):
1005
+ tag = node.get('tag')
1006
+ if tag == 'text' and isinstance(node.get('text'), str):
1007
+ texts.append(node['text'])
1008
+ # 递归遍历常见的容器键
1009
+ for key in ('elements', 'content', 'children'):
1010
+ value = node.get(key)
1011
+ if isinstance(value, (list, tuple, dict)):
1012
+ walk(value)
1013
+ elif isinstance(node, (list, tuple)):
1014
+ for item in node:
1015
+ walk(item)
1016
+
1017
+ walk(elements)
1018
+ return ''.join(texts)
1019
+
1020
+ def send_message_notify(self,
1021
+ receive_id: str='ou_ca3fc788570865cbbf59bfff43621a78',
1022
+ color: Literal['red', 'green', 'blue']='red',
1023
+ title: str='Test',
1024
+ sub_title: str='未填写子标题',
1025
+ priority: str='P0',
1026
+ content: str='Test'
1027
+ ):
1028
+ return self.parent.message.send_card(
1029
+ template_id="AAqzcy5Qrx84H",
1030
+ template_variable={
1031
+ "color": color,
1032
+ "title": title,
1033
+ "sub_title": sub_title,
1034
+ "priority": priority,
1035
+ "content": content
1036
+ },
1037
+ receive_id=receive_id
1038
+ )
1039
+
1040
+ def get_user_info_by_open_id(self, open_id: str, get: Literal['name', 'all']='all'):
1041
+ response = self.parent.request(path=f'/contact/v3/users/{open_id}?department_id_type=open_department_id&user_id_type=open_id',
1042
+ method='GET')
1043
+ if response.code == 0:
1044
+ if get == 'name':
1045
+ return response.data['user']['name']
1046
+ else:
1047
+ return response.data
1048
+ else:
1049
+ return None