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 +0 -0
- fody_agent/bridge/__init__.py +0 -0
- fody_agent/bridge/weixin.py +615 -0
- fody_agent/cli/__init__.py +0 -0
- fody_agent/cli/app.py +161 -0
- fody_agent/cli/render.py +31 -0
- fody_agent/core/__init__.py +0 -0
- fody_agent/core/agent.py +256 -0
- fody_agent/llm/__init__.py +24 -0
- fody_agent/llm/anthropic_client.py +143 -0
- fody_agent/llm/base.py +73 -0
- fody_agent/llm/openai_client.py +102 -0
- fody_agent/llm/prompts.py +79 -0
- fody_agent/main.py +13 -0
- fody_agent/memory/__init__.py +0 -0
- fody_agent/server/__init__.py +0 -0
- fody_agent/skills/__init__.py +19 -0
- fody_agent/skills/skill_utils.py +232 -0
- fody_agent/tools/__init__.py +11 -0
- fody_agent/tools/base.py +40 -0
- fody_agent/tools/core_tools.py +220 -0
- fody_agent/tools/skills_tool.py +341 -0
- fody_agent/utils/__init__.py +0 -0
- fody_agent/utils/config.py +43 -0
- fody_agent/utils/logger.py +39 -0
- fody_agent-0.1.0.dist-info/METADATA +19 -0
- fody_agent-0.1.0.dist-info/RECORD +29 -0
- fody_agent-0.1.0.dist-info/WHEEL +4 -0
- fody_agent-0.1.0.dist-info/entry_points.txt +2 -0
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
|