fody-agent 0.1.0__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.
fody_agent/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,615 @@
1
+ """
2
+ 微信桥接模块 —— 通过腾讯 iLink Bot API 接入个人微信
3
+
4
+ 协议参考:https://www.wechatbot.dev/zh/protocol
5
+
6
+ 使用流程:
7
+ 1. 扫码登录 (get_bot_qrcode → 轮询状态 → 获取 bot_token)
8
+ 2. 长轮询拉取消息 (getupdates)
9
+ 3. 调用 Agent 处理消息
10
+ 4. 回复消息 (sendmessage)
11
+ """
12
+
13
+ import asyncio
14
+ import base64
15
+ import json
16
+ import os
17
+ import random
18
+ import struct
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Awaitable, Callable, Union
23
+
24
+ import aiohttp
25
+
26
+ from fody_agent.utils.logger import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ ILINK_BASE_URL = "https://ilinkai.weixin.qq.com"
31
+ ILINK_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
32
+ POLL_TIMEOUT = 35 # 长轮询超时时间(秒)
33
+ POLL_INTERVAL = 2 # 临时错误重试间隔(秒)
34
+ MAX_POLL_RETRY = 3 # 连续临时错误重试次数
35
+
36
+ CREDENTIALS_DIR = Path.home() / ".fody" / "weixin"
37
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
38
+ CONTEXT_TOKENS_FILE = CREDENTIALS_DIR / "context_tokens.json"
39
+
40
+
41
+ @dataclass
42
+ class WeChatCredentials:
43
+ """微信 iLink Bot 登录凭证"""
44
+
45
+ bot_token: str
46
+ account_id: str
47
+ user_id: str
48
+ base_url: str = ILINK_BASE_URL
49
+ created_at: float = field(default_factory=time.time)
50
+
51
+
52
+ @dataclass
53
+ class WeChatMessage:
54
+ """微信消息"""
55
+
56
+ msg_id: str
57
+ from_user: str
58
+ content: str
59
+ context_token: str
60
+ msg_type: int = 1 # 1=文本
61
+ timestamp: int = 0
62
+
63
+
64
+ def _random_uin() -> str:
65
+ """生成随机的 X-WECHAT-UIN 头"""
66
+ uin = random.getrandbits(32)
67
+ uin_bytes = struct.pack("<I", uin)
68
+ return base64.b64encode(uin_bytes).decode()
69
+
70
+
71
+ def _build_headers(bot_token: str) -> dict:
72
+ """构建 iLink API 通用请求头"""
73
+ return {
74
+ "Content-Type": "application/json",
75
+ "AuthorizationType": "ilink_bot_token",
76
+ "Authorization": f"Bearer {bot_token}",
77
+ "X-WECHAT-UIN": _random_uin(),
78
+ }
79
+
80
+
81
+ def _save_credentials(creds: WeChatCredentials):
82
+ """保存登录凭证到磁盘"""
83
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
84
+ with open(CREDENTIALS_FILE, "w", encoding="utf-8") as f:
85
+ json.dump(
86
+ {
87
+ "bot_token": creds.bot_token,
88
+ "account_id": creds.account_id,
89
+ "user_id": creds.user_id,
90
+ "base_url": creds.base_url,
91
+ "created_at": creds.created_at,
92
+ },
93
+ f,
94
+ ensure_ascii=False,
95
+ indent=2,
96
+ )
97
+ # Windows 没有 chmod,仅确保文件存在
98
+ logger.info("凭证已保存到 %s", CREDENTIALS_FILE)
99
+
100
+
101
+ def _load_credentials() -> WeChatCredentials | None:
102
+ """从磁盘加载登录凭证"""
103
+ if not CREDENTIALS_FILE.exists():
104
+ return None
105
+ try:
106
+ with open(CREDENTIALS_FILE, encoding="utf-8") as f:
107
+ data = json.load(f)
108
+ return WeChatCredentials(
109
+ bot_token=data["bot_token"],
110
+ account_id=data["account_id"],
111
+ user_id=data.get("user_id", ""),
112
+ base_url=data.get("base_url", ILINK_BASE_URL),
113
+ created_at=data.get("created_at", 0),
114
+ )
115
+ except (json.JSONDecodeError, KeyError) as e:
116
+ logger.warning("凭证文件损坏: %s", e)
117
+ return None
118
+
119
+
120
+ def _load_context_tokens() -> dict[str, str]:
121
+ """加载缓存的 context_token 映射"""
122
+ if not CONTEXT_TOKENS_FILE.exists():
123
+ return {}
124
+ try:
125
+ with open(CONTEXT_TOKENS_FILE, encoding="utf-8") as f:
126
+ return json.load(f)
127
+ except json.JSONDecodeError:
128
+ return {}
129
+
130
+
131
+ def _save_context_tokens(tokens: dict[str, str]):
132
+ """保存 context_token 映射到磁盘"""
133
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
134
+ with open(CONTEXT_TOKENS_FILE, "w", encoding="utf-8") as f:
135
+ json.dump(tokens, f, ensure_ascii=False, indent=2)
136
+
137
+
138
+ async def login_qr(
139
+ session: aiohttp.ClientSession,
140
+ base_url: str = ILINK_BASE_URL,
141
+ ) -> WeChatCredentials:
142
+ """扫码登录流程:获取二维码 → 轮询扫码状态 → 获取凭证"""
143
+ print("\n正在获取微信登录二维码...")
144
+
145
+ # Step 1: 获取二维码
146
+ qr_url = f"{base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
147
+ async with session.get(qr_url) as resp:
148
+ if resp.status != 200:
149
+ raise RuntimeError(f"获取二维码失败: HTTP {resp.status}")
150
+ data = await resp.json()
151
+ qrcode_key = data.get("qrcode") or data.get("qrcode_key", "")
152
+ qrcode_img = data.get("qrcode_img_content", "")
153
+
154
+ if not qrcode_key:
155
+ raise RuntimeError("二维码数据为空")
156
+
157
+ # Step 2: 尝试显示二维码
158
+ try:
159
+ import qrcode as qrcode_lib
160
+
161
+ qr = qrcode_lib.QRCode(border=1)
162
+ qr.add_data(qrcode_img or qrcode_key)
163
+ qr.make(fit=True)
164
+ qr.print_ascii(invert=True)
165
+ except ImportError:
166
+ # 没有 qrcode 库,打印 URL
167
+ print(f"请打开链接扫描二维码: {qrcode_img or qrcode_key}")
168
+
169
+ print("\n请使用手机微信扫描上方二维码...")
170
+ print("(等待扫码确认中,二维码有效期为几分钟)\n")
171
+
172
+ # Step 3: 轮询扫码状态
173
+ status_url = f"{base_url}/ilink/bot/get_qrcode_status"
174
+ consecutive_errors = 0
175
+ refresh_count = 0
176
+
177
+ while refresh_count < 3:
178
+ try:
179
+ async with session.get(
180
+ f"{status_url}?qrcode={qrcode_key}",
181
+ headers={"iLink-App-ClientVersion": "1"},
182
+ timeout=45,
183
+ ) as resp:
184
+ status_data = await resp.json()
185
+ except (asyncio.TimeoutError, aiohttp.ClientError) as e:
186
+ consecutive_errors += 1
187
+ if consecutive_errors >= MAX_POLL_RETRY:
188
+ raise RuntimeError("二维码状态轮询多次失败") from e
189
+ await asyncio.sleep(POLL_INTERVAL)
190
+ continue
191
+
192
+ consecutive_errors = 0
193
+ status = status_data.get("status", "")
194
+
195
+ if status == "wait":
196
+ print(" 等待扫码...")
197
+ await asyncio.sleep(1.5)
198
+ elif status == "scaned":
199
+ print(" 已扫码,请在手机上确认登录...")
200
+ await asyncio.sleep(1.5)
201
+ elif status == "confirmed":
202
+ bot_token = status_data.get("bot_token", "")
203
+ account_id = status_data.get("ilink_bot_id", "")
204
+ user_id = status_data.get("ilink_user_id", "")
205
+ baseurl = status_data.get("baseurl", base_url)
206
+
207
+ if not bot_token or not account_id:
208
+ raise RuntimeError("登录确认但未返回完整凭证")
209
+
210
+ print(f"\n✅ 微信登录成功!")
211
+ print(f" 账号 ID: {account_id}")
212
+ print(f" 用户 ID: {user_id}")
213
+
214
+ creds = WeChatCredentials(
215
+ bot_token=bot_token,
216
+ account_id=account_id,
217
+ user_id=user_id,
218
+ base_url=baseurl,
219
+ )
220
+ _save_credentials(creds)
221
+ return creds
222
+ elif status == "expired":
223
+ refresh_count += 1
224
+ print(f" 二维码已过期,重新获取 ({refresh_count}/3)...")
225
+ async with session.get(qr_url) as resp2:
226
+ data2 = await resp2.json()
227
+ qrcode_key = data2.get("qrcode") or data2.get("qrcode_key", "")
228
+ qrcode_img = data2.get("qrcode_img_content", "")
229
+ if qrcode_img or qrcode_key:
230
+ try:
231
+ import qrcode as qrcode_lib
232
+
233
+ qr = qrcode_lib.QRCode(border=1)
234
+ qr.add_data(qrcode_img or qrcode_key)
235
+ qr.make(fit=True)
236
+ qr.print_ascii(invert=True)
237
+ except ImportError:
238
+ print(f"请打开链接扫描二维码: {qrcode_img or qrcode_key}")
239
+ print("\n请使用手机微信扫描新二维码...")
240
+ else:
241
+ # 未知状态,继续轮询
242
+ await asyncio.sleep(1)
243
+
244
+ raise RuntimeError("二维码多次过期,登录失败")
245
+
246
+
247
+ async def send_text(
248
+ session: aiohttp.ClientSession,
249
+ bot_token: str,
250
+ to_user_id: str,
251
+ text: str,
252
+ context_token: str,
253
+ base_url: str = ILINK_BASE_URL,
254
+ ) -> bool:
255
+ """发送文本消息"""
256
+ # 消息分块(微信限制约 4000 字符)
257
+ max_len = 3900
258
+ if len(text) > max_len:
259
+ chunks = _chunk_text(text, max_len)
260
+ for i, chunk in enumerate(chunks):
261
+ if i > 0:
262
+ await asyncio.sleep(0.3) # 避免频率限制
263
+ await _send_single_text(session, bot_token, to_user_id, chunk, context_token, base_url)
264
+ return True
265
+ return await _send_single_text(session, bot_token, to_user_id, text, context_token, base_url)
266
+
267
+
268
+ async def _send_single_text(
269
+ session: aiohttp.ClientSession,
270
+ bot_token: str,
271
+ to_user_id: str,
272
+ text: str,
273
+ context_token: str,
274
+ base_url: str = ILINK_BASE_URL,
275
+ ) -> bool:
276
+ """发送单条文本消息"""
277
+ url = f"{base_url}/ilink/bot/sendmessage"
278
+ headers = _build_headers(bot_token)
279
+ payload = {
280
+ "base_info": {"channel_version": "2.0.0"},
281
+ "msg": {
282
+ "to_user_id": to_user_id,
283
+ "context_token": context_token,
284
+ "item_list": [{"type": 1, "text_item": {"text": text}}],
285
+ },
286
+ }
287
+
288
+ async with session.post(url, json=payload, headers=headers) as resp:
289
+ if resp.status == 200:
290
+ return True
291
+ body = await resp.text()
292
+ logger.warning("发送消息失败: HTTP %s, body=%s", resp.status, body)
293
+ return False
294
+
295
+
296
+ def _chunk_text(text: str, max_len: int) -> list[str]:
297
+ """智能分块,在段落边界处拆分"""
298
+ if len(text) <= max_len:
299
+ return [text]
300
+
301
+ chunks = []
302
+ paragraphs = text.split("\n\n")
303
+ current = ""
304
+
305
+ for para in paragraphs:
306
+ if len(current) + len(para) + 2 <= max_len:
307
+ current = (current + "\n\n" + para) if current else para
308
+ else:
309
+ if current:
310
+ chunks.append(current)
311
+ # 如果一个段落本身就超长,按句子拆分
312
+ if len(para) > max_len:
313
+ for i in range(0, len(para), max_len):
314
+ chunks.append(para[i : i + max_len])
315
+ else:
316
+ current = para
317
+
318
+ if current:
319
+ chunks.append(current)
320
+
321
+ return chunks
322
+
323
+
324
+ async def send_typing(
325
+ session: aiohttp.ClientSession,
326
+ bot_token: str,
327
+ to_user_id: str,
328
+ typing_ticket: str,
329
+ status: int = 1,
330
+ base_url: str = ILINK_BASE_URL,
331
+ ) -> bool:
332
+ """发送正在输入状态"""
333
+ url = f"{base_url}/ilink/bot/sendtyping"
334
+ headers = _build_headers(bot_token)
335
+ payload = {
336
+ "base_info": {"channel_version": "2.0.0"},
337
+ "to_user_id": to_user_id,
338
+ "typing_ticket": typing_ticket,
339
+ "status": status,
340
+ }
341
+
342
+ async with session.post(url, json=payload, headers=headers) as resp:
343
+ return resp.status == 200
344
+
345
+
346
+ async def get_typing_ticket(
347
+ session: aiohttp.ClientSession,
348
+ bot_token: str,
349
+ to_user_id: str,
350
+ base_url: str = ILINK_BASE_URL,
351
+ ) -> str | None:
352
+ """获取用户的 typing ticket"""
353
+ url = f"{base_url}/ilink/bot/getconfig"
354
+ headers = _build_headers(bot_token)
355
+ payload = {
356
+ "base_info": {"channel_version": "2.0.0"},
357
+ "to_user_id": to_user_id,
358
+ }
359
+
360
+ async with session.post(url, json=payload, headers=headers) as resp:
361
+ if resp.status == 200:
362
+ data = await resp.json()
363
+ return data.get("typing_ticket")
364
+ return None
365
+
366
+
367
+ class WeChatBridge:
368
+ """微信 iLink Bot 桥接器
369
+
370
+ 管理扫码登录、长轮询收消息、调用 Agent 处理、回复的完整生命周期。
371
+ """
372
+
373
+ def __init__(
374
+ self,
375
+ provider: str = "",
376
+ model: str = "",
377
+ dm_policy: str = "open",
378
+ allowed_users: list[str] | None = None,
379
+ ):
380
+ self._provider = provider
381
+ self._model = model
382
+ self._dm_policy = dm_policy
383
+ self._allowed_users = allowed_users or []
384
+ self._creds: WeChatCredentials | None = None
385
+ self._context_tokens: dict[str, str] = {}
386
+ self._session: aiohttp.ClientSession | None = None
387
+ self._running = False
388
+ self._msg_dedup: set[str] = set() # 5分钟去重窗口
389
+ self._typing_cache: dict[str, str] = {} # user_id -> typing_ticket
390
+ self._on_message: Callable[[WeChatMessage], Union[str, None, Awaitable[str | None]]] | None = None
391
+
392
+ @property
393
+ def is_logged_in(self) -> bool:
394
+ return self._creds is not None
395
+
396
+ async def login(self, force: bool = False):
397
+ """登录微信,如有缓存凭证则复用"""
398
+ if not force:
399
+ self._creds = _load_credentials()
400
+ if self._creds:
401
+ print(f" 使用缓存的登录凭证(账号: {self._creds.account_id})")
402
+ return
403
+
404
+ if self._session is None:
405
+ self._session = aiohttp.ClientSession()
406
+
407
+ self._creds = await login_qr(self._session)
408
+ self._context_tokens = _load_context_tokens()
409
+
410
+ async def logout(self):
411
+ """清除登录凭证"""
412
+ self._creds = None
413
+ if CREDENTIALS_FILE.exists():
414
+ CREDENTIALS_FILE.unlink()
415
+ print("已清除登录凭证")
416
+
417
+ async def run(
418
+ self,
419
+ on_message: Callable[[WeChatMessage], Union[str, None, Awaitable[str | None]]] | None = None,
420
+ ):
421
+ """启动消息监听循环
422
+
423
+ Args:
424
+ on_message: 消息处理回调,接收 WeChatMessage,返回回复文本(None 表示不回复)
425
+ """
426
+ if not self._creds:
427
+ raise RuntimeError("尚未登录,请先调用 login()")
428
+
429
+ self._on_message = on_message
430
+ self._running = True
431
+
432
+ if self._session is None:
433
+ self._session = aiohttp.ClientSession()
434
+
435
+ print("\n开始监听微信消息...(按 Ctrl+C 停止)\n")
436
+
437
+ updates_buf = ""
438
+ consecutive_errors = 0
439
+
440
+ while self._running:
441
+ try:
442
+ updates_buf = await self._poll_updates(updates_buf)
443
+ consecutive_errors = 0
444
+ except SessionExpiredError:
445
+ print("\n⚠️ 会话已过期,需要重新登录")
446
+ self._creds = None
447
+ break
448
+ except Exception as e:
449
+ consecutive_errors += 1
450
+ wait = min(consecutive_errors * 2, 30)
451
+ logger.warning("消息轮询异常(第%d次): %s,%ds后重试", consecutive_errors, e, wait)
452
+ await asyncio.sleep(wait)
453
+
454
+ self._running = False
455
+
456
+ async def stop(self):
457
+ """停止监听循环"""
458
+ self._running = False
459
+
460
+ async def _poll_updates(self, updates_buf: str) -> str:
461
+ """执行一次长轮询,返回新的 updates_buf"""
462
+ if not self._creds:
463
+ raise SessionExpiredError("未登录")
464
+
465
+ url = f"{self._creds.base_url}/ilink/bot/getupdates"
466
+ headers = _build_headers(self._creds.bot_token)
467
+ payload = {
468
+ "base_info": {"channel_version": "2.0.0"},
469
+ "get_updates_buf": updates_buf,
470
+ }
471
+
472
+ async with self._session.post(
473
+ url, json=payload, headers=headers, timeout=POLL_TIMEOUT + 10
474
+ ) as resp:
475
+ data = await resp.json()
476
+
477
+ # 检查会话过期
478
+ errcode = data.get("errcode", 0) or data.get("ret", 0)
479
+ if errcode == -14:
480
+ raise SessionExpiredError("会话已过期")
481
+
482
+ new_buf = data.get("get_updates_buf", updates_buf)
483
+ messages = data.get("messages", data.get("data", []))
484
+
485
+ for msg_data in messages:
486
+ await self._handle_message(msg_data)
487
+
488
+ return new_buf
489
+
490
+ async def _handle_message(self, msg_data: dict):
491
+ """处理单条消息"""
492
+ try:
493
+ msg_id = str(msg_data.get("msg_id", ""))
494
+ from_user = msg_data.get("from_user", "")
495
+ content = ""
496
+ context_token = msg_data.get("context_token", "")
497
+ msg_type = msg_data.get("msg_type", 0)
498
+
499
+ # 提取文本内容
500
+ item_list = msg_data.get("item_list", [])
501
+ for item in item_list:
502
+ if item.get("type") == 1: # 文本
503
+ text_item = item.get("text_item", {})
504
+ content = text_item.get("text", "")
505
+ elif item.get("type") == 2: # 图片
506
+ content = "[图片]"
507
+ elif item.get("type") == 3: # 文件
508
+ content = f"[文件: {item.get('file_item', {}).get('name', '未知')}]"
509
+
510
+ if not msg_id or not from_user:
511
+ return
512
+
513
+ # 去重检查(5分钟滑动窗口)
514
+ if msg_id in self._msg_dedup:
515
+ return
516
+ self._msg_dedup.add(msg_id)
517
+ # 保持去重集合不超过 1000 项
518
+ if len(self._msg_dedup) > 1000:
519
+ self._msg_dedup.clear()
520
+
521
+ # 访问策略检查
522
+ if self._dm_policy == "disabled":
523
+ return
524
+ if self._dm_policy == "allowlist" and from_user not in self._allowed_users:
525
+ logger.info("忽略来自 %s 的消息(不在白名单中)", from_user)
526
+ return
527
+
528
+ message = WeChatMessage(
529
+ msg_id=msg_id,
530
+ from_user=from_user,
531
+ content=content,
532
+ context_token=context_token,
533
+ msg_type=msg_type,
534
+ )
535
+
536
+ # 更新 context_token 缓存
537
+ self._context_tokens[from_user] = context_token
538
+ _save_context_tokens(self._context_tokens)
539
+
540
+ # 打印收到消息
541
+ print(f"\n[微信消息] 来自 {from_user}: {content[:100]}")
542
+
543
+ # 显示正在输入
544
+ typing_ticket = self._typing_cache.get(from_user)
545
+ if not typing_ticket:
546
+ typing_ticket = await get_typing_ticket(
547
+ self._session, self._creds.bot_token, from_user, self._creds.base_url
548
+ )
549
+ if typing_ticket:
550
+ self._typing_cache[from_user] = typing_ticket
551
+
552
+ if typing_ticket:
553
+ await send_typing(
554
+ self._session, self._creds.bot_token, from_user, typing_ticket, 1, self._creds.base_url
555
+ )
556
+
557
+ # 调用消息处理回调(支持同步和异步)
558
+ if self._on_message:
559
+ try:
560
+ result = self._on_message(message)
561
+ if isinstance(result, Awaitable):
562
+ reply = await result
563
+ else:
564
+ reply = result
565
+ except Exception as e:
566
+ logger.exception("消息处理回调异常")
567
+ reply = f"处理消息时出错: {e}"
568
+
569
+ # 停止输入状态
570
+ if typing_ticket:
571
+ await send_typing(
572
+ self._session,
573
+ self._creds.bot_token,
574
+ from_user,
575
+ typing_ticket,
576
+ 2,
577
+ self._creds.base_url,
578
+ )
579
+
580
+ # 发送回复
581
+ if reply:
582
+ success = await send_text(
583
+ self._session,
584
+ self._creds.bot_token,
585
+ from_user,
586
+ reply,
587
+ context_token,
588
+ self._creds.base_url,
589
+ )
590
+ if success:
591
+ print(f" → 已回复: {reply[:100]}...")
592
+ else:
593
+ print(" → 回复发送失败")
594
+
595
+ except Exception as e:
596
+ logger.exception("处理消息异常")
597
+
598
+ async def close(self):
599
+ """关闭桥接器,释放资源"""
600
+ self._running = False
601
+ if self._session:
602
+ await self._session.close()
603
+ self._session = None
604
+
605
+ async def __aenter__(self):
606
+ self._session = aiohttp.ClientSession()
607
+ return self
608
+
609
+ async def __aexit__(self, *args):
610
+ await self.close()
611
+
612
+
613
+ class SessionExpiredError(Exception):
614
+ """会话过期异常"""
615
+ pass
File without changes