AstrBot 4.3.3__py3-none-any.whl → 4.5.0__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.
- astrbot/core/agent/mcp_client.py +18 -4
- astrbot/core/agent/runners/tool_loop_agent_runner.py +31 -2
- astrbot/core/astr_agent_context.py +1 -0
- astrbot/core/astrbot_config_mgr.py +23 -51
- astrbot/core/config/default.py +139 -14
- astrbot/core/conversation_mgr.py +36 -1
- astrbot/core/core_lifecycle.py +24 -5
- astrbot/core/db/migration/migra_45_to_46.py +44 -0
- astrbot/core/db/vec_db/base.py +33 -2
- astrbot/core/db/vec_db/faiss_impl/document_storage.py +310 -52
- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +31 -3
- astrbot/core/db/vec_db/faiss_impl/vec_db.py +81 -23
- astrbot/core/file_token_service.py +6 -1
- astrbot/core/initial_loader.py +6 -3
- astrbot/core/knowledge_base/chunking/__init__.py +11 -0
- astrbot/core/knowledge_base/chunking/base.py +24 -0
- astrbot/core/knowledge_base/chunking/fixed_size.py +57 -0
- astrbot/core/knowledge_base/chunking/recursive.py +155 -0
- astrbot/core/knowledge_base/kb_db_sqlite.py +299 -0
- astrbot/core/knowledge_base/kb_helper.py +348 -0
- astrbot/core/knowledge_base/kb_mgr.py +287 -0
- astrbot/core/knowledge_base/models.py +114 -0
- astrbot/core/knowledge_base/parsers/__init__.py +15 -0
- astrbot/core/knowledge_base/parsers/base.py +50 -0
- astrbot/core/knowledge_base/parsers/markitdown_parser.py +25 -0
- astrbot/core/knowledge_base/parsers/pdf_parser.py +100 -0
- astrbot/core/knowledge_base/parsers/text_parser.py +41 -0
- astrbot/core/knowledge_base/parsers/util.py +13 -0
- astrbot/core/knowledge_base/retrieval/__init__.py +16 -0
- astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
- astrbot/core/knowledge_base/retrieval/manager.py +273 -0
- astrbot/core/knowledge_base/retrieval/rank_fusion.py +138 -0
- astrbot/core/knowledge_base/retrieval/sparse_retriever.py +130 -0
- astrbot/core/pipeline/process_stage/method/llm_request.py +61 -21
- astrbot/core/pipeline/process_stage/utils.py +80 -0
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/platform/astr_message_event.py +8 -7
- astrbot/core/platform/manager.py +4 -0
- astrbot/core/platform/sources/misskey/misskey_adapter.py +380 -44
- astrbot/core/platform/sources/misskey/misskey_api.py +581 -45
- astrbot/core/platform/sources/misskey/misskey_event.py +76 -41
- astrbot/core/platform/sources/misskey/misskey_utils.py +254 -43
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
- astrbot/core/platform/sources/satori/satori_adapter.py +27 -1
- astrbot/core/platform/sources/satori/satori_event.py +270 -77
- astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
- astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +289 -0
- astrbot/core/platform/sources/wecom_ai_bot/__init__.py +17 -0
- astrbot/core/platform/sources/wecom_ai_bot/ierror.py +20 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +445 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +378 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +149 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +148 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +166 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_utils.py +199 -0
- astrbot/core/provider/manager.py +14 -9
- astrbot/core/provider/provider.py +67 -0
- astrbot/core/provider/sources/anthropic_source.py +4 -4
- astrbot/core/provider/sources/dashscope_source.py +10 -9
- astrbot/core/provider/sources/dify_source.py +6 -8
- astrbot/core/provider/sources/gemini_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_source.py +18 -15
- astrbot/core/provider/sources/openai_tts_api_source.py +1 -1
- astrbot/core/star/context.py +3 -0
- astrbot/core/star/star.py +6 -0
- astrbot/core/star/star_manager.py +13 -7
- astrbot/core/umop_config_router.py +81 -0
- astrbot/core/updator.py +1 -1
- astrbot/core/utils/io.py +23 -12
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +137 -9
- astrbot/dashboard/routes/knowledge_base.py +1065 -0
- astrbot/dashboard/routes/plugin.py +24 -5
- astrbot/dashboard/routes/tools.py +14 -0
- astrbot/dashboard/routes/update.py +1 -1
- astrbot/dashboard/server.py +6 -0
- astrbot/dashboard/utils.py +161 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/METADATA +91 -55
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/RECORD +83 -50
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/WHEEL +0 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,7 +2,18 @@ from typing import TYPE_CHECKING
|
|
|
2
2
|
from astrbot.api import logger
|
|
3
3
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
|
4
4
|
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
|
5
|
-
from astrbot.api.message_components import
|
|
5
|
+
from astrbot.api.message_components import (
|
|
6
|
+
Plain,
|
|
7
|
+
Image,
|
|
8
|
+
At,
|
|
9
|
+
File,
|
|
10
|
+
Record,
|
|
11
|
+
Video,
|
|
12
|
+
Reply,
|
|
13
|
+
Forward,
|
|
14
|
+
Node,
|
|
15
|
+
Nodes,
|
|
16
|
+
)
|
|
6
17
|
|
|
7
18
|
if TYPE_CHECKING:
|
|
8
19
|
from .satori_adapter import SatoriPlatformAdapter
|
|
@@ -48,44 +59,24 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
|
|
48
59
|
content_parts = []
|
|
49
60
|
|
|
50
61
|
for component in message.chain:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
content_parts.append(
|
|
70
|
-
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
|
71
|
-
)
|
|
72
|
-
except Exception as e:
|
|
73
|
-
logger.error(f"图片转换为base64失败: {e}")
|
|
74
|
-
|
|
75
|
-
elif isinstance(component, File):
|
|
76
|
-
content_parts.append(
|
|
77
|
-
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
elif isinstance(component, Record):
|
|
81
|
-
try:
|
|
82
|
-
record_base64 = await component.convert_to_base64()
|
|
83
|
-
if record_base64:
|
|
84
|
-
content_parts.append(
|
|
85
|
-
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
|
86
|
-
)
|
|
87
|
-
except Exception as e:
|
|
88
|
-
logger.error(f"语音转换为base64失败: {e}")
|
|
62
|
+
component_content = await cls._convert_component_to_satori_static(
|
|
63
|
+
component
|
|
64
|
+
)
|
|
65
|
+
if component_content:
|
|
66
|
+
content_parts.append(component_content)
|
|
67
|
+
|
|
68
|
+
# 特殊处理 Node 和 Nodes 组件
|
|
69
|
+
if isinstance(component, Node):
|
|
70
|
+
# 单个转发节点
|
|
71
|
+
node_content = await cls._convert_node_to_satori_static(component)
|
|
72
|
+
if node_content:
|
|
73
|
+
content_parts.append(node_content)
|
|
74
|
+
|
|
75
|
+
elif isinstance(component, Nodes):
|
|
76
|
+
# 合并转发消息
|
|
77
|
+
node_content = await cls._convert_nodes_to_satori_static(component)
|
|
78
|
+
if node_content:
|
|
79
|
+
content_parts.append(node_content)
|
|
89
80
|
|
|
90
81
|
content = "".join(content_parts)
|
|
91
82
|
channel_id = session_id
|
|
@@ -127,44 +118,22 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
|
|
127
118
|
content_parts = []
|
|
128
119
|
|
|
129
120
|
for component in message.chain:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
image_base64 = await component.convert_to_base64()
|
|
147
|
-
if image_base64:
|
|
148
|
-
content_parts.append(
|
|
149
|
-
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
|
150
|
-
)
|
|
151
|
-
except Exception as e:
|
|
152
|
-
logger.error(f"图片转换为base64失败: {e}")
|
|
153
|
-
|
|
154
|
-
elif isinstance(component, File):
|
|
155
|
-
content_parts.append(
|
|
156
|
-
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
elif isinstance(component, Record):
|
|
160
|
-
try:
|
|
161
|
-
record_base64 = await component.convert_to_base64()
|
|
162
|
-
if record_base64:
|
|
163
|
-
content_parts.append(
|
|
164
|
-
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
|
165
|
-
)
|
|
166
|
-
except Exception as e:
|
|
167
|
-
logger.error(f"语音转换为base64失败: {e}")
|
|
121
|
+
component_content = await self._convert_component_to_satori(component)
|
|
122
|
+
if component_content:
|
|
123
|
+
content_parts.append(component_content)
|
|
124
|
+
|
|
125
|
+
# 特殊处理 Node 和 Nodes 组件
|
|
126
|
+
if isinstance(component, Node):
|
|
127
|
+
# 单个转发节点
|
|
128
|
+
node_content = await self._convert_node_to_satori(component)
|
|
129
|
+
if node_content:
|
|
130
|
+
content_parts.append(node_content)
|
|
131
|
+
|
|
132
|
+
elif isinstance(component, Nodes):
|
|
133
|
+
# 合并转发消息
|
|
134
|
+
node_content = await self._convert_nodes_to_satori(component)
|
|
135
|
+
if node_content:
|
|
136
|
+
content_parts.append(node_content)
|
|
168
137
|
|
|
169
138
|
content = "".join(content_parts)
|
|
170
139
|
channel_id = self.session_id
|
|
@@ -228,3 +197,227 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
|
|
228
197
|
logger.error(f"Satori 流式消息发送异常: {e}")
|
|
229
198
|
|
|
230
199
|
return await super().send_streaming(generator, use_fallback)
|
|
200
|
+
|
|
201
|
+
async def _convert_component_to_satori(self, component) -> str:
|
|
202
|
+
"""将单个消息组件转换为 Satori 格式"""
|
|
203
|
+
try:
|
|
204
|
+
if isinstance(component, Plain):
|
|
205
|
+
text = (
|
|
206
|
+
component.text.replace("&", "&")
|
|
207
|
+
.replace("<", "<")
|
|
208
|
+
.replace(">", ">")
|
|
209
|
+
)
|
|
210
|
+
return text
|
|
211
|
+
|
|
212
|
+
elif isinstance(component, At):
|
|
213
|
+
if component.qq:
|
|
214
|
+
return f'<at id="{component.qq}"/>'
|
|
215
|
+
elif component.name:
|
|
216
|
+
return f'<at name="{component.name}"/>'
|
|
217
|
+
|
|
218
|
+
elif isinstance(component, Image):
|
|
219
|
+
try:
|
|
220
|
+
image_base64 = await component.convert_to_base64()
|
|
221
|
+
if image_base64:
|
|
222
|
+
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"图片转换为base64失败: {e}")
|
|
225
|
+
|
|
226
|
+
elif isinstance(component, File):
|
|
227
|
+
return (
|
|
228
|
+
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
elif isinstance(component, Record):
|
|
232
|
+
try:
|
|
233
|
+
record_base64 = await component.convert_to_base64()
|
|
234
|
+
if record_base64:
|
|
235
|
+
return f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"语音转换为base64失败: {e}")
|
|
238
|
+
|
|
239
|
+
elif isinstance(component, Reply):
|
|
240
|
+
return f'<reply id="{component.id}"/>'
|
|
241
|
+
|
|
242
|
+
elif isinstance(component, Video):
|
|
243
|
+
try:
|
|
244
|
+
video_path_url = await component.convert_to_file_path()
|
|
245
|
+
if video_path_url:
|
|
246
|
+
return f'<video src="{video_path_url}"/>'
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"视频文件转换失败: {e}")
|
|
249
|
+
|
|
250
|
+
elif isinstance(component, Forward):
|
|
251
|
+
return f'<message id="{component.id}" forward/>'
|
|
252
|
+
|
|
253
|
+
# 对于其他未处理的组件类型,返回空字符串
|
|
254
|
+
return ""
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.error(f"转换消息组件失败: {e}")
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
async def _convert_node_to_satori(self, node: Node) -> str:
|
|
261
|
+
"""将单个转发节点转换为 Satori 格式"""
|
|
262
|
+
try:
|
|
263
|
+
content_parts = []
|
|
264
|
+
if node.content:
|
|
265
|
+
for content_component in node.content:
|
|
266
|
+
component_content = await self._convert_component_to_satori(
|
|
267
|
+
content_component
|
|
268
|
+
)
|
|
269
|
+
if component_content:
|
|
270
|
+
content_parts.append(component_content)
|
|
271
|
+
|
|
272
|
+
content = "".join(content_parts)
|
|
273
|
+
|
|
274
|
+
# 如果内容为空,添加默认内容
|
|
275
|
+
if not content.strip():
|
|
276
|
+
content = "[转发消息]"
|
|
277
|
+
|
|
278
|
+
# 构建 Satori 格式的转发节点
|
|
279
|
+
author_attrs = []
|
|
280
|
+
if node.uin:
|
|
281
|
+
author_attrs.append(f'id="{node.uin}"')
|
|
282
|
+
if node.name:
|
|
283
|
+
author_attrs.append(f'name="{node.name}"')
|
|
284
|
+
|
|
285
|
+
author_attr_str = " ".join(author_attrs)
|
|
286
|
+
|
|
287
|
+
return f"<message><author {author_attr_str}/>{content}</message>"
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(f"转换转发节点失败: {e}")
|
|
291
|
+
return ""
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
async def _convert_component_to_satori_static(cls, component) -> str:
|
|
295
|
+
"""将单个消息组件转换为 Satori 格式"""
|
|
296
|
+
try:
|
|
297
|
+
if isinstance(component, Plain):
|
|
298
|
+
text = (
|
|
299
|
+
component.text.replace("&", "&")
|
|
300
|
+
.replace("<", "<")
|
|
301
|
+
.replace(">", ">")
|
|
302
|
+
)
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
elif isinstance(component, At):
|
|
306
|
+
if component.qq:
|
|
307
|
+
return f'<at id="{component.qq}"/>'
|
|
308
|
+
elif component.name:
|
|
309
|
+
return f'<at name="{component.name}"/>'
|
|
310
|
+
|
|
311
|
+
elif isinstance(component, Image):
|
|
312
|
+
try:
|
|
313
|
+
image_base64 = await component.convert_to_base64()
|
|
314
|
+
if image_base64:
|
|
315
|
+
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"图片转换为base64失败: {e}")
|
|
318
|
+
|
|
319
|
+
elif isinstance(component, File):
|
|
320
|
+
return (
|
|
321
|
+
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
elif isinstance(component, Record):
|
|
325
|
+
try:
|
|
326
|
+
record_base64 = await component.convert_to_base64()
|
|
327
|
+
if record_base64:
|
|
328
|
+
return f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"语音转换为base64失败: {e}")
|
|
331
|
+
|
|
332
|
+
elif isinstance(component, Reply):
|
|
333
|
+
return f'<reply id="{component.id}"/>'
|
|
334
|
+
|
|
335
|
+
elif isinstance(component, Video):
|
|
336
|
+
try:
|
|
337
|
+
video_path_url = await component.convert_to_file_path()
|
|
338
|
+
if video_path_url:
|
|
339
|
+
return f'<video src="{video_path_url}"/>'
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.error(f"视频文件转换失败: {e}")
|
|
342
|
+
|
|
343
|
+
elif isinstance(component, Forward):
|
|
344
|
+
return f'<message id="{component.id}" forward/>'
|
|
345
|
+
|
|
346
|
+
# 对于其他未处理的组件类型,返回空字符串
|
|
347
|
+
return ""
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.error(f"转换消息组件失败: {e}")
|
|
351
|
+
return ""
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
async def _convert_node_to_satori_static(cls, node: Node) -> str:
|
|
355
|
+
"""将单个转发节点转换为 Satori 格式"""
|
|
356
|
+
try:
|
|
357
|
+
content_parts = []
|
|
358
|
+
if node.content:
|
|
359
|
+
for content_component in node.content:
|
|
360
|
+
component_content = await cls._convert_component_to_satori_static(
|
|
361
|
+
content_component
|
|
362
|
+
)
|
|
363
|
+
if component_content:
|
|
364
|
+
content_parts.append(component_content)
|
|
365
|
+
|
|
366
|
+
content = "".join(content_parts)
|
|
367
|
+
|
|
368
|
+
# 如果内容为空,添加默认内容
|
|
369
|
+
if not content.strip():
|
|
370
|
+
content = "[转发消息]"
|
|
371
|
+
|
|
372
|
+
author_attrs = []
|
|
373
|
+
if node.uin:
|
|
374
|
+
author_attrs.append(f'id="{node.uin}"')
|
|
375
|
+
if node.name:
|
|
376
|
+
author_attrs.append(f'name="{node.name}"')
|
|
377
|
+
|
|
378
|
+
author_attr_str = " ".join(author_attrs)
|
|
379
|
+
|
|
380
|
+
return f"<message><author {author_attr_str}/>{content}</message>"
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error(f"转换转发节点失败: {e}")
|
|
384
|
+
return ""
|
|
385
|
+
|
|
386
|
+
async def _convert_nodes_to_satori(self, nodes: Nodes) -> str:
|
|
387
|
+
"""将多个转发节点转换为 Satori 格式的合并转发"""
|
|
388
|
+
try:
|
|
389
|
+
node_parts = []
|
|
390
|
+
|
|
391
|
+
for node in nodes.nodes:
|
|
392
|
+
node_content = await self._convert_node_to_satori(node)
|
|
393
|
+
if node_content:
|
|
394
|
+
node_parts.append(node_content)
|
|
395
|
+
|
|
396
|
+
if node_parts:
|
|
397
|
+
return f"<message forward>{''.join(node_parts)}</message>"
|
|
398
|
+
else:
|
|
399
|
+
return ""
|
|
400
|
+
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.error(f"转换合并转发消息失败: {e}")
|
|
403
|
+
return ""
|
|
404
|
+
|
|
405
|
+
@classmethod
|
|
406
|
+
async def _convert_nodes_to_satori_static(cls, nodes: Nodes) -> str:
|
|
407
|
+
"""将多个转发节点转换为 Satori 格式的合并转发"""
|
|
408
|
+
try:
|
|
409
|
+
node_parts = []
|
|
410
|
+
|
|
411
|
+
for node in nodes.nodes:
|
|
412
|
+
node_content = await cls._convert_node_to_satori_static(node)
|
|
413
|
+
if node_content:
|
|
414
|
+
node_parts.append(node_content)
|
|
415
|
+
|
|
416
|
+
if node_parts:
|
|
417
|
+
return f"<message forward>{''.join(node_parts)}</message>"
|
|
418
|
+
else:
|
|
419
|
+
return ""
|
|
420
|
+
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.error(f"转换合并转发消息失败: {e}")
|
|
423
|
+
return ""
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- encoding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""对企业微信发送给企业后台的消息加解密示例代码.
|
|
5
|
+
@copyright: Copyright (c) 1998-2020 Tencent Inc.
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
# ------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import base64
|
|
12
|
+
import random
|
|
13
|
+
import hashlib
|
|
14
|
+
import time
|
|
15
|
+
import struct
|
|
16
|
+
from Crypto.Cipher import AES
|
|
17
|
+
import socket
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from . import ierror
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
|
24
|
+
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
|
25
|
+
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FormatException(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def throw_exception(message, exception_class=FormatException):
|
|
34
|
+
"""my define raise exception function"""
|
|
35
|
+
raise exception_class(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SHA1:
|
|
39
|
+
"""计算企业微信的消息签名接口"""
|
|
40
|
+
|
|
41
|
+
def getSHA1(self, token, timestamp, nonce, encrypt):
|
|
42
|
+
"""用SHA1算法生成安全签名
|
|
43
|
+
@param token: 票据
|
|
44
|
+
@param timestamp: 时间戳
|
|
45
|
+
@param encrypt: 密文
|
|
46
|
+
@param nonce: 随机字符串
|
|
47
|
+
@return: 安全签名
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
# 确保所有输入都是字符串类型
|
|
51
|
+
if isinstance(encrypt, bytes):
|
|
52
|
+
encrypt = encrypt.decode("utf-8")
|
|
53
|
+
|
|
54
|
+
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
|
55
|
+
sortlist.sort()
|
|
56
|
+
sha = hashlib.sha1()
|
|
57
|
+
sha.update("".join(sortlist).encode("utf-8"))
|
|
58
|
+
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(e)
|
|
62
|
+
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class JsonParse:
|
|
66
|
+
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
|
67
|
+
|
|
68
|
+
# json消息模板
|
|
69
|
+
AES_TEXT_RESPONSE_TEMPLATE = """{
|
|
70
|
+
"encrypt": "%(msg_encrypt)s",
|
|
71
|
+
"msgsignature": "%(msg_signaturet)s",
|
|
72
|
+
"timestamp": "%(timestamp)s",
|
|
73
|
+
"nonce": "%(nonce)s"
|
|
74
|
+
}"""
|
|
75
|
+
|
|
76
|
+
def extract(self, jsontext):
|
|
77
|
+
"""提取出json数据包中的加密消息
|
|
78
|
+
@param jsontext: 待提取的json字符串
|
|
79
|
+
@return: 提取出的加密消息字符串
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
json_dict = json.loads(jsontext)
|
|
83
|
+
return ierror.WXBizMsgCrypt_OK, json_dict["encrypt"]
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(e)
|
|
86
|
+
return ierror.WXBizMsgCrypt_ParseJson_Error, None
|
|
87
|
+
|
|
88
|
+
def generate(self, encrypt, signature, timestamp, nonce):
|
|
89
|
+
"""生成json消息
|
|
90
|
+
@param encrypt: 加密后的消息密文
|
|
91
|
+
@param signature: 安全签名
|
|
92
|
+
@param timestamp: 时间戳
|
|
93
|
+
@param nonce: 随机字符串
|
|
94
|
+
@return: 生成的json字符串
|
|
95
|
+
"""
|
|
96
|
+
resp_dict = {
|
|
97
|
+
"msg_encrypt": encrypt,
|
|
98
|
+
"msg_signaturet": signature,
|
|
99
|
+
"timestamp": timestamp,
|
|
100
|
+
"nonce": nonce,
|
|
101
|
+
}
|
|
102
|
+
resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
|
103
|
+
return resp_json
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PKCS7Encoder:
|
|
107
|
+
"""提供基于PKCS7算法的加解密接口"""
|
|
108
|
+
|
|
109
|
+
block_size = 32
|
|
110
|
+
|
|
111
|
+
def encode(self, text):
|
|
112
|
+
"""对需要加密的明文进行填充补位
|
|
113
|
+
@param text: 需要进行填充补位操作的明文(bytes类型)
|
|
114
|
+
@return: 补齐明文字符串(bytes类型)
|
|
115
|
+
"""
|
|
116
|
+
text_length = len(text)
|
|
117
|
+
# 计算需要填充的位数
|
|
118
|
+
amount_to_pad = self.block_size - (text_length % self.block_size)
|
|
119
|
+
if amount_to_pad == 0:
|
|
120
|
+
amount_to_pad = self.block_size
|
|
121
|
+
# 获得补位所用的字符
|
|
122
|
+
pad = bytes([amount_to_pad])
|
|
123
|
+
# 确保text是bytes类型
|
|
124
|
+
if isinstance(text, str):
|
|
125
|
+
text = text.encode("utf-8")
|
|
126
|
+
return text + pad * amount_to_pad
|
|
127
|
+
|
|
128
|
+
def decode(self, decrypted):
|
|
129
|
+
"""删除解密后明文的补位字符
|
|
130
|
+
@param decrypted: 解密后的明文
|
|
131
|
+
@return: 删除补位字符后的明文
|
|
132
|
+
"""
|
|
133
|
+
pad = ord(decrypted[-1])
|
|
134
|
+
if pad < 1 or pad > 32:
|
|
135
|
+
pad = 0
|
|
136
|
+
return decrypted[:-pad]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Prpcrypt(object):
|
|
140
|
+
"""提供接收和推送给企业微信消息的加解密接口"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, key):
|
|
143
|
+
# self.key = base64.b64decode(key+"=")
|
|
144
|
+
self.key = key
|
|
145
|
+
# 设置加解密模式为AES的CBC模式
|
|
146
|
+
self.mode = AES.MODE_CBC
|
|
147
|
+
|
|
148
|
+
def encrypt(self, text, receiveid):
|
|
149
|
+
"""对明文进行加密
|
|
150
|
+
@param text: 需要加密的明文
|
|
151
|
+
@return: 加密得到的字符串
|
|
152
|
+
"""
|
|
153
|
+
# 16位随机字符串添加到明文开头
|
|
154
|
+
text = text.encode()
|
|
155
|
+
text = (
|
|
156
|
+
self.get_random_str()
|
|
157
|
+
+ struct.pack("I", socket.htonl(len(text)))
|
|
158
|
+
+ text
|
|
159
|
+
+ receiveid.encode()
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# 使用自定义的填充方式对明文进行补位填充
|
|
163
|
+
pkcs7 = PKCS7Encoder()
|
|
164
|
+
text = pkcs7.encode(text)
|
|
165
|
+
# 加密
|
|
166
|
+
cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore
|
|
167
|
+
try:
|
|
168
|
+
ciphertext = cryptor.encrypt(text)
|
|
169
|
+
# 使用BASE64对加密后的字符串进行编码
|
|
170
|
+
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger = logging.getLogger("astrbot")
|
|
173
|
+
logger.error(e)
|
|
174
|
+
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
|
175
|
+
|
|
176
|
+
def decrypt(self, text, receiveid):
|
|
177
|
+
"""对解密后的明文进行补位删除
|
|
178
|
+
@param text: 密文
|
|
179
|
+
@return: 删除填充补位后的明文
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore
|
|
183
|
+
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
|
184
|
+
plain_text = cryptor.decrypt(base64.b64decode(text))
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(e)
|
|
187
|
+
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
|
|
188
|
+
try:
|
|
189
|
+
pad = plain_text[-1]
|
|
190
|
+
# 去掉补位字符串
|
|
191
|
+
# pkcs7 = PKCS7Encoder()
|
|
192
|
+
# plain_text = pkcs7.encode(plain_text)
|
|
193
|
+
# 去除16位随机字符串
|
|
194
|
+
content = plain_text[16:-pad]
|
|
195
|
+
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
|
196
|
+
json_content = content[4 : json_len + 4].decode("utf-8")
|
|
197
|
+
from_receiveid = content[json_len + 4 :].decode("utf-8")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
print(e)
|
|
200
|
+
return ierror.WXBizMsgCrypt_IllegalBuffer, None
|
|
201
|
+
if from_receiveid != receiveid:
|
|
202
|
+
print("receiveid not match", receiveid, from_receiveid)
|
|
203
|
+
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
|
|
204
|
+
return 0, json_content
|
|
205
|
+
|
|
206
|
+
def get_random_str(self):
|
|
207
|
+
"""随机生成16位字符串
|
|
208
|
+
@return: 16位字符串
|
|
209
|
+
"""
|
|
210
|
+
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class WXBizJsonMsgCrypt(object):
|
|
214
|
+
# 构造函数
|
|
215
|
+
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
|
|
216
|
+
try:
|
|
217
|
+
self.key = base64.b64decode(sEncodingAESKey + "=")
|
|
218
|
+
assert len(self.key) == 32
|
|
219
|
+
except Exception as e:
|
|
220
|
+
throw_exception(f"[error]: EncodingAESKey invalid: {e}", FormatException)
|
|
221
|
+
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
|
222
|
+
self.m_sToken = sToken
|
|
223
|
+
self.m_sReceiveId = sReceiveId
|
|
224
|
+
|
|
225
|
+
# 验证URL
|
|
226
|
+
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
|
227
|
+
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
|
228
|
+
# @param sNonce: 随机串,对应URL参数的nonce
|
|
229
|
+
# @param sEchoStr: 随机串,对应URL参数的echostr
|
|
230
|
+
# @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
|
231
|
+
# @return:成功0,失败返回对应的错误码
|
|
232
|
+
|
|
233
|
+
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
|
234
|
+
sha1 = SHA1()
|
|
235
|
+
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
|
236
|
+
if ret != 0:
|
|
237
|
+
return ret, None
|
|
238
|
+
if not signature == sMsgSignature:
|
|
239
|
+
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
|
240
|
+
pc = Prpcrypt(self.key)
|
|
241
|
+
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
|
|
242
|
+
return ret, sReplyEchoStr
|
|
243
|
+
|
|
244
|
+
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
|
|
245
|
+
# 将企业回复用户的消息加密打包
|
|
246
|
+
# @param sReplyMsg: 企业号待回复用户的消息,json格式的字符串
|
|
247
|
+
# @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
|
248
|
+
# @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
|
249
|
+
# sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串,
|
|
250
|
+
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
|
251
|
+
pc = Prpcrypt(self.key)
|
|
252
|
+
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
|
253
|
+
encrypt = encrypt.decode("utf-8") # type: ignore
|
|
254
|
+
if ret != 0:
|
|
255
|
+
return ret, None
|
|
256
|
+
if timestamp is None:
|
|
257
|
+
timestamp = str(int(time.time()))
|
|
258
|
+
# 生成安全签名
|
|
259
|
+
sha1 = SHA1()
|
|
260
|
+
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
|
261
|
+
if ret != 0:
|
|
262
|
+
return ret, None
|
|
263
|
+
jsonParse = JsonParse()
|
|
264
|
+
return ret, jsonParse.generate(encrypt, signature, timestamp, sNonce)
|
|
265
|
+
|
|
266
|
+
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
|
267
|
+
# 检验消息的真实性,并且获取解密后的明文
|
|
268
|
+
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
|
269
|
+
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
|
270
|
+
# @param sNonce: 随机串,对应URL参数的nonce
|
|
271
|
+
# @param sPostData: 密文,对应POST请求的数据
|
|
272
|
+
# json_content: 解密后的原文,当return返回0时有效
|
|
273
|
+
# @return: 成功0,失败返回对应的错误码
|
|
274
|
+
# 验证安全签名
|
|
275
|
+
jsonParse = JsonParse()
|
|
276
|
+
ret, encrypt = jsonParse.extract(sPostData)
|
|
277
|
+
if ret != 0:
|
|
278
|
+
return ret, None
|
|
279
|
+
sha1 = SHA1()
|
|
280
|
+
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
|
281
|
+
if ret != 0:
|
|
282
|
+
return ret, None
|
|
283
|
+
if not signature == sMsgSignature:
|
|
284
|
+
print("signature not match")
|
|
285
|
+
print(signature)
|
|
286
|
+
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
|
287
|
+
pc = Prpcrypt(self.key)
|
|
288
|
+
ret, json_content = pc.decrypt(encrypt, self.m_sReceiveId)
|
|
289
|
+
return ret, json_content
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
企业微信智能机器人平台适配器包
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .wecomai_adapter import WecomAIBotAdapter
|
|
6
|
+
from .wecomai_api import WecomAIBotAPIClient
|
|
7
|
+
from .wecomai_event import WecomAIBotMessageEvent
|
|
8
|
+
from .wecomai_server import WecomAIBotServer
|
|
9
|
+
from .wecomai_utils import WecomAIBotConstants
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"WecomAIBotAdapter",
|
|
13
|
+
"WecomAIBotAPIClient",
|
|
14
|
+
"WecomAIBotMessageEvent",
|
|
15
|
+
"WecomAIBotServer",
|
|
16
|
+
"WecomAIBotConstants",
|
|
17
|
+
]
|