openilink-sdk-python 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (21) hide show
  1. openilink_sdk_python-0.1.1/PKG-INFO +376 -0
  2. openilink_sdk_python-0.1.1/README.md +365 -0
  3. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/__init__.py +5 -0
  4. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/client.py +9 -10
  5. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/helpers.py +14 -2
  6. openilink_sdk_python-0.1.1/openilink/http.py +78 -0
  7. openilink_sdk_python-0.1.1/openilink_sdk_python.egg-info/PKG-INFO +376 -0
  8. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink_sdk_python.egg-info/SOURCES.txt +1 -0
  9. openilink_sdk_python-0.1.1/openilink_sdk_python.egg-info/requires.txt +3 -0
  10. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/pyproject.toml +6 -6
  11. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/setup.cfg +4 -4
  12. openilink_sdk_python-0.1.0/PKG-INFO +0 -60
  13. openilink_sdk_python-0.1.0/README.md +0 -49
  14. openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/PKG-INFO +0 -60
  15. openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/requires.txt +0 -2
  16. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/auth.py +0 -0
  17. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/errors.py +0 -0
  18. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/monitor.py +0 -0
  19. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink/types.py +0 -0
  20. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink_sdk_python.egg-info/dependency_links.txt +0 -0
  21. {openilink_sdk_python-0.1.0 → openilink_sdk_python-0.1.1}/openilink_sdk_python.egg-info/top_level.txt +0 -0
@@ -0,0 +1,376 @@
1
+ Metadata-Version: 2.4
2
+ Name: openilink-sdk-python
3
+ Version: 0.1.1
4
+ Summary: Python client for the Weixin iLink Bot API
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/openilink/openilink-sdk-python
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: qrcode
10
+ Requires-Dist: qrcode>=7.0; extra == "qrcode"
11
+
12
+ # openilink-sdk-python
13
+
14
+ 微信 iLink Bot API 的 Python SDK,覆盖完整生命周期:扫码登录、消息监听、文本发送、输入状态指示、主动推送。
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ # 从源码安装
20
+ git clone https://github.com/openilink/openilink-sdk-python.git
21
+ cd openilink-sdk-python
22
+ pip install -e .
23
+ ```
24
+
25
+ ## 快速开始
26
+
27
+ ### 最小 Echo Bot
28
+
29
+ ```python
30
+ from openilink import Client, LoginCallbacks, MonitorOptions, extract_text, print_qrcode
31
+
32
+ client = Client()
33
+
34
+ # 扫码登录
35
+ result = client.login_with_qr(
36
+ callbacks=LoginCallbacks(on_qrcode=print_qrcode)
37
+ )
38
+ print(f"登录成功! BotID={result.bot_id}")
39
+
40
+ # 收到什么就回复什么
41
+ client.monitor(
42
+ lambda msg: client.push(msg.from_user_id, "echo: " + extract_text(msg)),
43
+ opts=MonitorOptions(on_error=lambda e: print(f"错误: {e}")),
44
+ )
45
+ ```
46
+
47
+ 运行后终端会显示二维码,用微信扫描即可登录。
48
+
49
+ ## 核心概念
50
+
51
+ ### 1. 创建客户端
52
+
53
+ ```python
54
+ from openilink import Client
55
+
56
+ # 首次使用,需要扫码登录
57
+ client = Client()
58
+
59
+ # 已有 token,跳过扫码直接使用
60
+ client = Client(token="你保存的bot_token")
61
+ ```
62
+
63
+ 支持的参数:
64
+
65
+ | 参数 | 默认值 | 说明 |
66
+ |---|---|---|
67
+ | `token` | `""` | Bot Token,为空则需要扫码登录 |
68
+ | `base_url` | `https://ilinkai.weixin.qq.com` | API 地址 |
69
+ | `cdn_base_url` | `https://novac2c.cdn.weixin.qq.com/c2c` | CDN 地址 |
70
+ | `bot_type` | `"3"` | Bot 类型 |
71
+ | `version` | `"1.0.0"` | 客户端版本号 |
72
+ | `session` | `None` | 自定义 `requests.Session` |
73
+
74
+ ### 2. 扫码登录
75
+
76
+ ```python
77
+ from openilink import Client, LoginCallbacks, print_qrcode
78
+
79
+ client = Client()
80
+
81
+ result = client.login_with_qr(
82
+ callbacks=LoginCallbacks(
83
+ on_qrcode=print_qrcode, # 二维码就绪
84
+ on_scanned=lambda: print("已扫码,请在手机上确认..."), # 用户已扫码
85
+ on_expired=lambda n, mx: print(f"二维码已过期,正在刷新 ({n}/{mx})..."),
86
+ ),
87
+ timeout=480, # 登录超时时间(秒),默认 8 分钟
88
+ )
89
+
90
+ if result.connected:
91
+ print(f"登录成功! BotID={result.bot_id} UserID={result.user_id}")
92
+ # 保存 token,下次启动可以跳过扫码
93
+ save_token(result.bot_token)
94
+ else:
95
+ print(f"登录失败: {result.message}")
96
+ ```
97
+
98
+ `LoginResult` 字段:
99
+
100
+ | 字段 | 类型 | 说明 |
101
+ |---|---|---|
102
+ | `connected` | `bool` | 是否登录成功 |
103
+ | `bot_token` | `str` | Bot Token,保存后下次可直接使用 |
104
+ | `bot_id` | `str` | Bot ID |
105
+ | `base_url` | `str` | 服务端返回的 API 地址 |
106
+ | `user_id` | `str` | 用户 ID |
107
+ | `message` | `str` | 状态消息 |
108
+
109
+ ### 3. 监听消息
110
+
111
+ ```python
112
+ from openilink import MonitorOptions
113
+
114
+ def handler(msg):
115
+ text = extract_text(msg)
116
+ if not text:
117
+ return
118
+ print(f"收到来自 {msg.from_user_id} 的消息: {text}")
119
+
120
+ # 回复消息
121
+ client.push(msg.from_user_id, f"你说了: {text}")
122
+
123
+ client.monitor(
124
+ handler,
125
+ opts=MonitorOptions(
126
+ initial_buf="", # 断点续传游标,空则从头开始
127
+ on_buf_update=lambda buf: save_to_file(buf), # 游标更新回调,持久化后可断点续传
128
+ on_error=lambda e: print(f"错误: {e}"), # 非致命错误回调
129
+ on_session_expired=lambda: print("会话已过期!"), # 会话过期回调
130
+ ),
131
+ )
132
+ ```
133
+
134
+ `monitor()` 会阻塞当前线程,自动处理重试和退避:
135
+ - 连续失败 3 次后退避 30 秒
136
+ - 会话过期(errcode -14)后等待 5 分钟
137
+ - 自动缓存每条消息的 `context_token`,供 `push()` 使用
138
+
139
+ ### 4. 发送消息
140
+
141
+ ```python
142
+ # 方式一:push(推荐) —— 使用自动缓存的 context_token
143
+ client.push(user_id, "你好!")
144
+
145
+ # 方式二:send_text —— 手动传入 context_token
146
+ client.send_text(user_id, "你好!", context_token)
147
+ ```
148
+
149
+ > `push()` 要求目标用户之前发过消息(SDK 会自动缓存其 context_token)。
150
+ > 如果用户从未发过消息,会抛出 `NoContextTokenError`。
151
+
152
+ ### 5. 输入状态指示
153
+
154
+ ```python
155
+ # 获取 typing_ticket
156
+ config = client.get_config(user_id, context_token)
157
+
158
+ # 显示"正在输入..."
159
+ client.send_typing(user_id, config.typing_ticket)
160
+
161
+ # 取消输入状态
162
+ from openilink import TypingStatus
163
+ client.send_typing(user_id, config.typing_ticket, TypingStatus.CANCEL)
164
+ ```
165
+
166
+ ### 6. 获取 CDN 上传地址
167
+
168
+ ```python
169
+ resp = client.get_upload_url({
170
+ "filekey": "my-file-key",
171
+ "media_type": 1, # 1=图片 2=视频 3=文件 4=语音
172
+ "to_user_id": user_id,
173
+ "rawsize": file_size,
174
+ "rawfilemd5": file_md5,
175
+ "filesize": file_size,
176
+ })
177
+ print(resp.upload_param)
178
+ ```
179
+
180
+ ### 7. 优雅停止
181
+
182
+ ```python
183
+ import signal
184
+
185
+ def on_signal(sig, frame):
186
+ client.stop() # 通知 monitor 停止
187
+
188
+ signal.signal(signal.SIGINT, on_signal)
189
+ signal.signal(signal.SIGTERM, on_signal)
190
+ ```
191
+
192
+ ## 消息结构
193
+
194
+ 收到的每条消息是 `WeixinMessage` 对象:
195
+
196
+ ```python
197
+ msg.from_user_id # 发送者 ID
198
+ msg.to_user_id # 接收者 ID
199
+ msg.message_id # 消息 ID
200
+ msg.message_type # MessageType.USER(1) 或 MessageType.BOT(2)
201
+ msg.message_state # MessageState.NEW(0) / GENERATING(1) / FINISH(2)
202
+ msg.context_token # 上下文 token,回复时需要
203
+ msg.session_id # 会话 ID
204
+ msg.group_id # 群 ID(私聊为空)
205
+ msg.item_list # 消息内容列表 [MessageItem, ...]
206
+ ```
207
+
208
+ 每个 `MessageItem` 包含一种内容类型:
209
+
210
+ ```python
211
+ item.type # MessageItemType: TEXT(1) IMAGE(2) VOICE(3) FILE(4) VIDEO(5)
212
+ item.text_item # TextItem: .text
213
+ item.image_item # ImageItem: .url, .media, .thumb_media, ...
214
+ item.voice_item # VoiceItem: .media, .playtime, .text(语音转文字), ...
215
+ item.file_item # FileItem: .file_name, .media, .md5, ...
216
+ item.video_item # VideoItem: .media, .play_length, .thumb_media, ...
217
+ ```
218
+
219
+ 使用 `extract_text(msg)` 可以快速提取第一条文本内容。
220
+
221
+ ## 完整示例:命令机器人
222
+
223
+ ```python
224
+ import signal
225
+ import sys
226
+ from pathlib import Path
227
+ from openilink import (
228
+ Client, LoginCallbacks, MonitorOptions,
229
+ extract_text, print_qrcode,
230
+ )
231
+
232
+ BUF_FILE = Path("sync_buf.dat")
233
+ TOKEN_FILE = Path("bot_token.dat")
234
+
235
+
236
+ def load_file(path: Path) -> str:
237
+ try:
238
+ return path.read_text().strip()
239
+ except FileNotFoundError:
240
+ return ""
241
+
242
+
243
+ def main():
244
+ token = load_file(TOKEN_FILE)
245
+ client = Client(token=token)
246
+
247
+ # 没有 token 则扫码登录
248
+ if not token:
249
+ result = client.login_with_qr(
250
+ callbacks=LoginCallbacks(
251
+ on_qrcode=print_qrcode,
252
+ on_scanned=lambda: print("已扫码,请确认..."),
253
+ )
254
+ )
255
+ if not result.connected:
256
+ print(f"登录失败: {result.message}", file=sys.stderr)
257
+ sys.exit(1)
258
+ TOKEN_FILE.write_text(result.bot_token)
259
+ print(f"登录成功! BotID={result.bot_id}")
260
+
261
+ # Ctrl+C 优雅退出
262
+ signal.signal(signal.SIGINT, lambda *_: client.stop())
263
+
264
+ def handler(msg):
265
+ text = extract_text(msg)
266
+ if not text:
267
+ return
268
+
269
+ user = msg.from_user_id
270
+ print(f"[{user}] {text}")
271
+
272
+ if text == "/help":
273
+ client.push(user, "支持的命令:\n/help - 帮助\n/ping - 测试\n/time - 当前时间")
274
+ elif text == "/ping":
275
+ client.push(user, "pong!")
276
+ elif text == "/time":
277
+ from datetime import datetime
278
+ client.push(user, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
279
+ else:
280
+ client.push(user, f"你说了: {text}")
281
+
282
+ print("开始监听消息... (Ctrl+C 退出)")
283
+ client.monitor(handler, opts=MonitorOptions(
284
+ initial_buf=load_file(BUF_FILE),
285
+ on_buf_update=lambda buf: BUF_FILE.write_text(buf),
286
+ on_error=lambda e: print(f"错误: {e}", file=sys.stderr),
287
+ on_session_expired=lambda: print("会话过期,需要重新登录", file=sys.stderr),
288
+ ))
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
293
+ ```
294
+
295
+ ## 错误处理
296
+
297
+ | 异常 | 场景 |
298
+ |---|---|
299
+ | `APIError` | API 返回错误(`ret != 0`),可通过 `.is_session_expired()` 判断会话是否过期 |
300
+ | `HTTPError` | HTTP 状态码 >= 400 |
301
+ | `NoContextTokenError` | 调用 `push()` 时目标用户没有缓存的 context token |
302
+
303
+ ```python
304
+ from openilink import NoContextTokenError, APIError
305
+
306
+ try:
307
+ client.push(user_id, "你好")
308
+ except NoContextTokenError:
309
+ print("该用户还没有发过消息,无法主动推送")
310
+ except APIError as e:
311
+ if e.is_session_expired():
312
+ print("会话过期,请重新登录")
313
+ else:
314
+ print(f"API 错误: {e}")
315
+ ```
316
+
317
+ ## 断点续传
318
+
319
+ `monitor()` 通过 `get_updates_buf` 游标实现增量拉取。持久化这个游标,重启后可以从断点继续:
320
+
321
+ ```python
322
+ client.monitor(handler, opts=MonitorOptions(
323
+ initial_buf=Path("sync_buf.dat").read_text(), # 启动时读取
324
+ on_buf_update=lambda buf: Path("sync_buf.dat").write_text(buf), # 实时保存
325
+ ))
326
+ ```
327
+
328
+ ## API 参考
329
+
330
+ | 方法 | 说明 |
331
+ |---|---|
332
+ | `Client(token, base_url, ...)` | 创建客户端 |
333
+ | `client.login_with_qr(callbacks, timeout)` | 扫码登录 |
334
+ | `client.fetch_qr_code()` | 单独获取二维码 |
335
+ | `client.poll_qr_status(qrcode)` | 轮询扫码状态 |
336
+ | `client.monitor(handler, opts)` | 长轮询消息监听(阻塞) |
337
+ | `client.get_updates(buf)` | 单次拉取更新 |
338
+ | `client.send_message(msg)` | 发送原始消息 |
339
+ | `client.send_text(to, text, context_token)` | 发送文本消息 |
340
+ | `client.push(to, text)` | 使用缓存 token 主动推送 |
341
+ | `client.get_config(user_id, context_token)` | 获取 bot 配置 |
342
+ | `client.send_typing(user_id, ticket, status)` | 发送输入状态 |
343
+ | `client.get_upload_url(req)` | 获取 CDN 上传地址 |
344
+ | `client.set_context_token(user_id, token)` | 手动缓存 context token |
345
+ | `client.get_context_token(user_id)` | 获取缓存的 context token |
346
+ | `client.stop()` | 停止监听循环 |
347
+ | `extract_text(msg)` | 提取消息中第一条文本 |
348
+ | `print_qrcode(url)` | 在终端打印二维码 |
349
+
350
+ ## 项目结构
351
+
352
+ ```
353
+ openilink-sdk-python/
354
+ ├── openilink/
355
+ │ ├── __init__.py # 包入口,统一导出
356
+ │ ├── client.py # 核心客户端,HTTP 请求封装和 API 方法
357
+ │ ├── types.py # 数据类型定义(dataclass + enum)
358
+ │ ├── auth.py # 扫码登录流程
359
+ │ ├── monitor.py # 长轮询消息监听
360
+ │ ├── errors.py # 异常类型
361
+ │ └── helpers.py # 工具函数
362
+ ├── examples/
363
+ │ └── echo_bot.py # Echo 机器人示例
364
+ ├── pyproject.toml
365
+ └── README.md
366
+ ```
367
+
368
+ ## 依赖
369
+
370
+ - Python >= 3.10
371
+ - [requests](https://pypi.org/project/requests/) >= 2.28
372
+ - [qrcode](https://pypi.org/project/qrcode/) >= 7.0
373
+
374
+ ## License
375
+
376
+ MIT