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.
- ides_adapter_weixin-0.1.0/.gitea/workflows/release.yaml +60 -0
- ides_adapter_weixin-0.1.0/.gitea/workflows/test-secret.yaml +17 -0
- ides_adapter_weixin-0.1.0/.gitignore +15 -0
- ides_adapter_weixin-0.1.0/PKG-INFO +78 -0
- ides_adapter_weixin-0.1.0/README.md +66 -0
- ides_adapter_weixin-0.1.0/pyproject.toml +30 -0
- ides_adapter_weixin-0.1.0/src/ides_adapter_weixin/__init__.py +10 -0
- ides_adapter_weixin-0.1.0/src/ides_adapter_weixin/server.py +217 -0
- ides_adapter_weixin-0.1.0/src/ides_adapter_weixin/weixin.py +161 -0
|
@@ -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,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()
|