easyWechatpy 0.0.2__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.
- easyWechatpy/__init__.py +2 -0
- easyWechatpy/ilink/__init__.py +4 -0
- easyWechatpy/ilink/__main__.py +4 -0
- easyWechatpy/ilink/api.py +291 -0
- easyWechatpy/ilink/cli.py +577 -0
- easyWechatpy/ilink/state.py +140 -0
- easyWechatpy/weChatRobot.py +62 -0
- easywechatpy-0.0.2.dist-info/METADATA +31 -0
- easywechatpy-0.0.2.dist-info/RECORD +12 -0
- easywechatpy-0.0.2.dist-info/WHEEL +5 -0
- easywechatpy-0.0.2.dist-info/entry_points.txt +2 -0
- easywechatpy-0.0.2.dist-info/top_level.txt +1 -0
easyWechatpy/__init__.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
微信 iLink Bot API 客户端 —— 纯 HTTP/JSON 协议实现。
|
|
4
|
+
参考: @tencent-weixin/openclaw-weixin (官方插件) 及 wechat-claw-cli (社区实现)。
|
|
5
|
+
|
|
6
|
+
核心流程:
|
|
7
|
+
1. login → get_bot_qrcode → 轮询 get_qrcode_status → 拿到 bot_token
|
|
8
|
+
2. poll → getupdates 长轮询,游标 get_updates_buf 循环
|
|
9
|
+
3. send-text → sendmessage,必须带回 context_token
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import time
|
|
18
|
+
from base64 import b64encode
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from .state import WechatState, StateStore
|
|
25
|
+
|
|
26
|
+
# ---- 版本 ----
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
# ---- 常量 ----
|
|
30
|
+
SESSION_TIMEOUT_ERRCODE = -14
|
|
31
|
+
DEFAULT_API_TIMEOUT = 15
|
|
32
|
+
DEFAULT_LONGPOLL_TIMEOUT = 35
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_base_url(url: str) -> str:
|
|
36
|
+
s = url.strip()
|
|
37
|
+
return s if s.endswith("/") else f"{s}/"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def random_wechat_uin() -> str:
|
|
41
|
+
raw = str(secrets.randbits(32)).encode("utf-8")
|
|
42
|
+
return b64encode(raw).decode("ascii")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_base_info() -> dict:
|
|
46
|
+
return {"channel_version": __version__}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---- 异常 ----
|
|
50
|
+
|
|
51
|
+
class ApiError(Exception):
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
status_code: int | None = None,
|
|
56
|
+
ret: int | None = None,
|
|
57
|
+
errcode: int | None = None,
|
|
58
|
+
payload: dict | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.status_code = status_code
|
|
62
|
+
self.ret = ret
|
|
63
|
+
self.errcode = errcode
|
|
64
|
+
self.payload = payload
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- 核心 API ----
|
|
68
|
+
|
|
69
|
+
class WechatILinkAPI:
|
|
70
|
+
"""微信 iLink Bot API 客户端。
|
|
71
|
+
|
|
72
|
+
用法:
|
|
73
|
+
api = WechatILinkAPI(base_url="https://ilinkai.weixin.qq.com", token="...")
|
|
74
|
+
# 或从本地状态恢复
|
|
75
|
+
store = StateStore()
|
|
76
|
+
state = store.load_account()
|
|
77
|
+
api = WechatILinkAPI(base_url=state.base_url, token=state.token)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
base_url: str,
|
|
83
|
+
token: str | None = None,
|
|
84
|
+
route_tag: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.base_url = normalize_base_url(base_url)
|
|
87
|
+
self.token = token
|
|
88
|
+
self.route_tag = route_tag
|
|
89
|
+
self._session = requests.Session()
|
|
90
|
+
|
|
91
|
+
# -- 内部工具 --
|
|
92
|
+
|
|
93
|
+
def _url(self, endpoint: str, params: dict | None = None) -> str:
|
|
94
|
+
url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
|
|
95
|
+
if params:
|
|
96
|
+
from urllib.parse import urlencode
|
|
97
|
+
qs = urlencode({k: v for k, v in params.items() if v is not None})
|
|
98
|
+
if qs:
|
|
99
|
+
url = f"{url}?{qs}"
|
|
100
|
+
return url
|
|
101
|
+
|
|
102
|
+
def _headers(self, body_bytes: bytes | None = None) -> dict:
|
|
103
|
+
h = {
|
|
104
|
+
"Accept": "application/json",
|
|
105
|
+
"X-WECHAT-UIN": random_wechat_uin(),
|
|
106
|
+
}
|
|
107
|
+
if body_bytes is not None:
|
|
108
|
+
h["Content-Type"] = "application/json"
|
|
109
|
+
h["AuthorizationType"] = "ilink_bot_token"
|
|
110
|
+
if self.token:
|
|
111
|
+
h["Authorization"] = f"Bearer {self.token}"
|
|
112
|
+
if self.route_tag:
|
|
113
|
+
h["SKRouteTag"] = self.route_tag
|
|
114
|
+
return h
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _parse_json(raw: bytes, endpoint: str) -> dict:
|
|
118
|
+
text = raw.decode("utf-8").strip()
|
|
119
|
+
if not text:
|
|
120
|
+
return {}
|
|
121
|
+
try:
|
|
122
|
+
obj = json.loads(text)
|
|
123
|
+
except json.JSONDecodeError as e:
|
|
124
|
+
raise ApiError(f"{endpoint} 返回非 JSON: {e}")
|
|
125
|
+
if not isinstance(obj, dict):
|
|
126
|
+
raise ApiError(f"{endpoint} 返回格式异常")
|
|
127
|
+
return obj
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _coerce_int(v: Any) -> int | None:
|
|
131
|
+
return v if isinstance(v, int) else None
|
|
132
|
+
|
|
133
|
+
def _raise_on_error(self, endpoint: str, payload: dict) -> None:
|
|
134
|
+
ret = self._coerce_int(payload.get("ret"))
|
|
135
|
+
errcode = self._coerce_int(payload.get("errcode"))
|
|
136
|
+
if ret and ret != 0:
|
|
137
|
+
raise ApiError(f"{endpoint} ret={ret}", ret=ret, errcode=errcode, payload=payload)
|
|
138
|
+
if errcode and errcode != 0:
|
|
139
|
+
raise ApiError(f"{endpoint} errcode={errcode}", ret=ret, errcode=errcode, payload=payload)
|
|
140
|
+
|
|
141
|
+
def _request(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
endpoint: str,
|
|
145
|
+
params: dict | None = None,
|
|
146
|
+
body: dict | None = None,
|
|
147
|
+
timeout: int = DEFAULT_API_TIMEOUT,
|
|
148
|
+
) -> dict:
|
|
149
|
+
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None
|
|
150
|
+
url = self._url(endpoint, params)
|
|
151
|
+
headers = self._headers(body_bytes)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
resp = self._session.request(
|
|
155
|
+
method=method.upper(),
|
|
156
|
+
url=url,
|
|
157
|
+
data=body_bytes,
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=max(1, timeout),
|
|
160
|
+
)
|
|
161
|
+
except requests.Timeout:
|
|
162
|
+
raise
|
|
163
|
+
except requests.ConnectionError as e:
|
|
164
|
+
raise ApiError(f"{endpoint} 网络错误: {e}")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
payload = self._parse_json(resp.content, endpoint)
|
|
168
|
+
except Exception:
|
|
169
|
+
payload = {}
|
|
170
|
+
|
|
171
|
+
if not resp.ok:
|
|
172
|
+
raise ApiError(
|
|
173
|
+
f"{endpoint} HTTP {resp.status_code}",
|
|
174
|
+
status_code=resp.status_code,
|
|
175
|
+
payload=payload,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return payload
|
|
179
|
+
|
|
180
|
+
# === 登录 ===
|
|
181
|
+
|
|
182
|
+
def get_bot_qrcode(self, bot_type: str = "3") -> dict:
|
|
183
|
+
"""获取登录二维码。
|
|
184
|
+
|
|
185
|
+
Returns: {"qrcode": "...", "qrcode_img_content": "https://..."}
|
|
186
|
+
"""
|
|
187
|
+
payload = self._request(
|
|
188
|
+
"GET",
|
|
189
|
+
"ilink/bot/get_bot_qrcode",
|
|
190
|
+
params={"bot_type": bot_type},
|
|
191
|
+
timeout=DEFAULT_API_TIMEOUT,
|
|
192
|
+
)
|
|
193
|
+
self._raise_on_error("get_bot_qrcode", payload)
|
|
194
|
+
return payload
|
|
195
|
+
|
|
196
|
+
def get_qrcode_status(self, qrcode: str, timeout: int = DEFAULT_LONGPOLL_TIMEOUT) -> dict:
|
|
197
|
+
"""长轮询扫码状态。
|
|
198
|
+
|
|
199
|
+
Returns: {"status": "wait|scaned|confirmed|expired|need_verifycode|...", "bot_token": "...", ...}
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
payload = self._request(
|
|
203
|
+
"GET",
|
|
204
|
+
"ilink/bot/get_qrcode_status",
|
|
205
|
+
params={"qrcode": qrcode},
|
|
206
|
+
timeout=timeout + 5,
|
|
207
|
+
)
|
|
208
|
+
except (requests.Timeout, requests.exceptions.ReadTimeout):
|
|
209
|
+
return {"status": "wait"}
|
|
210
|
+
self._raise_on_error("get_qrcode_status", payload)
|
|
211
|
+
return payload
|
|
212
|
+
|
|
213
|
+
# === 消息 ===
|
|
214
|
+
|
|
215
|
+
def get_updates(self, get_updates_buf: str = "", timeout: int = DEFAULT_LONGPOLL_TIMEOUT) -> dict:
|
|
216
|
+
"""长轮询拉取新消息。
|
|
217
|
+
|
|
218
|
+
Returns: {"ret": 0, "msgs": [...], "get_updates_buf": "..."}
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
payload = self._request(
|
|
222
|
+
"POST",
|
|
223
|
+
"ilink/bot/getupdates",
|
|
224
|
+
body={
|
|
225
|
+
"get_updates_buf": get_updates_buf,
|
|
226
|
+
"base_info": build_base_info(),
|
|
227
|
+
},
|
|
228
|
+
timeout=timeout + 5,
|
|
229
|
+
)
|
|
230
|
+
except (requests.Timeout, requests.exceptions.ReadTimeout):
|
|
231
|
+
return {"ret": 0, "msgs": [], "get_updates_buf": get_updates_buf}
|
|
232
|
+
self._raise_on_error("getupdates", payload)
|
|
233
|
+
return payload
|
|
234
|
+
|
|
235
|
+
def send_text_message(
|
|
236
|
+
self,
|
|
237
|
+
to_user_id: str,
|
|
238
|
+
text: str,
|
|
239
|
+
context_token: str | None = None,
|
|
240
|
+
) -> dict:
|
|
241
|
+
"""发送文本消息。
|
|
242
|
+
|
|
243
|
+
必须携带 context_token 才能正确维持对话上下文。
|
|
244
|
+
"""
|
|
245
|
+
import uuid
|
|
246
|
+
client_id = f"easywechatpy-{uuid.uuid4().hex[:12]}"
|
|
247
|
+
msg: dict = {
|
|
248
|
+
"from_user_id": "",
|
|
249
|
+
"to_user_id": to_user_id,
|
|
250
|
+
"client_id": client_id,
|
|
251
|
+
"message_type": 2, # BOT
|
|
252
|
+
"message_state": 2, # FINISH
|
|
253
|
+
"item_list": [
|
|
254
|
+
{"type": 1, "text_item": {"text": text}}
|
|
255
|
+
],
|
|
256
|
+
}
|
|
257
|
+
if context_token:
|
|
258
|
+
msg["context_token"] = context_token
|
|
259
|
+
|
|
260
|
+
body = {"msg": msg, "base_info": build_base_info()}
|
|
261
|
+
payload = self._request(
|
|
262
|
+
"POST",
|
|
263
|
+
"ilink/bot/sendmessage",
|
|
264
|
+
body=body,
|
|
265
|
+
timeout=DEFAULT_API_TIMEOUT,
|
|
266
|
+
)
|
|
267
|
+
if payload:
|
|
268
|
+
self._raise_on_error("sendmessage", payload)
|
|
269
|
+
return {"client_id": client_id, "response": payload}
|
|
270
|
+
|
|
271
|
+
def get_config(self, ilink_user_id: str, context_token: str | None = None) -> dict:
|
|
272
|
+
"""拉取 bot 配置(含 typing_ticket)。"""
|
|
273
|
+
body: dict = {"ilink_user_id": ilink_user_id, "base_info": build_base_info()}
|
|
274
|
+
if context_token:
|
|
275
|
+
body["context_token"] = context_token
|
|
276
|
+
payload = self._request("POST", "ilink/bot/getconfig", body=body, timeout=DEFAULT_API_TIMEOUT)
|
|
277
|
+
self._raise_on_error("getconfig", payload)
|
|
278
|
+
return payload
|
|
279
|
+
|
|
280
|
+
def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> dict:
|
|
281
|
+
"""发送「正在输入」状态 (status=1) 或取消 (status=2)。"""
|
|
282
|
+
body = {
|
|
283
|
+
"ilink_user_id": ilink_user_id,
|
|
284
|
+
"typing_ticket": typing_ticket,
|
|
285
|
+
"status": status,
|
|
286
|
+
"base_info": build_base_info(),
|
|
287
|
+
}
|
|
288
|
+
payload = self._request("POST", "ilink/bot/sendtyping", body=body, timeout=DEFAULT_API_TIMEOUT)
|
|
289
|
+
if payload:
|
|
290
|
+
self._raise_on_error("sendtyping", payload)
|
|
291
|
+
return payload
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
微信 iLink Bot CLI —— 命令行登录、收发消息、桥接 Agent。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python -m easywechatpy.ilink login # 扫码登录
|
|
7
|
+
python -m easywechatpy.ilink whoami # 查看状态
|
|
8
|
+
python -m easywechatpy.ilink poll # 长轮询收消息
|
|
9
|
+
python -m easywechatpy.ilink send-text ... # 发送文本
|
|
10
|
+
python -m easywechatpy.ilink reply-text ... # 带上下文回复
|
|
11
|
+
python -m easywechatpy.ilink bridge ... # 桥接到 Agent
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .api import (
|
|
27
|
+
WechatILinkAPI,
|
|
28
|
+
ApiError,
|
|
29
|
+
DEFAULT_LONGPOLL_TIMEOUT,
|
|
30
|
+
SESSION_TIMEOUT_ERRCODE,
|
|
31
|
+
)
|
|
32
|
+
from .state import (
|
|
33
|
+
WechatState,
|
|
34
|
+
PendingLogin,
|
|
35
|
+
StateStore,
|
|
36
|
+
DEFAULT_BASE_URL,
|
|
37
|
+
DEFAULT_BOT_TYPE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---- 环境变量 ----
|
|
42
|
+
|
|
43
|
+
def _env_route_tag() -> str | None:
|
|
44
|
+
return (os.environ.get("EASYWECHATPY_ROUTE_TAG") or "").strip() or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- 工具 ----
|
|
48
|
+
|
|
49
|
+
def _print_qr_terminal(content: str) -> None:
|
|
50
|
+
"""在终端打印二维码(不依赖额外库)。"""
|
|
51
|
+
try:
|
|
52
|
+
import qrcode # type: ignore
|
|
53
|
+
qr = qrcode.QRCode(border=4)
|
|
54
|
+
qr.add_data(content)
|
|
55
|
+
qr.make(fit=True)
|
|
56
|
+
buf = io.StringIO()
|
|
57
|
+
qr.print_ascii(out=buf, tty=False)
|
|
58
|
+
rendered = buf.getvalue().strip()
|
|
59
|
+
if rendered:
|
|
60
|
+
print("请用微信扫描以下二维码:")
|
|
61
|
+
print(rendered)
|
|
62
|
+
return
|
|
63
|
+
except ImportError:
|
|
64
|
+
pass
|
|
65
|
+
print(f"扫码链接: {content}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_api_from_state(store: StateStore) -> WechatILinkAPI:
|
|
69
|
+
state = store.load_account()
|
|
70
|
+
if not state.token:
|
|
71
|
+
raise ValueError("未登录,请先运行 login")
|
|
72
|
+
return WechatILinkAPI(
|
|
73
|
+
base_url=state.base_url,
|
|
74
|
+
token=state.token,
|
|
75
|
+
route_tag=state.route_tag or _env_route_tag(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_user_id(explicit: str | None, state: WechatState) -> str:
|
|
80
|
+
uid = (explicit or state.user_id or "").strip()
|
|
81
|
+
if not uid:
|
|
82
|
+
raise ValueError("无法确定目标用户 ID,请先登录或传入 --to-user-id")
|
|
83
|
+
return uid
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_text(message: dict) -> str:
|
|
87
|
+
items = message.get("item_list") or []
|
|
88
|
+
parts = []
|
|
89
|
+
for item in items:
|
|
90
|
+
if not isinstance(item, dict):
|
|
91
|
+
continue
|
|
92
|
+
if item.get("type") == 1:
|
|
93
|
+
text = str((item.get("text_item") or {}).get("text") or "").strip()
|
|
94
|
+
if text:
|
|
95
|
+
parts.append(text)
|
|
96
|
+
return "\n".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- 命令实现 ----
|
|
100
|
+
|
|
101
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
102
|
+
store = StateStore(args.state_dir)
|
|
103
|
+
state = store.load_account()
|
|
104
|
+
|
|
105
|
+
base_url = (args.base_url or state.base_url or DEFAULT_BASE_URL).strip()
|
|
106
|
+
route_tag = (args.route_tag or state.route_tag or _env_route_tag() or "").strip() or None
|
|
107
|
+
api = WechatILinkAPI(base_url=base_url, route_tag=route_tag)
|
|
108
|
+
|
|
109
|
+
# 1. 获取二维码
|
|
110
|
+
resp = api.get_bot_qrcode(bot_type=args.bot_type)
|
|
111
|
+
qrcode = str(resp.get("qrcode") or "").strip()
|
|
112
|
+
qrcode_url = str(resp.get("qrcode_img_content") or "").strip()
|
|
113
|
+
if not qrcode or not qrcode_url:
|
|
114
|
+
print("获取二维码失败:返回缺少字段", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
state.base_url = base_url
|
|
118
|
+
state.route_tag = route_tag
|
|
119
|
+
state.pending_login = PendingLogin(
|
|
120
|
+
qrcode=qrcode,
|
|
121
|
+
qrcode_url=qrcode_url,
|
|
122
|
+
bot_type=args.bot_type,
|
|
123
|
+
started_at=datetime.now().isoformat(),
|
|
124
|
+
)
|
|
125
|
+
store.save_account(state)
|
|
126
|
+
|
|
127
|
+
if not args.json:
|
|
128
|
+
_print_qr_terminal(qrcode_url)
|
|
129
|
+
print(f"\nqrcode: {qrcode}")
|
|
130
|
+
print(f"状态目录: {store.dir}")
|
|
131
|
+
|
|
132
|
+
# 2. 轮询等待扫码
|
|
133
|
+
return _wait_login(api, store, args)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _wait_login(api: WechatILinkAPI, store: StateStore, args: argparse.Namespace) -> int:
|
|
137
|
+
state = store.load_account()
|
|
138
|
+
pending = state.pending_login
|
|
139
|
+
if not pending:
|
|
140
|
+
print("没有待确认的二维码", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
deadline = time.monotonic() + max(1, args.timeout_seconds)
|
|
144
|
+
scanned_printed = False
|
|
145
|
+
|
|
146
|
+
while time.monotonic() < deadline:
|
|
147
|
+
remaining = max(1, int(deadline - time.monotonic()))
|
|
148
|
+
resp = api.get_qrcode_status(
|
|
149
|
+
qrcode=pending.qrcode,
|
|
150
|
+
timeout=min(args.request_timeout_seconds, remaining),
|
|
151
|
+
)
|
|
152
|
+
status = str(resp.get("status") or "").strip()
|
|
153
|
+
|
|
154
|
+
if status == "wait":
|
|
155
|
+
if args.verbose:
|
|
156
|
+
sys.stderr.write(".")
|
|
157
|
+
sys.stderr.flush()
|
|
158
|
+
time.sleep(1)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if status == "scaned":
|
|
162
|
+
if not scanned_printed:
|
|
163
|
+
print("\n已扫码,请在微信中确认...", file=sys.stderr)
|
|
164
|
+
scanned_printed = True
|
|
165
|
+
time.sleep(1)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if status == "expired":
|
|
169
|
+
pending.refresh_count += 1
|
|
170
|
+
if pending.refresh_count > args.max_refreshes:
|
|
171
|
+
print("\n二维码多次过期,请重新运行 login", file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
print(f"\n二维码已过期,正在刷新 ({pending.refresh_count}/{args.max_refreshes})...", file=sys.stderr)
|
|
174
|
+
new_resp = api.get_bot_qrcode(bot_type=pending.bot_type)
|
|
175
|
+
pending.qrcode = str(new_resp.get("qrcode") or "").strip()
|
|
176
|
+
pending.qrcode_url = str(new_resp.get("qrcode_img_content") or "").strip()
|
|
177
|
+
pending.started_at = datetime.now().isoformat()
|
|
178
|
+
state.pending_login = pending
|
|
179
|
+
store.save_account(state)
|
|
180
|
+
_print_qr_terminal(pending.qrcode_url)
|
|
181
|
+
scanned_printed = False
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
if status == "confirmed":
|
|
185
|
+
bot_token = str(resp.get("bot_token") or "").strip() or None
|
|
186
|
+
account_id = str(resp.get("ilink_bot_id") or "").strip() or None
|
|
187
|
+
user_id = str(resp.get("ilink_user_id") or "").strip() or None
|
|
188
|
+
new_base_url = str(resp.get("baseurl") or api.base_url).strip() or api.base_url
|
|
189
|
+
|
|
190
|
+
if not bot_token or not account_id:
|
|
191
|
+
print("登录确认但缺少 bot_token / ilink_bot_id", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
state.token = bot_token
|
|
195
|
+
state.account_id = account_id
|
|
196
|
+
state.user_id = user_id or state.user_id
|
|
197
|
+
state.base_url = new_base_url
|
|
198
|
+
state.pending_login = None
|
|
199
|
+
store.save_account(state)
|
|
200
|
+
store.clear_cursor()
|
|
201
|
+
|
|
202
|
+
if args.json:
|
|
203
|
+
print(json.dumps({
|
|
204
|
+
"connected": True,
|
|
205
|
+
"account_id": account_id,
|
|
206
|
+
"user_id": user_id,
|
|
207
|
+
"base_url": new_base_url,
|
|
208
|
+
"state_dir": str(store.dir),
|
|
209
|
+
}, ensure_ascii=False, indent=2))
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
print("\n✅ 登录成功!")
|
|
213
|
+
print(f" account_id: {account_id}")
|
|
214
|
+
print(f" user_id: {user_id}")
|
|
215
|
+
print(f" base_url: {new_base_url}")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
# 未知状态
|
|
219
|
+
print(f"\n未知状态: {status}", file=sys.stderr)
|
|
220
|
+
time.sleep(1)
|
|
221
|
+
|
|
222
|
+
print("\n等待扫码超时", file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
227
|
+
store = StateStore(args.state_dir)
|
|
228
|
+
state = store.load_account()
|
|
229
|
+
cursor = store.load_cursor()
|
|
230
|
+
|
|
231
|
+
if args.json:
|
|
232
|
+
print(json.dumps({
|
|
233
|
+
"logged_in": bool(state.token),
|
|
234
|
+
"base_url": state.base_url,
|
|
235
|
+
"account_id": state.account_id,
|
|
236
|
+
"user_id": state.user_id,
|
|
237
|
+
"has_cursor": bool(cursor),
|
|
238
|
+
}, ensure_ascii=False, indent=2))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
print(f"状态目录: {store.dir}")
|
|
242
|
+
print(f"已登录: {'是' if state.token else '否'}")
|
|
243
|
+
print(f"base_url: {state.base_url}")
|
|
244
|
+
print(f"account_id: {state.account_id or '-'}")
|
|
245
|
+
print(f"user_id: {state.user_id or '-'}")
|
|
246
|
+
print(f"route_tag: {state.route_tag or '-'}")
|
|
247
|
+
print(f"已存游标: {'是' if cursor else '否'}")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_poll(args: argparse.Namespace) -> int:
|
|
252
|
+
store = StateStore(args.state_dir)
|
|
253
|
+
api = _build_api_from_state(store)
|
|
254
|
+
|
|
255
|
+
if args.clear_cursor:
|
|
256
|
+
store.clear_cursor()
|
|
257
|
+
|
|
258
|
+
cursor = store.load_cursor()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
while True:
|
|
262
|
+
resp = api.get_updates(
|
|
263
|
+
get_updates_buf=cursor,
|
|
264
|
+
timeout=args.timeout_seconds,
|
|
265
|
+
)
|
|
266
|
+
next_cursor = str(resp.get("get_updates_buf") or "").strip()
|
|
267
|
+
if next_cursor:
|
|
268
|
+
store.save_cursor(next_cursor)
|
|
269
|
+
cursor = next_cursor
|
|
270
|
+
|
|
271
|
+
msgs = resp.get("msgs") or []
|
|
272
|
+
if not msgs and args.once:
|
|
273
|
+
print("暂无新消息")
|
|
274
|
+
|
|
275
|
+
for msg in msgs:
|
|
276
|
+
if not isinstance(msg, dict):
|
|
277
|
+
continue
|
|
278
|
+
_print_message(msg, fmt=args.format)
|
|
279
|
+
|
|
280
|
+
if args.once:
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
except KeyboardInterrupt:
|
|
284
|
+
print("\n已停止轮询", file=sys.stderr)
|
|
285
|
+
return 130
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_message(msg: dict, fmt: str = "summary") -> None:
|
|
289
|
+
if fmt == "message-json":
|
|
290
|
+
print(json.dumps(msg, ensure_ascii=False, indent=2))
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
ts = msg.get("create_time_ms")
|
|
294
|
+
if isinstance(ts, int):
|
|
295
|
+
created = datetime.fromtimestamp(ts / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
296
|
+
else:
|
|
297
|
+
created = "-"
|
|
298
|
+
|
|
299
|
+
print(f"[{created}] from: {msg.get('from_user_id', '-')}")
|
|
300
|
+
print(f" message_state: {msg.get('message_state')}")
|
|
301
|
+
print(f" context_token: {msg.get('context_token', '-')}")
|
|
302
|
+
|
|
303
|
+
text = _extract_text(msg)
|
|
304
|
+
if text:
|
|
305
|
+
print(f" 内容: {text}")
|
|
306
|
+
else:
|
|
307
|
+
# 检查附件类型
|
|
308
|
+
for item in (msg.get("item_list") or []):
|
|
309
|
+
t = item.get("type")
|
|
310
|
+
if t == 2: print(" [图片]")
|
|
311
|
+
elif t == 3: print(" [语音]")
|
|
312
|
+
elif t == 4: print(" [文件]")
|
|
313
|
+
elif t == 5: print(" [视频]")
|
|
314
|
+
print("-" * 40)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_send_text(args: argparse.Namespace) -> int:
|
|
318
|
+
store = StateStore(args.state_dir)
|
|
319
|
+
state = store.load_account()
|
|
320
|
+
api = _build_api_from_state(store)
|
|
321
|
+
|
|
322
|
+
to_user = _resolve_user_id(args.to_user_id, state)
|
|
323
|
+
text = _read_text(args)
|
|
324
|
+
|
|
325
|
+
resp = api.send_text_message(
|
|
326
|
+
to_user_id=to_user,
|
|
327
|
+
text=text,
|
|
328
|
+
context_token=args.context_token or None,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if args.json:
|
|
332
|
+
print(json.dumps({"ok": True, "to_user_id": to_user, "client_id": resp["client_id"]}, ensure_ascii=False))
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
print(f"发送成功 → {to_user}")
|
|
336
|
+
print(f"client_id: {resp['client_id']}")
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cmd_reply_text(args: argparse.Namespace) -> int:
|
|
341
|
+
if not args.context_token:
|
|
342
|
+
print("reply-text 必须传 --context-token", file=sys.stderr)
|
|
343
|
+
return 1
|
|
344
|
+
return cmd_send_text(args)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _read_text(args: argparse.Namespace) -> str:
|
|
348
|
+
if args.stdin:
|
|
349
|
+
return sys.stdin.read()
|
|
350
|
+
if args.text_file:
|
|
351
|
+
return Path(args.text_file).read_text("utf-8")
|
|
352
|
+
text = (args.text or "").strip()
|
|
353
|
+
if not text:
|
|
354
|
+
raise ValueError("文本为空,请传 --text / --stdin / --text-file")
|
|
355
|
+
return text
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_get_config(args: argparse.Namespace) -> int:
|
|
359
|
+
store = StateStore(args.state_dir)
|
|
360
|
+
state = store.load_account()
|
|
361
|
+
api = _build_api_from_state(store)
|
|
362
|
+
|
|
363
|
+
uid = _resolve_user_id(args.ilink_user_id, state)
|
|
364
|
+
resp = api.get_config(ilink_user_id=uid, context_token=args.context_token or None)
|
|
365
|
+
|
|
366
|
+
if args.json:
|
|
367
|
+
print(json.dumps(resp, ensure_ascii=False, indent=2))
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
print(f"typing_ticket: {resp.get('typing_ticket', '-')}")
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def cmd_send_typing(args: argparse.Namespace) -> int:
|
|
375
|
+
store = StateStore(args.state_dir)
|
|
376
|
+
state = store.load_account()
|
|
377
|
+
api = _build_api_from_state(store)
|
|
378
|
+
|
|
379
|
+
uid = _resolve_user_id(args.ilink_user_id, state)
|
|
380
|
+
ticket = (args.typing_ticket or "").strip()
|
|
381
|
+
if not ticket and args.fetch_ticket:
|
|
382
|
+
config = api.get_config(ilink_user_id=uid, context_token=args.context_token or None)
|
|
383
|
+
ticket = str(config.get("typing_ticket") or "").strip()
|
|
384
|
+
if not ticket:
|
|
385
|
+
print("需要 --typing-ticket 或 --fetch-ticket", file=sys.stderr)
|
|
386
|
+
return 1
|
|
387
|
+
|
|
388
|
+
status_val = 1 if args.status == "typing" else 2
|
|
389
|
+
api.send_typing(ilink_user_id=uid, typing_ticket=ticket, status=status_val)
|
|
390
|
+
|
|
391
|
+
if args.json:
|
|
392
|
+
print(json.dumps({"ok": True, "status": args.status}))
|
|
393
|
+
return 0
|
|
394
|
+
print(f"typing 状态已发送: {args.status}")
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---- 桥接 ----
|
|
399
|
+
|
|
400
|
+
def cmd_bridge(args: argparse.Namespace) -> int:
|
|
401
|
+
"""将微信消息桥接到一个外部命令(如 Claude Code、自定义脚本)。"""
|
|
402
|
+
store = StateStore(args.state_dir)
|
|
403
|
+
state = store.load_account()
|
|
404
|
+
api = _build_api_from_state(store)
|
|
405
|
+
to_user = _resolve_user_id(None, state)
|
|
406
|
+
|
|
407
|
+
if args.clear_cursor:
|
|
408
|
+
store.clear_cursor()
|
|
409
|
+
|
|
410
|
+
cursor = store.load_cursor()
|
|
411
|
+
|
|
412
|
+
print(f"[bridge] 监听中... 命令: {args.cmd}", file=sys.stderr)
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
while True:
|
|
416
|
+
resp = api.get_updates(get_updates_buf=cursor, timeout=args.timeout_seconds)
|
|
417
|
+
next_cursor = str(resp.get("get_updates_buf") or "").strip()
|
|
418
|
+
if next_cursor:
|
|
419
|
+
store.save_cursor(next_cursor)
|
|
420
|
+
cursor = next_cursor
|
|
421
|
+
|
|
422
|
+
for msg in (resp.get("msgs") or []):
|
|
423
|
+
if not isinstance(msg, dict):
|
|
424
|
+
continue
|
|
425
|
+
# 只处理用户文本消息
|
|
426
|
+
if msg.get("message_type") != 1 or msg.get("message_state") != 2:
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
from_user = str(msg.get("from_user_id") or "").strip()
|
|
430
|
+
ctx_token = str(msg.get("context_token") or "").strip()
|
|
431
|
+
text = _extract_text(msg)
|
|
432
|
+
|
|
433
|
+
if not from_user or not ctx_token or not text:
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
print(f"\n[{datetime.now().strftime('%H:%M:%S')}] {from_user}: {text[:100]}{'...' if len(text) > 100 else ''}")
|
|
437
|
+
|
|
438
|
+
# 调用外部命令
|
|
439
|
+
reply = _run_bridge_command(args.cmd, text, from_user)
|
|
440
|
+
if reply:
|
|
441
|
+
api.send_text_message(to_user_id=from_user, text=reply, context_token=ctx_token)
|
|
442
|
+
|
|
443
|
+
except KeyboardInterrupt:
|
|
444
|
+
print("\n[bridge] 已停止", file=sys.stderr)
|
|
445
|
+
return 130
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _run_bridge_command(cmd: str, text: str, user_id: str) -> str | None:
|
|
449
|
+
"""执行桥接命令,通过 stdin 传入消息文本,stdout 读取回复。"""
|
|
450
|
+
import subprocess
|
|
451
|
+
try:
|
|
452
|
+
result = subprocess.run(
|
|
453
|
+
cmd,
|
|
454
|
+
input=text,
|
|
455
|
+
capture_output=True,
|
|
456
|
+
text=True,
|
|
457
|
+
timeout=300,
|
|
458
|
+
shell=True,
|
|
459
|
+
env={**os.environ, "EASYWECHATPY_FROM_USER": user_id},
|
|
460
|
+
)
|
|
461
|
+
if result.returncode != 0:
|
|
462
|
+
err = result.stderr.strip()
|
|
463
|
+
return f"命令执行失败: {err}" if err else f"命令返回码: {result.returncode}"
|
|
464
|
+
return result.stdout.strip() or None
|
|
465
|
+
except subprocess.TimeoutExpired:
|
|
466
|
+
return "处理超时"
|
|
467
|
+
except FileNotFoundError:
|
|
468
|
+
return f"命令不存在: {cmd}"
|
|
469
|
+
except Exception as e:
|
|
470
|
+
return f"执行异常: {e}"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---- CLI 构建 ----
|
|
474
|
+
|
|
475
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
476
|
+
parser = argparse.ArgumentParser(
|
|
477
|
+
prog="python -m easywechatpy.ilink",
|
|
478
|
+
description="微信 iLink Bot 命令行工具",
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument("--state-dir", default=None, help="状态目录 (默认 ~/.easywechatpy/)")
|
|
481
|
+
|
|
482
|
+
sub = parser.add_subparsers(dest="command")
|
|
483
|
+
|
|
484
|
+
# login
|
|
485
|
+
p = sub.add_parser("login", help="扫码登录微信 Bot")
|
|
486
|
+
p.add_argument("--base-url", default=None, help=f"API 地址 (默认 {DEFAULT_BASE_URL})")
|
|
487
|
+
p.add_argument("--route-tag", default=None, help="SKRouteTag")
|
|
488
|
+
p.add_argument("--bot-type", default=DEFAULT_BOT_TYPE, help=f"bot_type (默认 {DEFAULT_BOT_TYPE})")
|
|
489
|
+
p.add_argument("--timeout-seconds", type=int, default=480, help="等待扫码总秒数")
|
|
490
|
+
p.add_argument("--request-timeout-seconds", type=int, default=DEFAULT_LONGPOLL_TIMEOUT, help="单次轮询秒数")
|
|
491
|
+
p.add_argument("--max-refreshes", type=int, default=3, help="二维码过期后最多刷新次数")
|
|
492
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
493
|
+
p.add_argument("--verbose", action="store_true", help="详细输出")
|
|
494
|
+
p.set_defaults(func=cmd_login)
|
|
495
|
+
|
|
496
|
+
# whoami
|
|
497
|
+
p = sub.add_parser("whoami", help="查看当前登录状态")
|
|
498
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
499
|
+
p.set_defaults(func=cmd_whoami)
|
|
500
|
+
|
|
501
|
+
# poll
|
|
502
|
+
p = sub.add_parser("poll", help="长轮询接收消息")
|
|
503
|
+
p.add_argument("--once", action="store_true", help="只拉取一次")
|
|
504
|
+
p.add_argument("--timeout-seconds", type=int, default=DEFAULT_LONGPOLL_TIMEOUT, help="单次长轮询秒数")
|
|
505
|
+
p.add_argument("--format", choices=("summary", "message-json"), default="summary", help="输出格式")
|
|
506
|
+
p.add_argument("--clear-cursor", action="store_true", help="清空游标")
|
|
507
|
+
p.set_defaults(func=cmd_poll)
|
|
508
|
+
|
|
509
|
+
# send-text
|
|
510
|
+
p = sub.add_parser("send-text", help="发送文本消息")
|
|
511
|
+
p.add_argument("--to-user-id", default=None, help="目标用户 ID")
|
|
512
|
+
p.add_argument("--text", default=None, help="消息文本")
|
|
513
|
+
p.add_argument("--text-file", default=None, help="从文件读取文本")
|
|
514
|
+
p.add_argument("--stdin", action="store_true", help="从标准输入读取")
|
|
515
|
+
p.add_argument("--context-token", default=None, help="上下文 token(非回复可省略)")
|
|
516
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
517
|
+
p.set_defaults(func=cmd_send_text)
|
|
518
|
+
|
|
519
|
+
# reply-text
|
|
520
|
+
p = sub.add_parser("reply-text", help="带上下文 token 回复消息")
|
|
521
|
+
p.add_argument("--to-user-id", default=None, help="目标用户 ID")
|
|
522
|
+
p.add_argument("--context-token", required=True, help="原消息的 context_token")
|
|
523
|
+
p.add_argument("--text", default=None, help="回复文本")
|
|
524
|
+
p.add_argument("--text-file", default=None, help="从文件读取文本")
|
|
525
|
+
p.add_argument("--stdin", action="store_true", help="从标准输入读取")
|
|
526
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
527
|
+
p.set_defaults(func=cmd_reply_text)
|
|
528
|
+
|
|
529
|
+
# get-config
|
|
530
|
+
p = sub.add_parser("get-config", help="获取 bot 配置 (typing_ticket)")
|
|
531
|
+
p.add_argument("--ilink-user-id", default=None, help="用户 ID")
|
|
532
|
+
p.add_argument("--context-token", default=None, help="上下文 token")
|
|
533
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
534
|
+
p.set_defaults(func=cmd_get_config)
|
|
535
|
+
|
|
536
|
+
# send-typing
|
|
537
|
+
p = sub.add_parser("send-typing", help="发送「正在输入」状态")
|
|
538
|
+
p.add_argument("--ilink-user-id", default=None, help="用户 ID")
|
|
539
|
+
p.add_argument("--typing-ticket", default=None, help="直接提供 typing_ticket")
|
|
540
|
+
p.add_argument("--context-token", default=None, help="配合 --fetch-ticket 使用")
|
|
541
|
+
p.add_argument("--fetch-ticket", action="store_true", help="自动拉取 typing_ticket")
|
|
542
|
+
p.add_argument("--status", choices=("typing", "cancel"), default="typing", help="状态")
|
|
543
|
+
p.add_argument("--json", action="store_true", help="JSON 输出")
|
|
544
|
+
p.set_defaults(func=cmd_send_typing)
|
|
545
|
+
|
|
546
|
+
# bridge
|
|
547
|
+
p = sub.add_parser("bridge", help="桥接微信消息到外部命令")
|
|
548
|
+
p.add_argument("cmd", help="外部命令(消息通过 stdin 传入,回复从 stdout 读取)")
|
|
549
|
+
p.add_argument("--timeout-seconds", type=int, default=DEFAULT_LONGPOLL_TIMEOUT, help="长轮询秒数")
|
|
550
|
+
p.add_argument("--clear-cursor", action="store_true", help="启动前清空游标")
|
|
551
|
+
p.set_defaults(func=cmd_bridge)
|
|
552
|
+
|
|
553
|
+
return parser
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def main(argv: list[str] | None = None) -> int:
|
|
557
|
+
parser = build_parser()
|
|
558
|
+
args = parser.parse_args(argv)
|
|
559
|
+
|
|
560
|
+
if not args.command:
|
|
561
|
+
parser.print_help()
|
|
562
|
+
return 1
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
return args.func(args)
|
|
566
|
+
except KeyboardInterrupt:
|
|
567
|
+
print("\n已中断", file=sys.stderr)
|
|
568
|
+
return 130
|
|
569
|
+
except ApiError as e:
|
|
570
|
+
if e.errcode == SESSION_TIMEOUT_ERRCODE:
|
|
571
|
+
print("会话已过期 (errcode=-14),请重新登录", file=sys.stderr)
|
|
572
|
+
return 1
|
|
573
|
+
print(f"请求失败: {e}", file=sys.stderr)
|
|
574
|
+
return 1
|
|
575
|
+
except (ValueError, TimeoutError) as e:
|
|
576
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
577
|
+
return 1
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
微信 iLink 状态管理:保存/加载 bot_token、游标、用户信息。
|
|
4
|
+
状态目录: ~/.easywechatpy/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
16
|
+
DEFAULT_BOT_TYPE = "3"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_state_dir(env_name: str = "EASYWECHATPY_STATE_DIR") -> Path:
|
|
20
|
+
env = os.environ.get(env_name)
|
|
21
|
+
if env:
|
|
22
|
+
return Path(env).resolve()
|
|
23
|
+
return Path.home() / ".easywechatpy"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utc_now_iso() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PendingLogin:
|
|
32
|
+
qrcode: str = ""
|
|
33
|
+
qrcode_url: str = ""
|
|
34
|
+
bot_type: str = DEFAULT_BOT_TYPE
|
|
35
|
+
started_at: str = ""
|
|
36
|
+
refresh_count: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class WechatState:
|
|
41
|
+
"""微信 iLink 账号状态(单账号,JSON 文件持久化)。"""
|
|
42
|
+
token: str = ""
|
|
43
|
+
account_id: str = ""
|
|
44
|
+
user_id: str = ""
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL
|
|
46
|
+
route_tag: str = ""
|
|
47
|
+
pending_login: PendingLogin | None = None
|
|
48
|
+
updated_at: str = ""
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"token": self.token,
|
|
53
|
+
"account_id": self.account_id,
|
|
54
|
+
"user_id": self.user_id,
|
|
55
|
+
"base_url": self.base_url,
|
|
56
|
+
"route_tag": self.route_tag,
|
|
57
|
+
"pending_login": {
|
|
58
|
+
"qrcode": self.pending_login.qrcode,
|
|
59
|
+
"qrcode_url": self.pending_login.qrcode_url,
|
|
60
|
+
"bot_type": self.pending_login.bot_type,
|
|
61
|
+
"started_at": self.pending_login.started_at,
|
|
62
|
+
"refresh_count": self.pending_login.refresh_count,
|
|
63
|
+
} if self.pending_login else None,
|
|
64
|
+
"updated_at": self.updated_at,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, d: dict) -> "WechatState":
|
|
69
|
+
pending = d.get("pending_login")
|
|
70
|
+
return cls(
|
|
71
|
+
token=d.get("token", ""),
|
|
72
|
+
account_id=d.get("account_id", ""),
|
|
73
|
+
user_id=d.get("user_id", ""),
|
|
74
|
+
base_url=d.get("base_url", DEFAULT_BASE_URL),
|
|
75
|
+
route_tag=d.get("route_tag", ""),
|
|
76
|
+
pending_login=PendingLogin(**pending) if pending else None,
|
|
77
|
+
updated_at=d.get("updated_at", ""),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class StateStore:
|
|
82
|
+
"""管理 easywechatpy 的持久化状态。"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, state_dir: Path | str | None = None) -> None:
|
|
85
|
+
self._dir = Path(state_dir).resolve() if state_dir else resolve_state_dir()
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def dir(self) -> Path:
|
|
89
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return self._dir
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def account_file(self) -> Path:
|
|
94
|
+
return self.dir / "account.json"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def cursor_file(self) -> Path:
|
|
98
|
+
return self.dir / "cursor.txt"
|
|
99
|
+
|
|
100
|
+
# ---- account ----
|
|
101
|
+
|
|
102
|
+
def load_account(self) -> WechatState:
|
|
103
|
+
try:
|
|
104
|
+
if not self.account_file.exists():
|
|
105
|
+
return WechatState()
|
|
106
|
+
raw = self.account_file.read_text("utf-8")
|
|
107
|
+
return WechatState.from_dict(json.loads(raw))
|
|
108
|
+
except Exception:
|
|
109
|
+
return WechatState()
|
|
110
|
+
|
|
111
|
+
def save_account(self, state: WechatState) -> None:
|
|
112
|
+
state.updated_at = utc_now_iso()
|
|
113
|
+
payload = state.to_dict()
|
|
114
|
+
self.account_file.write_text(
|
|
115
|
+
json.dumps(payload, ensure_ascii=False, indent=2), "utf-8"
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
os.chmod(self.account_file, 0o600)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# ---- cursor ----
|
|
123
|
+
|
|
124
|
+
def load_cursor(self) -> str:
|
|
125
|
+
try:
|
|
126
|
+
if not self.cursor_file.exists():
|
|
127
|
+
return ""
|
|
128
|
+
return self.cursor_file.read_text("utf-8").strip()
|
|
129
|
+
except Exception:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
def save_cursor(self, cursor: str) -> None:
|
|
133
|
+
self.cursor_file.write_text(cursor.strip(), "utf-8")
|
|
134
|
+
|
|
135
|
+
def clear_cursor(self) -> None:
|
|
136
|
+
try:
|
|
137
|
+
if self.cursor_file.exists():
|
|
138
|
+
self.cursor_file.unlink()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WechatRobot:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
key="",
|
|
9
|
+
userid="",
|
|
10
|
+
msgtype="text",
|
|
11
|
+
qyapi="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=",
|
|
12
|
+
):
|
|
13
|
+
self.headers = {"Content-Type": "application/json"}
|
|
14
|
+
self.msgtype = msgtype
|
|
15
|
+
self.qyapi = qyapi
|
|
16
|
+
self.key = key
|
|
17
|
+
self.userid = userid
|
|
18
|
+
self.url = f"{self.qyapi}{self.key}"
|
|
19
|
+
|
|
20
|
+
def do_post(self, payload):
|
|
21
|
+
try:
|
|
22
|
+
r = requests.post(self.url, data=payload, headers=self.headers)
|
|
23
|
+
if r.status_code == 200:
|
|
24
|
+
return True
|
|
25
|
+
else:
|
|
26
|
+
return False
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def send_mes(self, mes_info):
|
|
31
|
+
content = {}
|
|
32
|
+
content["msgtype"] = self.msgtype
|
|
33
|
+
msg_type = {"content": mes_info}
|
|
34
|
+
content["text"] = msg_type
|
|
35
|
+
print(content)
|
|
36
|
+
data = json.dumps(content)
|
|
37
|
+
try:
|
|
38
|
+
status = self.do_post(data)
|
|
39
|
+
if status:
|
|
40
|
+
return 0
|
|
41
|
+
else:
|
|
42
|
+
return 1
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
def send_custom_msg(self, msg, status=None, name=None, number=None):
|
|
47
|
+
base_url = "http://bj.s1f.ren/gzh/sendMsg"
|
|
48
|
+
query_params = {"userid": self.userid, "text": msg}
|
|
49
|
+
|
|
50
|
+
if status is not None:
|
|
51
|
+
query_params["status"] = status
|
|
52
|
+
if name is not None:
|
|
53
|
+
query_params["name"] = name
|
|
54
|
+
if number is not None:
|
|
55
|
+
query_params["number"] = number
|
|
56
|
+
|
|
57
|
+
response = requests.get(base_url, params=query_params)
|
|
58
|
+
|
|
59
|
+
if response.status_code == 200:
|
|
60
|
+
return response.text
|
|
61
|
+
else:
|
|
62
|
+
raise Exception(f"Request failed with status code: {response.status_code}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyWechatpy
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: 微信 iLink Bot API 客户端 —— 扫码登录、收发消息、桥接 Agent。
|
|
5
|
+
Home-page: https://github.com/xleizi/easyWechatpy
|
|
6
|
+
Author: Lei Cui
|
|
7
|
+
Author-email: cuilei798@qq.com
|
|
8
|
+
Maintainer: Lei Cui
|
|
9
|
+
Maintainer-email: cuilei798@qq.com
|
|
10
|
+
License: MIT License
|
|
11
|
+
Platform: linux
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: classifier
|
|
20
|
+
Dynamic: description
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: maintainer
|
|
24
|
+
Dynamic: maintainer-email
|
|
25
|
+
Dynamic: platform
|
|
26
|
+
Dynamic: requires-dist
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# easyWechatpy
|
|
31
|
+
easyWechatpy
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
easyWechatpy/__init__.py,sha256=Q4CUNFMAY61YjjTID2jn3tBX5Fv47NOagklWeQtFGdQ,94
|
|
2
|
+
easyWechatpy/weChatRobot.py,sha256=9ybuTzsf2e_KMs316X3DB2LX-ofThvJESc2vl_tJQvk,1796
|
|
3
|
+
easyWechatpy/ilink/__init__.py,sha256=EN2QESsRZkPfpS_8vK95utvSv_dwAqQv2UvzcyPNrE4,172
|
|
4
|
+
easyWechatpy/ilink/__main__.py,sha256=_dZ5Y1x9_q0JXb2f0uOKFMP_BU_TYEmIoIx38HCtU2I,92
|
|
5
|
+
easyWechatpy/ilink/api.py,sha256=Yf4Y47Jsp5o2h4PaJm24T1Ckjzr3nTmzlBDmeFokVAI,9258
|
|
6
|
+
easyWechatpy/ilink/cli.py,sha256=Qr9ziDq0uppoK7Wkm9vHkirsAOXiUdkw16iJInC7Wl8,20422
|
|
7
|
+
easyWechatpy/ilink/state.py,sha256=Qk8RwrRJiXV9UMoUgeZ0z_G8RZcXfR20l0rmX_6Z6X8,4058
|
|
8
|
+
easywechatpy-0.0.2.dist-info/METADATA,sha256=VMqs1CgkoKSqweE_q2t3i-i-NoqDELwgQLQPvjbcLn0,802
|
|
9
|
+
easywechatpy-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
easywechatpy-0.0.2.dist-info/entry_points.txt,sha256=d22a0TpGLiSAtTkUBcHr_hDCbQH6uMScHRLKOWS9hbU,67
|
|
11
|
+
easywechatpy-0.0.2.dist-info/top_level.txt,sha256=P63Es0wbB1gGywQaFwnxOP9lSI3RbIx2A7CwElFu1lY,13
|
|
12
|
+
easywechatpy-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
easyWechatpy
|