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.
@@ -0,0 +1,2 @@
1
+ from .weChatRobot import WechatRobot
2
+ from .ilink import WechatILinkAPI, ApiError, WechatState
@@ -0,0 +1,4 @@
1
+ from .api import WechatILinkAPI, ApiError, normalize_base_url
2
+ from .state import WechatState
3
+
4
+ __all__ = ["WechatILinkAPI", "ApiError", "normalize_base_url", "WechatState"]
@@ -0,0 +1,4 @@
1
+ """允许通过 python -m easywechatpy.ilink 运行 CLI。"""
2
+ from .cli import main
3
+
4
+ main()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ easywechatpy-ilink = easyWechatpy.ilink.cli:main
@@ -0,0 +1 @@
1
+ easyWechatpy