ides_adapter_weixin 0.1.0__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.
@@ -0,0 +1,60 @@
1
+ name: Release to PyPI
2
+
3
+ # 手动触发发 PyPI:
4
+ # - workflow_dispatch:在 Actions 页面点 Run workflow,手动填 version(如 0.1.1)
5
+ # - 也可用 gitea release 事件手动触发(建 release 时自动跑,取 tag 版本)
6
+ # 用组织级 secret PIPY_TOKEN(已配置,组织下所有项目共享)发 PyPI。
7
+ # 第一次配置参考:hermes-rs-ides 的 release.yaml(2026-08-13 煊拍板模式沿用)
8
+
9
+ on:
10
+ workflow_dispatch:
11
+ inputs:
12
+ version:
13
+ description: "要发布的版本号(如 0.1.1;留空则读 pyproject 当前版本)"
14
+ required: false
15
+ type: string
16
+ default: ""
17
+ release:
18
+ types: [published]
19
+
20
+ jobs:
21
+ publish:
22
+ name: Build & Publish to PyPI
23
+ runs-on: ubuntu-latest
24
+ timeout-minutes: 15
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v4
28
+ with:
29
+ ref: ${{ gitea.ref }}
30
+
31
+ - name: Install uv
32
+ uses: astral-sh/setup-uv@v5
33
+ with:
34
+ enable-cache: true
35
+
36
+ - name: Show version
37
+ id: version
38
+ run: |
39
+ # 手动触发填了 version 就用填的,否则读 pyproject 当前版本
40
+ if [ -n "${{ inputs.version }}" ]; then
41
+ echo "VERSION=${{ inputs.version }}"
42
+ echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
43
+ else
44
+ UV_VER=$(uv run --quiet python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
45
+ echo "VERSION=$UV_VER"
46
+ echo "VERSION=$UV_VER" >> "$GITHUB_ENV"
47
+ fi
48
+
49
+ - name: Build
50
+ run: uv build
51
+
52
+ - name: Publish to PyPI
53
+ env:
54
+ UV_PUBLISH_TOKEN: ${{ secrets.PIPY_TOKEN }}
55
+ UV_PUBLISH_URL: https://upload.pypi.org/legacy/
56
+ run: uv publish
57
+
58
+ - name: Confirm published
59
+ run: |
60
+ echo "✅ 已发布 ides_adapter_weixin@${{ env.VERSION }} 到 PyPI"
@@ -0,0 +1,17 @@
1
+ name: test-secret
2
+ on:
3
+ - push
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - name: 检查 PIPY_TOKEN 是否存在(正确写法)
10
+ env:
11
+ PIPY_TOKEN: ${{ secrets.PIPY_TOKEN }}
12
+ run: |
13
+ if [ -n "$PIPY_TOKEN" ]; then
14
+ echo "✅ PIPY_TOKEN 存在(长度: ${#PIPY_TOKEN})"
15
+ else
16
+ echo "❌ PIPY_TOKEN 不存在"
17
+ fi
@@ -0,0 +1,15 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ env/
8
+
9
+ # uv
10
+ uv.lock
11
+
12
+ # Build
13
+ dist/
14
+ build/
15
+ *.egg-info/
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: ides_adapter_weixin
3
+ Version: 0.1.0
4
+ Summary: 微信(WeChat iLink Bot)adapter for IDES channel——通过 channel_ingest/emit 让微信消息驱动 IDES
5
+ Author-email: "tckm.rush" <rush@tachikoma.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: fastmcp>=2.0
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: segno>=1.6
11
+ Description-Content-Type: text/markdown
12
+
13
+ # ides_adapter_weixin
14
+
15
+ 微信(WeChat iLink Bot)adapter for IDES channel——让微信消息驱动 IDES agent。
16
+
17
+ 通过 fastmcp 暴露 3 个工具,对接 IDES 的 channel 机制(`mcp_<server>_<tool>` 命名)。
18
+
19
+ ## 工具
20
+
21
+ | 工具 | 作用 |
22
+ |------|------|
23
+ | `weixin_qrcode_login` | 登录:拿二维码 → 用户微信扫码 → 自动拿 token(后登录设计) |
24
+ | `channel_ingest` | 收消息:带 sync_buf 游标增量拉取(未登录返回空,零打扰) |
25
+ | `channel_emit` | 发消息:sendmessage 发文本 |
26
+
27
+ ## 后登录(lazy login)设计
28
+
29
+ MCP server **启动时不登录**。`channel_ingest` 被感知层轮询时:
30
+ - 有 token → 正常 getupdates
31
+ - 无 token → 返回 `{"messages": []}`(空),感知层 `is_empty()` 天然过滤,不打扰 agent
32
+
33
+ agent 想接微信时,主动调 `weixin_qrcode_login` 扫码,成功即存 token,之后收发光。
34
+
35
+ ## 使用
36
+
37
+ ### 作为 MCP server 接入 IDES
38
+
39
+ 在 `mcp/weixin.toml`(IDES_HOME 下)注册:
40
+
41
+ ```toml
42
+ [mcp_servers.weixin]
43
+ command = "uvx"
44
+ args = ["ides-adapter-weixin"]
45
+ ```
46
+
47
+ 或用本地开发版:
48
+
49
+ ```toml
50
+ [mcp_servers.weixin]
51
+ command = "uv"
52
+ args = ["run", "--with", ".", "ides-adapter-weixin"]
53
+ ```
54
+
55
+ ### 登录
56
+
57
+ ```bash
58
+ # 设置 IDES_HOME(默认取环境变量或当前目录)
59
+ export IDES_HOME=H:/RUSH
60
+
61
+ # 调用登录工具拿二维码,用微信扫
62
+ ```
63
+
64
+ ### .env 配置
65
+
66
+ ```bash
67
+ IDES_HOME=H:/RUSH # 存 token/游标的目录
68
+ WEIXIN_ACCOUNT_ID=default # 账号标识(多账号用)
69
+ ```
70
+
71
+ ## 实现参考
72
+
73
+ 核心 API 参考 hermes-agent 的 `gateway/platforms/weixin.py`(已验证的 iLink Bot API),
74
+ 按 IDES channel 契约精简重做,不依赖 hermes-agent 生态。
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,66 @@
1
+ # ides_adapter_weixin
2
+
3
+ 微信(WeChat iLink Bot)adapter for IDES channel——让微信消息驱动 IDES agent。
4
+
5
+ 通过 fastmcp 暴露 3 个工具,对接 IDES 的 channel 机制(`mcp_<server>_<tool>` 命名)。
6
+
7
+ ## 工具
8
+
9
+ | 工具 | 作用 |
10
+ |------|------|
11
+ | `weixin_qrcode_login` | 登录:拿二维码 → 用户微信扫码 → 自动拿 token(后登录设计) |
12
+ | `channel_ingest` | 收消息:带 sync_buf 游标增量拉取(未登录返回空,零打扰) |
13
+ | `channel_emit` | 发消息:sendmessage 发文本 |
14
+
15
+ ## 后登录(lazy login)设计
16
+
17
+ MCP server **启动时不登录**。`channel_ingest` 被感知层轮询时:
18
+ - 有 token → 正常 getupdates
19
+ - 无 token → 返回 `{"messages": []}`(空),感知层 `is_empty()` 天然过滤,不打扰 agent
20
+
21
+ agent 想接微信时,主动调 `weixin_qrcode_login` 扫码,成功即存 token,之后收发光。
22
+
23
+ ## 使用
24
+
25
+ ### 作为 MCP server 接入 IDES
26
+
27
+ 在 `mcp/weixin.toml`(IDES_HOME 下)注册:
28
+
29
+ ```toml
30
+ [mcp_servers.weixin]
31
+ command = "uvx"
32
+ args = ["ides-adapter-weixin"]
33
+ ```
34
+
35
+ 或用本地开发版:
36
+
37
+ ```toml
38
+ [mcp_servers.weixin]
39
+ command = "uv"
40
+ args = ["run", "--with", ".", "ides-adapter-weixin"]
41
+ ```
42
+
43
+ ### 登录
44
+
45
+ ```bash
46
+ # 设置 IDES_HOME(默认取环境变量或当前目录)
47
+ export IDES_HOME=H:/RUSH
48
+
49
+ # 调用登录工具拿二维码,用微信扫
50
+ ```
51
+
52
+ ### .env 配置
53
+
54
+ ```bash
55
+ IDES_HOME=H:/RUSH # 存 token/游标的目录
56
+ WEIXIN_ACCOUNT_ID=default # 账号标识(多账号用)
57
+ ```
58
+
59
+ ## 实现参考
60
+
61
+ 核心 API 参考 hermes-agent 的 `gateway/platforms/weixin.py`(已验证的 iLink Bot API),
62
+ 按 IDES channel 契约精简重做,不依赖 hermes-agent 生态。
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ides_adapter_weixin"
7
+ version = "0.1.0"
8
+ description = "微信(WeChat iLink Bot)adapter for IDES channel——通过 channel_ingest/emit 让微信消息驱动 IDES"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "tckm.rush", email = "rush@tachikoma.dev" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "fastmcp>=2.0",
15
+ "httpx>=0.27",
16
+ "segno>=1.6",
17
+ ]
18
+
19
+ [project.scripts]
20
+ ides-adapter-weixin = "ides_adapter_weixin.server:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/ides_adapter_weixin"]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+ target-version = "py310"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,10 @@
1
+ """IDES channel 微信 adapter。
2
+
3
+ 用 fastmcp 暴露 channel_ingest/channel_emit 让微信消息驱动 IDES(后登录设计)。
4
+ """
5
+ from ides_adapter_weixin.server import build_server
6
+ from ides_adapter_weixin.weixin import WeixinAdapter
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["build_server", "WeixinAdapter", "__version__"]
@@ -0,0 +1,217 @@
1
+ """IDES channel 的微信 adapter——fastmcp server。
2
+
3
+ 暴露 3 个工具:
4
+ 1. weixin_qrcode_login:登录。拿二维码(agent 自行决定展示方式给用户扫),轮询扫码状态,
5
+ 成功拿 token 存到本地(后登录,不用启动时登录)。
6
+ 2. channel_ingest:收消息。有 token → getupdates 增量拉;无 token → 返回空 messages(零打扰)。
7
+ 3. channel_emit:发消息。sendmessage 发文本。
8
+
9
+ MCP 通过 stdio 启动本进程(command=uvx ides-adapter-weixin 或 uv run --with ... python server.py)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from fastmcp import FastMCP
20
+
21
+ from ides_adapter_weixin.weixin import WeixinAdapter, _account_file
22
+
23
+
24
+ def _resolve_home() -> str:
25
+ """解析 IDES home 目录。优先 IDES_HOME env,否则当前目录。"""
26
+ return os.environ.get("IDES_HOME") or os.environ.get("HOME") or str(Path.cwd())
27
+
28
+
29
+ def _resolve_account_id() -> str:
30
+ return os.environ.get("WEIXIN_ACCOUNT_ID", "default")
31
+
32
+
33
+ def _token_file() -> Path:
34
+ return _account_file(_resolve_home(), _resolve_account_id())
35
+
36
+
37
+ def _load_token() -> Optional[str]:
38
+ """读取本地 token。不存在返回 None。"""
39
+ f = _token_file()
40
+ if not f.exists():
41
+ return None
42
+ try:
43
+ return json.loads(f.read_text(encoding="utf-8")).get("token")
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def _save_token(token: str, user_id: str = "") -> None:
49
+ """保存 token 到本地账号文件。"""
50
+ f = _token_file()
51
+ f.parent.mkdir(parents=True, exist_ok=True)
52
+ f.write_text(json.dumps({"token": token, "user_id": user_id}, ensure_ascii=False), encoding="utf-8")
53
+
54
+
55
+ def _strip_wechat_uin(msgs: list) -> list:
56
+ """把 getupdates 的 msgs 转成 channel 契约的 messages。
57
+
58
+ iLink 的 msg 结构里 from/to 的表意取决于视角。这里简化为:
59
+ - from:发给我们的用户(拿原始 msg 里的 from_user_id 或 fromNickname)
60
+ - text:文本(从 text_item 里抠)
61
+ """
62
+ out = []
63
+ for m in msgs:
64
+ item_list = m.get("item_list", [])
65
+ text = ""
66
+ for item in item_list:
67
+ text_item = item.get("text_item") or {}
68
+ if text_item.get("text"):
69
+ text = text_item["text"]
70
+ break
71
+ if not text:
72
+ continue
73
+ msg_id = m.get("msg_id") or m.get("client_msg_id") or str(id(m))
74
+ from_user = m.get("from_user_id") or m.get("fromNickname") or "微信"
75
+ to_user = m.get("to_user_id") or ""
76
+ out.append({
77
+ "msg_id": msg_id,
78
+ "from": from_user,
79
+ "text": text,
80
+ "timestamp": int(m.get("timestamp") or m.get("msg_time") or 0),
81
+ })
82
+ return out
83
+
84
+
85
+ def build_server() -> FastMCP:
86
+ adapter = WeixinAdapter(_resolve_home(), _resolve_account_id())
87
+ mcp = FastMCP("weixin")
88
+
89
+ @mcp.tool()
90
+ def weixin_qrcode_login(poll_seconds: float = 60.0) -> dict:
91
+ """获取微信登录二维码,供用户扫码。
92
+
93
+ agent 拿到后自行决定如何展示给用户(转成图片 / 直接给 liteapp URL / 命令行渲染)。
94
+ 二维码已自动渲染落盘到 <IDES_HOME>/weixin/qrcode.png,agent 可直接展示。
95
+ 本工具会同步轮询扫码状态(默认 60s),直到 confirmed 拿到 token 自动保存。
96
+
97
+ 返回 {
98
+ "qrcode": "<hex token>",
99
+ "qrcode_url": "<完整 liteapp URL,微信要扫这个>",
100
+ "qr_scan_data": "<实际可扫数据>",
101
+ "qr_image_path": "<落盘的二维码图片路径>",
102
+ "status": "wait|scaned|scaned_but_redirect|confirmed|expired|error",
103
+ "account_id": "<登录成功时返回>",
104
+ "token": "<登录成功时返回(已自动保存)>"
105
+ }
106
+ """
107
+ try:
108
+ qr = adapter.fetch_qrcode()
109
+ qr_scan_data = qr["qr_scan_data"]
110
+
111
+ # 渲染二维码落盘(agent 可直接展示)
112
+ qr_img_path = Path(_resolve_home()) / "weixin" / "qrcode.png"
113
+ qr_img_path.parent.mkdir(parents=True, exist_ok=True)
114
+ try:
115
+ import segno
116
+ segno.make(qr_scan_data, error="m").save(
117
+ str(qr_img_path), scale=8, border=2
118
+ )
119
+ except Exception:
120
+ pass # segno 不可用则跳过渲染,仍可用 qrcode_url 展示
121
+
122
+ result = {
123
+ "qrcode": qr["qrcode"],
124
+ "qrcode_url": qr["qrcode_url"],
125
+ "qr_scan_data": qr_scan_data,
126
+ "qr_image_path": str(qr_img_path),
127
+ "status": "wait",
128
+ }
129
+
130
+ # 同步轮询扫码状态(直到 confirmed / expired / 超时)
131
+ deadline = time.time() + poll_seconds
132
+ while time.time() < deadline:
133
+ st = adapter.poll_qr_status(qr["qrcode"])
134
+ status = st.get("status")
135
+ result["status"] = status
136
+ if status == "confirmed":
137
+ token = st.get("bot_token")
138
+ account_id = st.get("ilink_bot_id") or _resolve_account_id()
139
+ if token:
140
+ _save_token(token, st.get("ilink_user_id", ""))
141
+ result["token"] = token
142
+ result["account_id"] = account_id
143
+ result["base_url"] = st.get("baseurl") or ""
144
+ result["status"] = "ok"
145
+ break
146
+ elif status == "expired":
147
+ break
148
+ time.sleep(1)
149
+
150
+ return result
151
+ except Exception as e:
152
+ return {"status": "error", "message": str(e)}
153
+
154
+ @mcp.tool()
155
+ def channel_ingest() -> dict:
156
+ """channel_ingest: 拉取该 adapter 积累给 IDES 的新消息(增量)。
157
+
158
+ 返回 ChannelIngestResponse schema:
159
+ { "messages": [ { "msg_id": "...", "from": "...", "text": "...", "timestamp": 123 } ] }
160
+ 未登录时返回 {"messages": []}(零打扰)。
161
+ """
162
+ token = _load_token()
163
+ if not token:
164
+ # 未登录 → 返回空 messages,让感知层的 is_empty() 天然过滤,不打扰 agent
165
+ return {"messages": []}
166
+
167
+ # 读 sync_buf 游标
168
+ buf_file = Path(_resolve_home()) / "weixin" / "sync_buf" / f"{_resolve_account_id()}.txt"
169
+ sync_buf = ""
170
+ if buf_file.exists():
171
+ sync_buf = buf_file.read_text(encoding="utf-8").strip()
172
+
173
+ try:
174
+ updates = adapter.get_updates(token, sync_buf)
175
+ except Exception as e:
176
+ # 网络错误也别当消息投递(避免轰炸),返回空
177
+ logger = __import__("logging").getLogger(__name__)
178
+ logger.warning("channel_ingest: getupdates 失败: %s", e)
179
+ return {"messages": []}
180
+
181
+ # 保存新游标
182
+ new_buf = updates.get("get_updates_buf", sync_buf)
183
+ if new_buf != sync_buf:
184
+ buf_file.parent.mkdir(parents=True, exist_ok=True)
185
+ buf_file.write_text(new_buf, encoding="utf-8")
186
+
187
+ messages = _strip_wechat_uin(updates.get("msgs", []))
188
+ return {"messages": messages}
189
+
190
+ @mcp.tool()
191
+ def channel_emit(text: str, to_user_id: str) -> str:
192
+ """channel_emit: agent 回信,发给微信用户。
193
+
194
+ to_user_id: 收件人(对应消息里的 from,或用户指定)。
195
+ 返回发送结果。
196
+ """
197
+ token = _load_token()
198
+ if not token:
199
+ return "未登录,请先调用 weixin_qrcode_login"
200
+
201
+ client_id = str(int(time.time() * 1000))
202
+ try:
203
+ result = adapter.send_message(token, to_user_id, text, client_id)
204
+ return json.dumps(result, ensure_ascii=False)
205
+ except Exception as e:
206
+ return f"发送失败: {e}"
207
+
208
+ return mcp
209
+
210
+
211
+ def main() -> None:
212
+ mcp = build_server()
213
+ mcp.run()
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
@@ -0,0 +1,161 @@
1
+ """微信 iLink Bot API 客户端——IDES channel adapter 的核心。
2
+
3
+ 这个模块封装微信 iLink Bot HTTP API 的三个动作:
4
+ 1. 登录(qr_login):拿二维码 → 用户微信扫码 → 轮询换 token
5
+ 2. 收消息(get_updates):带 sync_buf 游标增量拉取
6
+ 3. 发消息(send_message):sendmessage 发文本
7
+
8
+ 参考 hermes-agent 的 gateway/platforms/weixin.py(已验证的核心 API),
9
+ 这里按 IDES channel 契约精简重做,不依赖 hermes-agent 生态。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import random
16
+ import secrets
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+ import httpx
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ---- iLink Bot API 常量 ----
26
+ ILINK_BASE_URL = "https://ilinkai.weixin.qq.com"
27
+ EP_GET_BOT_QR = "ilink/bot/get_bot_qrcode"
28
+ EP_GET_QR_STATUS = "ilink/bot/get_qrcode_status"
29
+ EP_GET_UPDATES = "ilink/bot/getupdates"
30
+ EP_SEND_MESSAGE = "ilink/bot/sendmessage"
31
+
32
+ ILINK_APP_ID = "bot"
33
+ ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0
34
+
35
+ CHANNEL_VERSION = "2.2.0"
36
+
37
+ MSG_TYPE_BOT = 2
38
+ ITEM_TEXT = 1
39
+ MSG_STATE_FINISH = 2
40
+
41
+ LONG_POLL_TIMEOUT_MS = 35_000
42
+ API_TIMEOUT_MS = 15_000
43
+ QR_TIMEOUT_MS = 35_000
44
+
45
+ # 默认账号文件路径:{home}/weixin/accounts/{account_id}.json
46
+ def _account_file(home: str, account_id: str) -> Path:
47
+ return Path(home) / "weixin" / "accounts" / f"{account_id}.json"
48
+
49
+
50
+ def _random_wechat_uin() -> str:
51
+ return str(random.randint(1, 0xFFFFFFFF)).zfill(10)
52
+
53
+
54
+ def _base_info() -> dict:
55
+ return {"channel_version": CHANNEL_VERSION}
56
+
57
+
58
+ def _auth_headers(token: Optional[str], body: str) -> dict:
59
+ """构造 iLink bot 请求头(token + X-WECHAT-UIN + app-id)。"""
60
+ headers = {
61
+ "Content-Type": "application/json",
62
+ "AuthorizationType": "ilink_bot_token",
63
+ "Content-Length": str(len(body.encode("utf-8"))),
64
+ "X-WECHAT-UIN": _random_wechat_uin(),
65
+ "iLink-App-Id": ILINK_APP_ID,
66
+ "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
67
+ }
68
+ if token:
69
+ headers["Authorization"] = f"Bearer {token}"
70
+ return headers
71
+
72
+
73
+ class WeixinAdapter:
74
+ """微信 iLink Bot 客户端——后登录(lazy)设计。
75
+
76
+ MCP server 启动时不登录。只有 channel_ingest 被调用时才检查 token:
77
+ - 有 token → 正常 getupdates
78
+ - 无 token → 由 server 层返回空 messages(零打扰)
79
+ """
80
+
81
+ def __init__(self, home: str, account_id: str, base_url: str = ILINK_BASE_URL) -> None:
82
+ self.home = home
83
+ self.account_id = account_id
84
+ self.base_url = base_url.rstrip("/")
85
+ self._client = httpx.Client(timeout=API_TIMEOUT_MS / 1000)
86
+
87
+ # ---- 登录:拿二维码 ----
88
+ def fetch_qrcode(self) -> dict:
89
+ """调 get_bot_qrcode,返回 {"qrcode","qrcode_img_content"}。"""
90
+ url = f"{self.base_url}/{EP_GET_BOT_QR}?bot_type=3"
91
+ resp = self._client.get(url)
92
+ resp.raise_for_status()
93
+ data = resp.json()
94
+ qrcode_value = str(data.get("qrcode") or "")
95
+ qrcode_url = str(data.get("qrcode_img_content") or "")
96
+ if not qrcode_value:
97
+ raise RuntimeError("微信返回的二维码缺少 qrcode 字段")
98
+ # 微信要扫完整的 liteapp URL,不是 raw hex
99
+ qr_scan_data = qrcode_url if qrcode_url else qrcode_value
100
+ return {"qrcode": qrcode_value, "qrcode_url": qrcode_url, "qr_scan_data": qr_scan_data}
101
+
102
+ def poll_qr_status(self, qrcode: str) -> dict:
103
+ """轮询扫码状态。返回 status + redirect_host + 凭据字段。
104
+
105
+ status == "confirmed" 时,凭据在响应顶层字段(参考 hermes-agent):
106
+ ilink_bot_id / bot_token / baseurl / ilink_user_id。
107
+ """
108
+ url = f"{self.base_url}/{EP_GET_QR_STATUS}?qrcode={qrcode}"
109
+ resp = self._client.get(url)
110
+ resp.raise_for_status()
111
+ data = resp.json()
112
+ return {
113
+ "status": str(data.get("status") or "wait"),
114
+ "redirect_host": str(data.get("redirect_host") or ""),
115
+ "credential": data.get("credential"),
116
+ # confirmed 时顶层凭据字段(最关键)
117
+ "ilink_bot_id": str(data.get("ilink_bot_id") or ""),
118
+ "bot_token": str(data.get("bot_token") or ""),
119
+ "baseurl": str(data.get("baseurl") or ""),
120
+ "ilink_user_id": str(data.get("ilink_user_id") or ""),
121
+ }
122
+
123
+ # ---- 收消息 ----
124
+ def get_updates(self, token: str, sync_buf: str) -> dict:
125
+ """调 getupdates,返回 {"msgs":[...], "get_updates_buf": new_buf}。
126
+
127
+ getupdates 是长轮询(LONG_POLL_TIMEOUT_MS=35s),所以必须用超过 35s 的 timeout。
128
+ """
129
+ body = json.dumps({"get_updates_buf": sync_buf, "base_info": _base_info()})
130
+ url = f"{self.base_url}/{EP_GET_UPDATES}"
131
+ # 长轮询超时 + 余量;单独一个 client 避免影响其他调用
132
+ with httpx.Client(timeout=(LONG_POLL_TIMEOUT_MS + 10_000) / 1000) as long_client:
133
+ resp = long_client.post(url, content=body, headers=_auth_headers(token, body))
134
+ resp.raise_for_status()
135
+ data = resp.json()
136
+ return {
137
+ "msgs": data.get("msgs", []),
138
+ "get_updates_buf": data.get("get_updates_buf", sync_buf),
139
+ }
140
+
141
+ # ---- 发消息 ----
142
+ def send_message(self, token: str, to: str, text: str, client_id: str) -> dict:
143
+ """调 sendmessage 发文本,返回原始响应。"""
144
+ if not text or not text.strip():
145
+ raise ValueError("send_message: text 不能为空")
146
+ message = {
147
+ "from_user_id": "",
148
+ "to_user_id": to,
149
+ "client_id": client_id,
150
+ "message_type": MSG_TYPE_BOT,
151
+ "message_state": MSG_STATE_FINISH,
152
+ "item_list": [{"type": ITEM_TEXT, "text_item": {"text": text}}],
153
+ }
154
+ body = json.dumps({"msg": message, "base_info": _base_info()})
155
+ url = f"{self.base_url}/{EP_SEND_MESSAGE}"
156
+ resp = self._client.post(url, content=body, headers=_auth_headers(token, body))
157
+ resp.raise_for_status()
158
+ return resp.json()
159
+
160
+ def close(self) -> None:
161
+ self._client.close()