wecom-aibot-python-sdk 1.0.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.
- aibot/__init__.py +50 -0
- aibot/api.py +74 -0
- aibot/client.py +362 -0
- aibot/crypto_utils.py +73 -0
- aibot/logger.py +47 -0
- aibot/message_handler.py +89 -0
- aibot/types.py +170 -0
- aibot/utils.py +32 -0
- aibot/ws.py +574 -0
- wecom_aibot_python_sdk-1.0.0.dist-info/METADATA +365 -0
- wecom_aibot_python_sdk-1.0.0.dist-info/RECORD +14 -0
- wecom_aibot_python_sdk-1.0.0.dist-info/WHEEL +5 -0
- wecom_aibot_python_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- wecom_aibot_python_sdk-1.0.0.dist-info/top_level.txt +1 -0
aibot/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
企业微信智能机器人 Python SDK
|
|
3
|
+
|
|
4
|
+
基于 WebSocket 长连接通道,提供消息收发、流式回复、模板卡片、事件回调、文件下载解密等核心能力。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from .client import WSClient
|
|
10
|
+
from .api import WeComApiClient
|
|
11
|
+
from .ws import WsConnectionManager
|
|
12
|
+
from .message_handler import MessageHandler
|
|
13
|
+
from .crypto_utils import decrypt_file
|
|
14
|
+
from .logger import DefaultLogger
|
|
15
|
+
from .utils import generate_req_id, generate_random_string
|
|
16
|
+
from .types import (
|
|
17
|
+
MessageType,
|
|
18
|
+
EventType,
|
|
19
|
+
TemplateCardType,
|
|
20
|
+
WsCmd,
|
|
21
|
+
WSClientOptions,
|
|
22
|
+
WsFrame,
|
|
23
|
+
WsFrameHeaders,
|
|
24
|
+
Logger,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# 版本
|
|
29
|
+
"__version__",
|
|
30
|
+
# 类
|
|
31
|
+
"WSClient",
|
|
32
|
+
"WeComApiClient",
|
|
33
|
+
"WsConnectionManager",
|
|
34
|
+
"MessageHandler",
|
|
35
|
+
"DefaultLogger",
|
|
36
|
+
# 函数
|
|
37
|
+
"decrypt_file",
|
|
38
|
+
"generate_req_id",
|
|
39
|
+
"generate_random_string",
|
|
40
|
+
# 枚举/常量
|
|
41
|
+
"MessageType",
|
|
42
|
+
"EventType",
|
|
43
|
+
"TemplateCardType",
|
|
44
|
+
"WsCmd",
|
|
45
|
+
# 类型
|
|
46
|
+
"WSClientOptions",
|
|
47
|
+
"WsFrame",
|
|
48
|
+
"WsFrameHeaders",
|
|
49
|
+
"Logger",
|
|
50
|
+
]
|
aibot/api.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
企业微信 API 客户端
|
|
3
|
+
|
|
4
|
+
对标 Node.js SDK src/api.ts
|
|
5
|
+
仅负责文件下载等 HTTP 辅助功能,消息收发均走 WebSocket 通道。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import ssl
|
|
10
|
+
from typing import Any, Optional, Tuple
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import certifi
|
|
17
|
+
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
|
18
|
+
except ImportError:
|
|
19
|
+
# 未安装 certifi 时回退到系统默认证书
|
|
20
|
+
_SSL_CONTEXT = ssl.create_default_context()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WeComApiClient:
|
|
24
|
+
"""企业微信 API 客户端"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, logger: Any, timeout: int = 10000):
|
|
27
|
+
self._logger = logger
|
|
28
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout / 1000)
|
|
29
|
+
|
|
30
|
+
async def download_file_raw(self, url: str) -> Tuple[bytes, Optional[str]]:
|
|
31
|
+
"""
|
|
32
|
+
下载文件(返回原始 bytes 及文件名)
|
|
33
|
+
|
|
34
|
+
:param url: 文件下载地址
|
|
35
|
+
:return: (文件数据, 文件名)
|
|
36
|
+
"""
|
|
37
|
+
self._logger.info("Downloading file...")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
connector = aiohttp.TCPConnector(ssl=_SSL_CONTEXT)
|
|
41
|
+
async with aiohttp.ClientSession(timeout=self._timeout, connector=connector) as session:
|
|
42
|
+
async with session.get(url) as response:
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
data = await response.read()
|
|
45
|
+
|
|
46
|
+
# 从 Content-Disposition 头中解析文件名
|
|
47
|
+
content_disposition = response.headers.get("Content-Disposition", "")
|
|
48
|
+
filename: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
if content_disposition:
|
|
51
|
+
# 优先匹配 filename*=UTF-8''xxx 格式(RFC 5987)
|
|
52
|
+
utf8_match = re.search(
|
|
53
|
+
r"filename\*=UTF-8''([^;\s]+)",
|
|
54
|
+
content_disposition,
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
if utf8_match:
|
|
58
|
+
filename = unquote(utf8_match.group(1))
|
|
59
|
+
else:
|
|
60
|
+
# 匹配 filename="xxx" 或 filename=xxx 格式
|
|
61
|
+
match = re.search(
|
|
62
|
+
r'filename="?([^";\s]+)"?',
|
|
63
|
+
content_disposition,
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
if match:
|
|
67
|
+
filename = unquote(match.group(1))
|
|
68
|
+
|
|
69
|
+
self._logger.info("File downloaded successfully")
|
|
70
|
+
return data, filename
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
self._logger.error("File download failed:", str(e))
|
|
74
|
+
raise
|
aibot/client.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSClient 核心客户端
|
|
3
|
+
|
|
4
|
+
对标 Node.js SDK src/client.ts
|
|
5
|
+
继承自 pyee.AsyncIOEventEmitter,组合 WsConnectionManager + MessageHandler + WeComApiClient。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from pyee.asyncio import AsyncIOEventEmitter
|
|
12
|
+
|
|
13
|
+
from .api import WeComApiClient
|
|
14
|
+
from .crypto_utils import decrypt_file
|
|
15
|
+
from .logger import DefaultLogger
|
|
16
|
+
from .message_handler import MessageHandler
|
|
17
|
+
from .types import WsCmd, WsFrame, WsFrameHeaders, WSClientOptions
|
|
18
|
+
from .utils import generate_req_id
|
|
19
|
+
from .ws import WsConnectionManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WSClient(AsyncIOEventEmitter):
|
|
23
|
+
"""
|
|
24
|
+
企业微信智能机器人 Python SDK 核心客户端
|
|
25
|
+
|
|
26
|
+
基于 asyncio + pyee 的事件驱动架构,提供 WebSocket 长连接消息收发能力。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, options: WSClientOptions) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
|
|
32
|
+
self._options = options
|
|
33
|
+
self._logger = options.logger or DefaultLogger()
|
|
34
|
+
self._started = False
|
|
35
|
+
|
|
36
|
+
# 初始化 API 客户端(仅用于文件下载)
|
|
37
|
+
self._api_client = WeComApiClient(
|
|
38
|
+
self._logger,
|
|
39
|
+
self._options.request_timeout,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 初始化 WebSocket 管理器
|
|
43
|
+
self._ws_manager = WsConnectionManager(
|
|
44
|
+
self._logger,
|
|
45
|
+
heartbeat_interval=self._options.heartbeat_interval,
|
|
46
|
+
reconnect_base_delay=self._options.reconnect_interval,
|
|
47
|
+
max_reconnect_attempts=self._options.max_reconnect_attempts,
|
|
48
|
+
ws_url=self._options.ws_url or None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 设置认证凭证
|
|
52
|
+
self._ws_manager.set_credentials(self._options.bot_id, self._options.secret)
|
|
53
|
+
|
|
54
|
+
# 初始化消息处理器
|
|
55
|
+
self._message_handler = MessageHandler(self._logger)
|
|
56
|
+
|
|
57
|
+
# 绑定 WebSocket 事件
|
|
58
|
+
self._setup_ws_events()
|
|
59
|
+
|
|
60
|
+
def _setup_ws_events(self) -> None:
|
|
61
|
+
"""设置 WebSocket 事件处理"""
|
|
62
|
+
self._ws_manager.on_connected = lambda: self.emit("connected")
|
|
63
|
+
|
|
64
|
+
def _on_authenticated() -> None:
|
|
65
|
+
self._logger.info("Authenticated")
|
|
66
|
+
self.emit("authenticated")
|
|
67
|
+
|
|
68
|
+
self._ws_manager.on_authenticated = _on_authenticated
|
|
69
|
+
|
|
70
|
+
self._ws_manager.on_disconnected = lambda reason: self.emit(
|
|
71
|
+
"disconnected", reason
|
|
72
|
+
)
|
|
73
|
+
self._ws_manager.on_reconnecting = lambda attempt: self.emit(
|
|
74
|
+
"reconnecting", attempt
|
|
75
|
+
)
|
|
76
|
+
self._ws_manager.on_error = lambda error: self.emit("error", error)
|
|
77
|
+
self._ws_manager.on_message = lambda frame: self._message_handler.handle_frame(
|
|
78
|
+
frame, self
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def connect(self) -> "WSClient":
|
|
82
|
+
"""
|
|
83
|
+
建立 WebSocket 长连接
|
|
84
|
+
|
|
85
|
+
SDK 使用内置默认地址建立连接,连接成功后自动发送认证帧(bot_id + secret)。
|
|
86
|
+
|
|
87
|
+
:return: 返回 self,支持链式调用
|
|
88
|
+
"""
|
|
89
|
+
if self._started:
|
|
90
|
+
self._logger.warn("Client already connected")
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
self._logger.info("Establishing WebSocket connection...")
|
|
94
|
+
self._started = True
|
|
95
|
+
|
|
96
|
+
await self._ws_manager.connect()
|
|
97
|
+
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def disconnect(self) -> None:
|
|
101
|
+
"""断开 WebSocket 连接"""
|
|
102
|
+
if not self._started:
|
|
103
|
+
self._logger.warn("Client not connected")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
self._logger.info("Disconnecting...")
|
|
107
|
+
self._started = False
|
|
108
|
+
self._ws_manager.disconnect()
|
|
109
|
+
self._logger.info("Disconnected")
|
|
110
|
+
|
|
111
|
+
async def reply(
|
|
112
|
+
self,
|
|
113
|
+
frame: WsFrameHeaders,
|
|
114
|
+
body: Dict[str, Any],
|
|
115
|
+
cmd: Optional[str] = None,
|
|
116
|
+
) -> WsFrame:
|
|
117
|
+
"""
|
|
118
|
+
通过 WebSocket 通道发送回复消息(通用方法)
|
|
119
|
+
|
|
120
|
+
:param frame: 收到的原始 WebSocket 帧,透传 headers.req_id
|
|
121
|
+
:param body: 回复消息体
|
|
122
|
+
:param cmd: 发送的命令类型
|
|
123
|
+
:return: 回执帧
|
|
124
|
+
"""
|
|
125
|
+
headers = frame.get("headers", {})
|
|
126
|
+
req_id = headers.get("req_id", "")
|
|
127
|
+
return await self._ws_manager.send_reply(req_id, body, cmd or WsCmd.RESPONSE)
|
|
128
|
+
|
|
129
|
+
async def reply_stream(
|
|
130
|
+
self,
|
|
131
|
+
frame: WsFrameHeaders,
|
|
132
|
+
stream_id: str,
|
|
133
|
+
content: str,
|
|
134
|
+
finish: bool = False,
|
|
135
|
+
msg_item: Optional[List[Dict[str, Any]]] = None,
|
|
136
|
+
feedback: Optional[Dict[str, Any]] = None,
|
|
137
|
+
) -> WsFrame:
|
|
138
|
+
"""
|
|
139
|
+
发送流式文本回复(便捷方法)
|
|
140
|
+
|
|
141
|
+
:param frame: 收到的原始 WebSocket 帧,透传 headers.req_id
|
|
142
|
+
:param stream_id: 流式消息 ID
|
|
143
|
+
:param content: 回复内容(支持 Markdown)
|
|
144
|
+
:param finish: 是否结束流式消息,默认 False
|
|
145
|
+
:param msg_item: 图文混排项(仅在 finish=True 时有效)
|
|
146
|
+
:param feedback: 反馈信息(仅在首次回复时设置)
|
|
147
|
+
:return: 回执帧
|
|
148
|
+
"""
|
|
149
|
+
stream: Dict[str, Any] = {
|
|
150
|
+
"id": stream_id,
|
|
151
|
+
"finish": finish,
|
|
152
|
+
"content": content,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# msg_item 仅在 finish=True 时支持
|
|
156
|
+
if finish and msg_item and len(msg_item) > 0:
|
|
157
|
+
stream["msg_item"] = msg_item
|
|
158
|
+
|
|
159
|
+
# feedback 仅在首次回复时设置
|
|
160
|
+
if feedback:
|
|
161
|
+
stream["feedback"] = feedback
|
|
162
|
+
|
|
163
|
+
return await self.reply(
|
|
164
|
+
frame,
|
|
165
|
+
{
|
|
166
|
+
"msgtype": "stream",
|
|
167
|
+
"stream": stream,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def reply_welcome(
|
|
172
|
+
self,
|
|
173
|
+
frame: WsFrameHeaders,
|
|
174
|
+
body: Dict[str, Any],
|
|
175
|
+
) -> WsFrame:
|
|
176
|
+
"""
|
|
177
|
+
发送欢迎语回复
|
|
178
|
+
|
|
179
|
+
注意:此方法需要使用对应事件(如 enter_chat)的 req_id 才能调用。
|
|
180
|
+
收到事件回调后需在 5 秒内发送回复,超时将无法发送欢迎语。
|
|
181
|
+
|
|
182
|
+
:param frame: 对应事件的 WebSocket 帧
|
|
183
|
+
:param body: 欢迎语消息体(支持文本或模板卡片格式)
|
|
184
|
+
:return: 回执帧
|
|
185
|
+
"""
|
|
186
|
+
return await self.reply(frame, body, WsCmd.RESPONSE_WELCOME)
|
|
187
|
+
|
|
188
|
+
async def reply_template_card(
|
|
189
|
+
self,
|
|
190
|
+
frame: WsFrameHeaders,
|
|
191
|
+
template_card: Dict[str, Any],
|
|
192
|
+
feedback: Optional[Dict[str, Any]] = None,
|
|
193
|
+
) -> WsFrame:
|
|
194
|
+
"""
|
|
195
|
+
回复模板卡片消息
|
|
196
|
+
|
|
197
|
+
:param frame: 收到的原始 WebSocket 帧
|
|
198
|
+
:param template_card: 模板卡片内容
|
|
199
|
+
:param feedback: 反馈信息
|
|
200
|
+
:return: 回执帧
|
|
201
|
+
"""
|
|
202
|
+
card = {**template_card, "feedback": feedback} if feedback else template_card
|
|
203
|
+
body = {
|
|
204
|
+
"msgtype": "template_card",
|
|
205
|
+
"template_card": card,
|
|
206
|
+
}
|
|
207
|
+
return await self.reply(frame, body)
|
|
208
|
+
|
|
209
|
+
async def reply_stream_with_card(
|
|
210
|
+
self,
|
|
211
|
+
frame: WsFrameHeaders,
|
|
212
|
+
stream_id: str,
|
|
213
|
+
content: str,
|
|
214
|
+
finish: bool = False,
|
|
215
|
+
msg_item: Optional[List[Dict[str, Any]]] = None,
|
|
216
|
+
stream_feedback: Optional[Dict[str, Any]] = None,
|
|
217
|
+
template_card: Optional[Dict[str, Any]] = None,
|
|
218
|
+
card_feedback: Optional[Dict[str, Any]] = None,
|
|
219
|
+
) -> WsFrame:
|
|
220
|
+
"""
|
|
221
|
+
发送流式消息 + 模板卡片组合回复
|
|
222
|
+
|
|
223
|
+
:param frame: 收到的原始 WebSocket 帧
|
|
224
|
+
:param stream_id: 流式消息 ID
|
|
225
|
+
:param content: 回复内容(支持 Markdown)
|
|
226
|
+
:param finish: 是否结束流式消息,默认 False
|
|
227
|
+
:param msg_item: 图文混排项(仅在 finish=True 时有效)
|
|
228
|
+
:param stream_feedback: 流式消息反馈信息(首次回复时设置)
|
|
229
|
+
:param template_card: 模板卡片内容(同一消息只能回复一次)
|
|
230
|
+
:param card_feedback: 模板卡片反馈信息
|
|
231
|
+
:return: 回执帧
|
|
232
|
+
"""
|
|
233
|
+
stream: Dict[str, Any] = {
|
|
234
|
+
"id": stream_id,
|
|
235
|
+
"finish": finish,
|
|
236
|
+
"content": content,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if finish and msg_item and len(msg_item) > 0:
|
|
240
|
+
stream["msg_item"] = msg_item
|
|
241
|
+
|
|
242
|
+
if stream_feedback:
|
|
243
|
+
stream["feedback"] = stream_feedback
|
|
244
|
+
|
|
245
|
+
body: Dict[str, Any] = {
|
|
246
|
+
"msgtype": "stream_with_template_card",
|
|
247
|
+
"stream": stream,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if template_card:
|
|
251
|
+
card = (
|
|
252
|
+
{**template_card, "feedback": card_feedback}
|
|
253
|
+
if card_feedback
|
|
254
|
+
else template_card
|
|
255
|
+
)
|
|
256
|
+
body["template_card"] = card
|
|
257
|
+
|
|
258
|
+
return await self.reply(frame, body)
|
|
259
|
+
|
|
260
|
+
async def update_template_card(
|
|
261
|
+
self,
|
|
262
|
+
frame: WsFrameHeaders,
|
|
263
|
+
template_card: Dict[str, Any],
|
|
264
|
+
userids: Optional[List[str]] = None,
|
|
265
|
+
) -> WsFrame:
|
|
266
|
+
"""
|
|
267
|
+
更新模板卡片
|
|
268
|
+
|
|
269
|
+
注意:此方法需要使用对应事件(template_card_event)的 req_id 才能调用。
|
|
270
|
+
收到事件回调后需在 5 秒内发送回复,超时将无法更新卡片。
|
|
271
|
+
|
|
272
|
+
:param frame: 对应事件的 WebSocket 帧
|
|
273
|
+
:param template_card: 模板卡片内容(task_id 需跟回调收到的 task_id 一致)
|
|
274
|
+
:param userids: 要替换模版卡片消息的 userid 列表
|
|
275
|
+
:return: 回执帧
|
|
276
|
+
"""
|
|
277
|
+
body: Dict[str, Any] = {
|
|
278
|
+
"response_type": "update_template_card",
|
|
279
|
+
"template_card": template_card,
|
|
280
|
+
}
|
|
281
|
+
if userids and len(userids) > 0:
|
|
282
|
+
body["userids"] = userids
|
|
283
|
+
|
|
284
|
+
return await self.reply(frame, body, WsCmd.RESPONSE_UPDATE)
|
|
285
|
+
|
|
286
|
+
async def send_message(
|
|
287
|
+
self,
|
|
288
|
+
chatid: str,
|
|
289
|
+
body: Dict[str, Any],
|
|
290
|
+
) -> WsFrame:
|
|
291
|
+
"""
|
|
292
|
+
主动发送消息
|
|
293
|
+
|
|
294
|
+
向指定会话(单聊或群聊)主动推送消息,无需依赖收到的回调帧。
|
|
295
|
+
|
|
296
|
+
:param chatid: 会话 ID,单聊填用户的 userid,群聊填对应群聊的 chatid
|
|
297
|
+
:param body: 消息体(支持 markdown 或 template_card 格式)
|
|
298
|
+
:return: 回执帧
|
|
299
|
+
"""
|
|
300
|
+
req_id = generate_req_id(WsCmd.SEND_MSG)
|
|
301
|
+
full_body = {"chatid": chatid, **body}
|
|
302
|
+
return await self._ws_manager.send_reply(req_id, full_body, WsCmd.SEND_MSG)
|
|
303
|
+
|
|
304
|
+
async def download_file(
|
|
305
|
+
self, url: str, aes_key: Optional[str] = None
|
|
306
|
+
) -> Tuple[bytes, Optional[str]]:
|
|
307
|
+
"""
|
|
308
|
+
下载文件并使用 AES 密钥解密
|
|
309
|
+
|
|
310
|
+
:param url: 文件下载地址
|
|
311
|
+
:param aes_key: AES 解密密钥(Base64 编码),取自消息中 image.aeskey 或 file.aeskey
|
|
312
|
+
:return: (解密后的文件数据, 文件名)
|
|
313
|
+
"""
|
|
314
|
+
self._logger.info("Downloading and decrypting file...")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
# 下载加密的文件数据
|
|
318
|
+
encrypted_data, filename = await self._api_client.download_file_raw(url)
|
|
319
|
+
|
|
320
|
+
# 如果没有提供 aes_key,直接返回原始数据
|
|
321
|
+
if not aes_key:
|
|
322
|
+
self._logger.warn("No aes_key provided, returning raw file data")
|
|
323
|
+
return encrypted_data, filename
|
|
324
|
+
|
|
325
|
+
# 使用独立的解密模块进行 AES-256-CBC 解密
|
|
326
|
+
decrypted_data = decrypt_file(encrypted_data, aes_key)
|
|
327
|
+
|
|
328
|
+
self._logger.info("File downloaded and decrypted successfully")
|
|
329
|
+
return decrypted_data, filename
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
self._logger.error(f"File download/decrypt failed: {e}")
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def is_connected(self) -> bool:
|
|
337
|
+
"""获取当前连接状态"""
|
|
338
|
+
return self._ws_manager.is_connected
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def api(self) -> WeComApiClient:
|
|
342
|
+
"""获取 API 客户端实例(供高级用途使用)"""
|
|
343
|
+
return self._api_client
|
|
344
|
+
|
|
345
|
+
def run(self) -> None:
|
|
346
|
+
"""
|
|
347
|
+
便捷方法:启动事件循环并连接
|
|
348
|
+
|
|
349
|
+
等价于:
|
|
350
|
+
asyncio.get_event_loop().run_until_complete(client.connect())
|
|
351
|
+
asyncio.get_event_loop().run_forever()
|
|
352
|
+
"""
|
|
353
|
+
loop = asyncio.new_event_loop()
|
|
354
|
+
asyncio.set_event_loop(loop)
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
loop.run_until_complete(self.connect())
|
|
358
|
+
loop.run_forever()
|
|
359
|
+
except KeyboardInterrupt:
|
|
360
|
+
self.disconnect()
|
|
361
|
+
finally:
|
|
362
|
+
loop.close()
|
aibot/crypto_utils.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
加解密工具模块
|
|
3
|
+
|
|
4
|
+
对标 Node.js SDK src/crypto.ts
|
|
5
|
+
提供文件加解密相关的功能函数,使用 AES-256-CBC 解密。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def decrypt_file(encrypted_data: bytes, aes_key: str) -> bytes:
|
|
14
|
+
"""
|
|
15
|
+
使用 AES-256-CBC 解密文件
|
|
16
|
+
|
|
17
|
+
:param encrypted_data: 加密的文件数据
|
|
18
|
+
:param aes_key: Base64 编码的 AES-256 密钥
|
|
19
|
+
:return: 解密后的文件数据
|
|
20
|
+
:raises ValueError: 参数无效时
|
|
21
|
+
:raises RuntimeError: 解密失败时
|
|
22
|
+
"""
|
|
23
|
+
if not encrypted_data:
|
|
24
|
+
raise ValueError("decrypt_file: encrypted_data is empty or not provided")
|
|
25
|
+
|
|
26
|
+
if not aes_key or not isinstance(aes_key, str):
|
|
27
|
+
raise ValueError("decrypt_file: aes_key must be a non-empty string")
|
|
28
|
+
|
|
29
|
+
# 将 Base64 编码的 aesKey 解码为 bytes
|
|
30
|
+
# Node.js 的 Buffer.from(str, 'base64') 会自动容错处理缺少的 '=' padding,
|
|
31
|
+
# 但 Python 的 base64.b64decode 严格要求长度是 4 的倍数,需要手动补齐。
|
|
32
|
+
padded_aes_key = aes_key + '=' * (4 - len(aes_key) % 4) if len(aes_key) % 4 != 0 else aes_key
|
|
33
|
+
key = base64.b64decode(padded_aes_key)
|
|
34
|
+
|
|
35
|
+
# IV 取 aesKey 解码后的前 16 字节
|
|
36
|
+
iv = key[:16]
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
40
|
+
decryptor = cipher.decryptor()
|
|
41
|
+
|
|
42
|
+
# 确保加密数据长度是 AES block size (16字节) 的倍数
|
|
43
|
+
# Node.js 的 setAutoPadding(false) 不会对不对齐的数据报错,
|
|
44
|
+
# 但 Python 的 cryptography 库会抛出 "Incorrect padding"。
|
|
45
|
+
# 这里手动补零对齐,后续通过 PKCS#7 去除 padding 来获得正确数据。
|
|
46
|
+
block_size = 16
|
|
47
|
+
remainder = len(encrypted_data) % block_size
|
|
48
|
+
if remainder != 0:
|
|
49
|
+
encrypted_data = encrypted_data + b'\x00' * (block_size - remainder)
|
|
50
|
+
|
|
51
|
+
# 解密(不自动处理 padding)
|
|
52
|
+
decrypted = decryptor.update(encrypted_data) + decryptor.finalize()
|
|
53
|
+
|
|
54
|
+
# 手动去除 PKCS#7 填充(支持 32 字节 block)
|
|
55
|
+
if len(decrypted) == 0:
|
|
56
|
+
raise ValueError("Decrypted data is empty")
|
|
57
|
+
|
|
58
|
+
pad_len = decrypted[-1]
|
|
59
|
+
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
|
60
|
+
raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}")
|
|
61
|
+
|
|
62
|
+
# 验证所有 padding 字节是否一致
|
|
63
|
+
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
|
64
|
+
if decrypted[i] != pad_len:
|
|
65
|
+
raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch")
|
|
66
|
+
|
|
67
|
+
return decrypted[: len(decrypted) - pad_len]
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"decrypt_file: Decryption failed - {e}. "
|
|
72
|
+
"This may indicate corrupted data or an incorrect aesKey."
|
|
73
|
+
) from e
|
aibot/logger.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
默认日志实现
|
|
3
|
+
|
|
4
|
+
对标 Node.js SDK src/logger.ts
|
|
5
|
+
带有日志级别和时间戳的控制台日志
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DefaultLogger:
|
|
13
|
+
"""默认日志实现,带有日志级别和时间戳的控制台日志"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, prefix: str = "AiBotSDK"):
|
|
16
|
+
self._prefix = prefix
|
|
17
|
+
|
|
18
|
+
def _format_time(self) -> str:
|
|
19
|
+
return datetime.now(timezone.utc).isoformat()
|
|
20
|
+
|
|
21
|
+
def debug(self, message: str, *args: object) -> None:
|
|
22
|
+
print(
|
|
23
|
+
f"[{self._format_time()}] [{self._prefix}] [DEBUG] {message}",
|
|
24
|
+
*args,
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def info(self, message: str, *args: object) -> None:
|
|
29
|
+
print(
|
|
30
|
+
f"[{self._format_time()}] [{self._prefix}] [INFO] {message}",
|
|
31
|
+
*args,
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def warn(self, message: str, *args: object) -> None:
|
|
36
|
+
print(
|
|
37
|
+
f"[{self._format_time()}] [{self._prefix}] [WARN] {message}",
|
|
38
|
+
*args,
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def error(self, message: str, *args: object) -> None:
|
|
43
|
+
print(
|
|
44
|
+
f"[{self._format_time()}] [{self._prefix}] [ERROR] {message}",
|
|
45
|
+
*args,
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
aibot/message_handler.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
消息处理器
|
|
3
|
+
|
|
4
|
+
对标 Node.js SDK src/message-handler.ts
|
|
5
|
+
负责解析 WebSocket 帧并分发为具体的消息事件和事件回调。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .types import MessageType, WsCmd, WsFrame
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MessageHandler:
|
|
15
|
+
"""
|
|
16
|
+
消息处理器
|
|
17
|
+
|
|
18
|
+
负责解析 WebSocket 帧并分发为具体的消息事件和事件回调。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, logger: Any):
|
|
22
|
+
self._logger = logger
|
|
23
|
+
|
|
24
|
+
def handle_frame(self, frame: WsFrame, emitter: Any) -> None:
|
|
25
|
+
"""
|
|
26
|
+
处理收到的 WebSocket 帧,解析并触发对应的消息/事件
|
|
27
|
+
|
|
28
|
+
:param frame: WebSocket 接收帧
|
|
29
|
+
:param emitter: WSClient 实例,用于触发事件
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
body = frame.get("body")
|
|
33
|
+
|
|
34
|
+
if not body or not body.get("msgtype"):
|
|
35
|
+
self._logger.warn(
|
|
36
|
+
f"Received invalid message format: {json.dumps(frame)[:200]}"
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# 事件推送回调处理
|
|
41
|
+
if frame.get("cmd") == WsCmd.EVENT_CALLBACK:
|
|
42
|
+
self._handle_event_callback(frame, emitter)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# 消息推送回调处理
|
|
46
|
+
self._handle_message_callback(frame, emitter)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
self._logger.error(f"Failed to handle message: {e}")
|
|
49
|
+
|
|
50
|
+
def _handle_message_callback(self, frame: WsFrame, emitter: Any) -> None:
|
|
51
|
+
"""处理消息推送回调 (aibot_msg_callback)"""
|
|
52
|
+
body = frame.get("body", {})
|
|
53
|
+
|
|
54
|
+
# 触发通用消息事件
|
|
55
|
+
emitter.emit("message", frame)
|
|
56
|
+
|
|
57
|
+
# 根据 body 中的消息类型触发特定事件
|
|
58
|
+
msgtype = body.get("msgtype", "")
|
|
59
|
+
|
|
60
|
+
if msgtype == MessageType.Text:
|
|
61
|
+
emitter.emit("message.text", frame)
|
|
62
|
+
elif msgtype == MessageType.Image:
|
|
63
|
+
emitter.emit("message.image", frame)
|
|
64
|
+
elif msgtype == MessageType.Mixed:
|
|
65
|
+
emitter.emit("message.mixed", frame)
|
|
66
|
+
elif msgtype == MessageType.Voice:
|
|
67
|
+
emitter.emit("message.voice", frame)
|
|
68
|
+
elif msgtype == MessageType.File:
|
|
69
|
+
emitter.emit("message.file", frame)
|
|
70
|
+
else:
|
|
71
|
+
self._logger.debug(f"Received unhandled message type: {msgtype}")
|
|
72
|
+
|
|
73
|
+
def _handle_event_callback(self, frame: WsFrame, emitter: Any) -> None:
|
|
74
|
+
"""处理事件推送回调 (aibot_event_callback)"""
|
|
75
|
+
body = frame.get("body", {})
|
|
76
|
+
|
|
77
|
+
# 触发通用事件
|
|
78
|
+
emitter.emit("event", frame)
|
|
79
|
+
|
|
80
|
+
# 根据事件类型触发特定事件
|
|
81
|
+
event = body.get("event", {})
|
|
82
|
+
event_type = event.get("eventtype") if isinstance(event, dict) else None
|
|
83
|
+
|
|
84
|
+
if event_type:
|
|
85
|
+
emitter.emit(f"event.{event_type}", frame)
|
|
86
|
+
else:
|
|
87
|
+
self._logger.debug(
|
|
88
|
+
f"Received event callback without eventtype: {json.dumps(body)[:200]}"
|
|
89
|
+
)
|