goofish-cli 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.
- goofish_cli/__init__.py +1 -0
- goofish_cli/cli.py +119 -0
- goofish_cli/commands/__init__.py +0 -0
- goofish_cli/commands/auth/__init__.py +0 -0
- goofish_cli/commands/auth/login.py +66 -0
- goofish_cli/commands/auth/reset_guard.py +16 -0
- goofish_cli/commands/auth/status.py +40 -0
- goofish_cli/commands/category/__init__.py +0 -0
- goofish_cli/commands/category/recommend.py +64 -0
- goofish_cli/commands/item/__init__.py +0 -0
- goofish_cli/commands/item/delete.py +32 -0
- goofish_cli/commands/item/get.py +34 -0
- goofish_cli/commands/item/publish.py +175 -0
- goofish_cli/commands/location/__init__.py +0 -0
- goofish_cli/commands/location/default.py +40 -0
- goofish_cli/commands/media/__init__.py +0 -0
- goofish_cli/commands/media/upload.py +53 -0
- goofish_cli/commands/message/__init__.py +0 -0
- goofish_cli/commands/message/history.py +23 -0
- goofish_cli/commands/message/send.py +102 -0
- goofish_cli/commands/message/watch.py +58 -0
- goofish_cli/core/__init__.py +26 -0
- goofish_cli/core/errors.py +31 -0
- goofish_cli/core/guard.py +63 -0
- goofish_cli/core/limiter.py +60 -0
- goofish_cli/core/mtop.py +131 -0
- goofish_cli/core/output.py +82 -0
- goofish_cli/core/registry.py +78 -0
- goofish_cli/core/session.py +88 -0
- goofish_cli/core/sign.py +37 -0
- goofish_cli/core/strategy.py +10 -0
- goofish_cli/core/token.py +88 -0
- goofish_cli/core/ws.py +493 -0
- goofish_cli/mcp_server.py +56 -0
- goofish_cli/static/goofish_js_version_2.js +568 -0
- goofish_cli-0.1.0.dist-info/METADATA +279 -0
- goofish_cli-0.1.0.dist-info/RECORD +41 -0
- goofish_cli-0.1.0.dist-info/WHEEL +4 -0
- goofish_cli-0.1.0.dist-info/entry_points.txt +3 -0
- goofish_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- goofish_cli-0.1.0.dist-info/licenses/NOTICE +11 -0
goofish_cli/core/ws.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""闲鱼 IM WebSocket 运行时。
|
|
2
|
+
|
|
3
|
+
协议:LWP(Long-polling WebSocket Protocol,钉钉体系)——JSON 而非 Protobuf。
|
|
4
|
+
端点:wss://wss-goofish.dingtalk.com/
|
|
5
|
+
|
|
6
|
+
职责:
|
|
7
|
+
- 连接 + /reg 注册(accessToken 鉴权)
|
|
8
|
+
- 心跳 /!(15s)
|
|
9
|
+
- 下行 ack(code=200 回包,透传 mid/sid/app-key/ua/dt)
|
|
10
|
+
- 上行业务帧:send_msg / list_user_messages / create_chat
|
|
11
|
+
- 收包解密回退(syncPushPackage.data[].data → json 或 decrypt)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
20
|
+
from contextlib import asynccontextmanager, suppress
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import websockets
|
|
24
|
+
from loguru import logger
|
|
25
|
+
from websockets.asyncio.client import ClientConnection
|
|
26
|
+
|
|
27
|
+
from goofish_cli.core.session import Session
|
|
28
|
+
from goofish_cli.core.sign import decrypt, generate_mid, generate_uuid
|
|
29
|
+
from goofish_cli.core.token import IM_APP_KEY, get_access_token
|
|
30
|
+
|
|
31
|
+
WS_URL = "wss://wss-goofish.dingtalk.com/"
|
|
32
|
+
UA_WEB = (
|
|
33
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
34
|
+
"(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
|
35
|
+
)
|
|
36
|
+
UA_IM = (
|
|
37
|
+
UA_WEB
|
|
38
|
+
+ " DingTalk(2.1.5) OS(Windows/10) Browser(Chrome/133.0.0.0) "
|
|
39
|
+
+ "DingWeb/2.1.5 IMPaaS DingWeb/2.1.5"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cookie_header(session: Session) -> str:
|
|
44
|
+
return "; ".join(f"{k}={v}" for k, v in session.http.cookies.get_dict().items())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _handshake_headers(session: Session) -> dict[str, str]:
|
|
48
|
+
return {
|
|
49
|
+
"Cookie": _cookie_header(session),
|
|
50
|
+
"Host": "wss-goofish.dingtalk.com",
|
|
51
|
+
"Connection": "Upgrade",
|
|
52
|
+
"Pragma": "no-cache",
|
|
53
|
+
"Cache-Control": "no-cache",
|
|
54
|
+
"User-Agent": UA_WEB,
|
|
55
|
+
"Origin": "https://www.goofish.com",
|
|
56
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
57
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@asynccontextmanager
|
|
62
|
+
async def connect(session: Session) -> AsyncIterator[ClientConnection]:
|
|
63
|
+
"""建立 WebSocket 连接(未 reg)。外层自己 reg + heartbeat。"""
|
|
64
|
+
async with websockets.connect(
|
|
65
|
+
WS_URL,
|
|
66
|
+
additional_headers=_handshake_headers(session),
|
|
67
|
+
ping_interval=None, # 禁用 WS ping,我们走 LWP /! 心跳
|
|
68
|
+
max_size=4 * 1024 * 1024,
|
|
69
|
+
) as ws:
|
|
70
|
+
yield ws
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def register(ws: ClientConnection, session: Session, token: str) -> None:
|
|
74
|
+
"""/reg + /r/SyncStatus/ackDiff。发完立即返回,后续由外层 recv 循环消化回包。"""
|
|
75
|
+
reg = {
|
|
76
|
+
"lwp": "/reg",
|
|
77
|
+
"headers": {
|
|
78
|
+
"cache-header": "app-key token ua wv",
|
|
79
|
+
"app-key": IM_APP_KEY,
|
|
80
|
+
"token": token,
|
|
81
|
+
"ua": UA_IM,
|
|
82
|
+
"dt": "j",
|
|
83
|
+
"wv": "im:3,au:3,sy:6",
|
|
84
|
+
"sync": "0,0;0;0;",
|
|
85
|
+
"did": session.device_id,
|
|
86
|
+
"mid": generate_mid(),
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
await ws.send(json.dumps(reg))
|
|
90
|
+
current_ms = int(time.time() * 1000)
|
|
91
|
+
ack_diff = {
|
|
92
|
+
"lwp": "/r/SyncStatus/ackDiff",
|
|
93
|
+
"headers": {"mid": generate_mid()},
|
|
94
|
+
"body": [
|
|
95
|
+
{
|
|
96
|
+
"pipeline": "sync",
|
|
97
|
+
"tooLong2Tag": "PNM,1",
|
|
98
|
+
"channel": "sync",
|
|
99
|
+
"topic": "sync",
|
|
100
|
+
"highPts": 0,
|
|
101
|
+
"pts": current_ms * 1000,
|
|
102
|
+
"seq": 0,
|
|
103
|
+
"timestamp": current_ms,
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
await ws.send(json.dumps(ack_diff))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def heartbeat_loop(ws: ClientConnection, interval: float = 15.0) -> None:
|
|
111
|
+
"""LWP /! 空心跳,默认 15s 一次。"""
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
await ws.send(json.dumps({"lwp": "/!", "headers": {"mid": generate_mid()}}))
|
|
115
|
+
except Exception as e: # noqa: BLE001
|
|
116
|
+
logger.debug(f"heartbeat send failed: {e}")
|
|
117
|
+
return
|
|
118
|
+
await asyncio.sleep(interval)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_ack(msg: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
"""对下行包回 code=200 ack,透传关键 header。"""
|
|
123
|
+
h = msg.get("headers") or {}
|
|
124
|
+
ack: dict[str, Any] = {
|
|
125
|
+
"code": 200,
|
|
126
|
+
"headers": {
|
|
127
|
+
"mid": h.get("mid") or generate_mid(),
|
|
128
|
+
"sid": h.get("sid", ""),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
for k in ("app-key", "ua", "dt"):
|
|
132
|
+
if k in h:
|
|
133
|
+
ack["headers"][k] = h[k]
|
|
134
|
+
return ack
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def send_text(
|
|
138
|
+
ws: ClientConnection, *, myid: str, cid: str, toid: str, text: str
|
|
139
|
+
) -> str:
|
|
140
|
+
"""发一条文本消息。返回用于追踪的 mid。"""
|
|
141
|
+
return await _send_custom(ws, myid=myid, cid=cid, toid=toid, ctype=1, payload={
|
|
142
|
+
"contentType": 1, "text": {"text": text},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def send_image(
|
|
147
|
+
ws: ClientConnection,
|
|
148
|
+
*,
|
|
149
|
+
myid: str,
|
|
150
|
+
cid: str,
|
|
151
|
+
toid: str,
|
|
152
|
+
url: str,
|
|
153
|
+
width: int,
|
|
154
|
+
height: int,
|
|
155
|
+
) -> str:
|
|
156
|
+
return await _send_custom(ws, myid=myid, cid=cid, toid=toid, ctype=2, payload={
|
|
157
|
+
"contentType": 2,
|
|
158
|
+
"image": {"pics": [{"type": 0, "url": url, "width": width, "height": height}]},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _send_custom(
|
|
163
|
+
ws: ClientConnection,
|
|
164
|
+
*,
|
|
165
|
+
myid: str,
|
|
166
|
+
cid: str,
|
|
167
|
+
toid: str,
|
|
168
|
+
ctype: int,
|
|
169
|
+
payload: dict[str, Any],
|
|
170
|
+
) -> str:
|
|
171
|
+
mid = generate_mid()
|
|
172
|
+
data_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
|
|
173
|
+
msg = {
|
|
174
|
+
"lwp": "/r/MessageSend/sendByReceiverScope",
|
|
175
|
+
"headers": {"mid": mid},
|
|
176
|
+
"body": [
|
|
177
|
+
{
|
|
178
|
+
"uuid": generate_uuid(),
|
|
179
|
+
"cid": f"{cid}@goofish",
|
|
180
|
+
"conversationType": 1,
|
|
181
|
+
"content": {
|
|
182
|
+
"contentType": 101,
|
|
183
|
+
"custom": {"type": ctype, "data": data_b64},
|
|
184
|
+
},
|
|
185
|
+
"redPointPolicy": 0,
|
|
186
|
+
"extension": {"extJson": "{}"},
|
|
187
|
+
"ctx": {"appVersion": "1.0", "platform": "web"},
|
|
188
|
+
"mtags": {},
|
|
189
|
+
"msgReadStatusSetting": 1,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"actualReceivers": [f"{toid}@goofish", f"{myid}@goofish"],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
await ws.send(json.dumps(msg))
|
|
197
|
+
return mid
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def create_chat(ws: ClientConnection, *, myid: str, toid: str, item_id: str) -> str:
|
|
201
|
+
"""创建/复用单聊会话。返回 mid 供回包配对(若需要)。"""
|
|
202
|
+
mid = generate_mid()
|
|
203
|
+
msg = {
|
|
204
|
+
"lwp": "/r/SingleChatConversation/create",
|
|
205
|
+
"headers": {"mid": mid},
|
|
206
|
+
"body": [
|
|
207
|
+
{
|
|
208
|
+
"pairFirst": f"{toid}@goofish",
|
|
209
|
+
"pairSecond": f"{myid}@goofish",
|
|
210
|
+
"bizType": "1",
|
|
211
|
+
"extension": {"itemId": item_id},
|
|
212
|
+
"ctx": {"appVersion": "1.0", "platform": "web"},
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
await ws.send(json.dumps(msg))
|
|
217
|
+
return mid
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def list_user_messages(
|
|
221
|
+
session: Session, cid: str, limit_per_page: int = 20
|
|
222
|
+
) -> list[dict[str, Any]]:
|
|
223
|
+
"""一次性拉指定会话的历史消息(翻页直到 hasMore=0)。"""
|
|
224
|
+
token = get_access_token(session)
|
|
225
|
+
messages: list[dict[str, Any]] = []
|
|
226
|
+
send_mid = generate_mid()
|
|
227
|
+
req = {
|
|
228
|
+
"lwp": "/r/MessageManager/listUserMessages",
|
|
229
|
+
"headers": {"mid": send_mid},
|
|
230
|
+
"body": [f"{cid}@goofish", False, 9007199254740991, limit_per_page, False],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async with connect(session) as ws:
|
|
234
|
+
await register(ws, session, token)
|
|
235
|
+
hb = asyncio.create_task(heartbeat_loop(ws))
|
|
236
|
+
try:
|
|
237
|
+
async for raw in ws:
|
|
238
|
+
try:
|
|
239
|
+
msg = json.loads(raw)
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
continue
|
|
242
|
+
with suppress(Exception):
|
|
243
|
+
await ws.send(json.dumps(build_ack(msg)))
|
|
244
|
+
|
|
245
|
+
lwp = msg.get("lwp")
|
|
246
|
+
if lwp == "/s/vulcan":
|
|
247
|
+
await ws.send(json.dumps(req))
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
recv_mid = (msg.get("headers") or {}).get("mid", "")
|
|
251
|
+
if recv_mid != send_mid:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
body = msg.get("body") or {}
|
|
255
|
+
models = body.get("userMessageModels") or []
|
|
256
|
+
for um in models:
|
|
257
|
+
try:
|
|
258
|
+
ext = um["message"]["extension"]
|
|
259
|
+
data_b64 = um["message"]["content"]["custom"]["data"]
|
|
260
|
+
payload = json.loads(base64.b64decode(data_b64).decode("utf-8"))
|
|
261
|
+
messages.insert(0, {
|
|
262
|
+
"send_user_id": ext.get("senderUserId", ""),
|
|
263
|
+
"send_user_name": ext.get("reminderTitle", ""),
|
|
264
|
+
"message": payload,
|
|
265
|
+
})
|
|
266
|
+
except Exception as e: # noqa: BLE001
|
|
267
|
+
logger.debug(f"parse history item failed: {e}")
|
|
268
|
+
|
|
269
|
+
has_more = body.get("hasMore") == 1
|
|
270
|
+
if has_more:
|
|
271
|
+
send_mid = generate_mid()
|
|
272
|
+
req["headers"]["mid"] = send_mid
|
|
273
|
+
req["body"][2] = body.get("nextCursor")
|
|
274
|
+
await ws.send(json.dumps(req))
|
|
275
|
+
else:
|
|
276
|
+
break
|
|
277
|
+
finally:
|
|
278
|
+
hb.cancel()
|
|
279
|
+
return messages
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _decode_one(raw: str) -> dict[str, Any] | None:
|
|
283
|
+
"""三种解码尝试:明文 JSON → base64(JSON) → decrypt。"""
|
|
284
|
+
if not isinstance(raw, str):
|
|
285
|
+
return None
|
|
286
|
+
try:
|
|
287
|
+
return json.loads(raw)
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
pass
|
|
290
|
+
try:
|
|
291
|
+
return json.loads(base64.b64decode(raw))
|
|
292
|
+
except Exception: # noqa: BLE001
|
|
293
|
+
pass
|
|
294
|
+
try:
|
|
295
|
+
return json.loads(decrypt(raw))
|
|
296
|
+
except Exception as e: # noqa: BLE001
|
|
297
|
+
logger.debug(f"decrypt push failed: {e}")
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def extract_push_messages(msg: dict[str, Any]) -> list[dict[str, Any]]:
|
|
302
|
+
"""解析 /s/vulcan 推送包。**一帧可能包含多条 push**(实测 32 条/帧很常见)。
|
|
303
|
+
|
|
304
|
+
三种编码尝试:明文 JSON / base64(JSON) / decrypt。任一成功即收录。
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
data_list = msg["body"]["syncPushPackage"]["data"]
|
|
308
|
+
except (KeyError, TypeError):
|
|
309
|
+
return []
|
|
310
|
+
if not isinstance(data_list, list):
|
|
311
|
+
return []
|
|
312
|
+
out: list[dict[str, Any]] = []
|
|
313
|
+
for item in data_list:
|
|
314
|
+
if not isinstance(item, dict):
|
|
315
|
+
continue
|
|
316
|
+
decoded = _decode_one(item.get("data", ""))
|
|
317
|
+
if decoded is not None:
|
|
318
|
+
out.append(decoded)
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def extract_push_message(msg: dict[str, Any]) -> dict[str, Any] | None:
|
|
323
|
+
"""[deprecated 保留] 只取第一条 —— 新代码请用 extract_push_messages。"""
|
|
324
|
+
batch = extract_push_messages(msg)
|
|
325
|
+
return batch[0] if batch else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def extract_incoming_text(decoded: dict[str, Any]) -> dict[str, Any] | None:
|
|
329
|
+
"""从解码后的推送包里提取一条消息事件。
|
|
330
|
+
|
|
331
|
+
两种格式兼容:
|
|
332
|
+
- 新 web 端(base64(JSON)):顶层有 operation.content.contentType + sessionId
|
|
333
|
+
- 老协议(decrypt 返回):message["1"]["10"] 挂 reminder*/sender*/cid
|
|
334
|
+
"""
|
|
335
|
+
# —— 新格式 ——
|
|
336
|
+
op = decoded.get("operation") if isinstance(decoded, dict) else None
|
|
337
|
+
if isinstance(op, dict):
|
|
338
|
+
content = op.get("content") or {}
|
|
339
|
+
content_type = content.get("contentType")
|
|
340
|
+
sess = op.get("sessionInfo") or {}
|
|
341
|
+
sender = op.get("senderInfo") or {}
|
|
342
|
+
reminder = content.get("reminder") or {}
|
|
343
|
+
cid = str(decoded.get("sessionId") or sess.get("sessionId") or "")
|
|
344
|
+
# contentType=1 文本;2 图片;8 会话激活;101 是 custom
|
|
345
|
+
text = ""
|
|
346
|
+
if content_type == 1:
|
|
347
|
+
text = (content.get("text") or {}).get("text", "") or reminder.get("reminderContent", "")
|
|
348
|
+
elif content_type == 101:
|
|
349
|
+
# custom 包装:data 是 base64(payload)
|
|
350
|
+
custom = content.get("custom") or {}
|
|
351
|
+
data_b64 = custom.get("data", "")
|
|
352
|
+
if data_b64:
|
|
353
|
+
try:
|
|
354
|
+
payload = json.loads(base64.b64decode(data_b64))
|
|
355
|
+
text = (payload.get("text") or {}).get("text", "")
|
|
356
|
+
except Exception: # noqa: BLE001
|
|
357
|
+
text = ""
|
|
358
|
+
text = text or reminder.get("reminderContent", "")
|
|
359
|
+
else:
|
|
360
|
+
text = reminder.get("reminderContent", "")
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
"event": "message",
|
|
364
|
+
"cid": cid,
|
|
365
|
+
"content_type": content_type,
|
|
366
|
+
"send_user_id": str(sender.get("senderUserId", "") or reminder.get("senderUserId", "")),
|
|
367
|
+
"send_user_name": reminder.get("reminderTitle", ""),
|
|
368
|
+
"send_message": text,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# —— 老格式(decrypt 后嵌套带正文)——
|
|
372
|
+
one = decoded.get("1") if isinstance(decoded, dict) else None
|
|
373
|
+
if isinstance(one, dict):
|
|
374
|
+
node = one.get("10")
|
|
375
|
+
if isinstance(node, dict):
|
|
376
|
+
cid_full = one.get("2", "")
|
|
377
|
+
cid = cid_full.split("@")[0] if isinstance(cid_full, str) else ""
|
|
378
|
+
return {
|
|
379
|
+
"event": "message",
|
|
380
|
+
"cid": cid,
|
|
381
|
+
"send_user_id": node.get("senderUserId", ""),
|
|
382
|
+
"send_user_name": node.get("reminderTitle", ""),
|
|
383
|
+
"send_message": node.get("reminderContent", ""),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def extract_meta_event(decoded: dict[str, Any]) -> dict[str, Any] | None:
|
|
390
|
+
"""识别 /s/sync 平结构里的元事件:new_msg 通知 / read receipt。
|
|
391
|
+
|
|
392
|
+
new_msg 通知(无正文):{"1":"cid@goofish","2":1,"3":"msgId","4":"ts"}
|
|
393
|
+
read receipt: {"1":[msgIds],"2":2,"3":"cid@goofish","4":1,"5":"ts"}
|
|
394
|
+
|
|
395
|
+
没识别出来返回 None;调用方再尝试 extract_incoming_text。
|
|
396
|
+
"""
|
|
397
|
+
if not isinstance(decoded, dict):
|
|
398
|
+
return None
|
|
399
|
+
one, two, three = decoded.get("1"), decoded.get("2"), decoded.get("3")
|
|
400
|
+
|
|
401
|
+
# read receipt
|
|
402
|
+
if isinstance(one, list) and two == 2 and isinstance(three, str) and three.endswith("@goofish"):
|
|
403
|
+
return {
|
|
404
|
+
"event": "read",
|
|
405
|
+
"cid": three.split("@")[0],
|
|
406
|
+
"msg_ids": [str(x) for x in one],
|
|
407
|
+
"status": decoded.get("4"),
|
|
408
|
+
"ts": str(decoded.get("5", "")),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
# new_msg 轻量通知(cid 在 "1",msgId 在 "3")
|
|
412
|
+
if isinstance(one, str) and one.endswith("@goofish") and two == 1 and isinstance(three, str):
|
|
413
|
+
return {
|
|
414
|
+
"event": "new_msg",
|
|
415
|
+
"cid": one.split("@")[0],
|
|
416
|
+
"msg_id": three,
|
|
417
|
+
"ts": str(decoded.get("4", "")),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
MessageHandler = Callable[[dict[str, Any], ClientConnection], Awaitable[None]]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def run_forever(
|
|
427
|
+
session: Session,
|
|
428
|
+
handler: MessageHandler | None = None,
|
|
429
|
+
*,
|
|
430
|
+
refresh_every: float = 600.0,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""常驻 IM 长连接。每条下行包走 ack,然后丢给 handler。
|
|
433
|
+
|
|
434
|
+
handler(msg, ws) —— msg 是 json.loads 后的原始帧;需要业务自己去 extract_push_message。
|
|
435
|
+
refresh_every 秒调一次 mtop.taobao.idlemessage.pc.loginuser.get 保活。
|
|
436
|
+
|
|
437
|
+
外层套重连循环:服务端会不定时关连接(观察约每 10~30 分钟一次),
|
|
438
|
+
捕获 ConnectionClosed 后指数退避重连 + 重新 reg。
|
|
439
|
+
"""
|
|
440
|
+
from goofish_cli.core.token import refresh_login
|
|
441
|
+
|
|
442
|
+
async def _keepalive() -> None:
|
|
443
|
+
while True:
|
|
444
|
+
await asyncio.sleep(refresh_every)
|
|
445
|
+
try:
|
|
446
|
+
refresh_login(session)
|
|
447
|
+
logger.debug("login refreshed")
|
|
448
|
+
except Exception as e: # noqa: BLE001
|
|
449
|
+
logger.warning(f"login refresh failed: {e}")
|
|
450
|
+
|
|
451
|
+
async def _one_session() -> None:
|
|
452
|
+
token = get_access_token(session)
|
|
453
|
+
async with connect(session) as ws:
|
|
454
|
+
await register(ws, session, token)
|
|
455
|
+
hb = asyncio.create_task(heartbeat_loop(ws))
|
|
456
|
+
try:
|
|
457
|
+
async for raw in ws:
|
|
458
|
+
try:
|
|
459
|
+
msg = json.loads(raw)
|
|
460
|
+
except json.JSONDecodeError:
|
|
461
|
+
continue
|
|
462
|
+
with suppress(Exception):
|
|
463
|
+
await ws.send(json.dumps(build_ack(msg)))
|
|
464
|
+
if handler is not None:
|
|
465
|
+
try:
|
|
466
|
+
await handler(msg, ws)
|
|
467
|
+
except Exception as e: # noqa: BLE001
|
|
468
|
+
logger.exception(f"handler error: {e}")
|
|
469
|
+
finally:
|
|
470
|
+
hb.cancel()
|
|
471
|
+
|
|
472
|
+
ka = asyncio.create_task(_keepalive())
|
|
473
|
+
backoff = 1.0
|
|
474
|
+
try:
|
|
475
|
+
while True:
|
|
476
|
+
try:
|
|
477
|
+
logger.info("WS 连接中…")
|
|
478
|
+
await _one_session()
|
|
479
|
+
logger.info("WS 正常退出,3s 后重连")
|
|
480
|
+
backoff = 1.0
|
|
481
|
+
await asyncio.sleep(3)
|
|
482
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
483
|
+
logger.warning(f"WS 断连:{e};{backoff:.1f}s 后重连")
|
|
484
|
+
await asyncio.sleep(backoff)
|
|
485
|
+
backoff = min(backoff * 2, 30.0)
|
|
486
|
+
except asyncio.CancelledError:
|
|
487
|
+
raise
|
|
488
|
+
except Exception as e: # noqa: BLE001
|
|
489
|
+
logger.exception(f"WS 会话异常:{e};{backoff:.1f}s 后重连")
|
|
490
|
+
await asyncio.sleep(backoff)
|
|
491
|
+
backoff = min(backoff * 2, 30.0)
|
|
492
|
+
finally:
|
|
493
|
+
ka.cancel()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""FastMCP 入口。扫描同一 registry → 每个 Command 注册为 @mcp.tool()。
|
|
2
|
+
|
|
3
|
+
启动:`uvx goofish-mcp` 或 `python -m goofish_cli.mcp_server`
|
|
4
|
+
Claude Code 配置:
|
|
5
|
+
{
|
|
6
|
+
"mcpServers": {
|
|
7
|
+
"goofish": { "command": "uvx", "args": ["goofish-mcp"] }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from goofish_cli.core import GoofishError, iter_commands
|
|
19
|
+
from goofish_cli.core.registry import discover
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP("goofish")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _register_all() -> None:
|
|
25
|
+
discover()
|
|
26
|
+
for cmd in iter_commands():
|
|
27
|
+
_register_one(cmd)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _register_one(cmd) -> None:
|
|
31
|
+
"""把一条 registry Command 注册为 MCP tool,保留参数签名。"""
|
|
32
|
+
tool_name = f"{cmd.namespace}_{cmd.name}".replace("-", "_")
|
|
33
|
+
doc = cmd.description
|
|
34
|
+
sig = inspect.signature(cmd.func)
|
|
35
|
+
|
|
36
|
+
async def handler(**kwargs: Any) -> dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
result = cmd.func(**kwargs)
|
|
39
|
+
return {"ok": True, "data": result}
|
|
40
|
+
except GoofishError as e:
|
|
41
|
+
return {"ok": False, "error_type": type(e).__name__, "message": str(e)}
|
|
42
|
+
|
|
43
|
+
handler.__name__ = tool_name
|
|
44
|
+
handler.__doc__ = doc
|
|
45
|
+
handler.__signature__ = sig # type: ignore[attr-defined]
|
|
46
|
+
|
|
47
|
+
mcp.tool(name=tool_name, description=doc)(handler)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main() -> None:
|
|
51
|
+
_register_all()
|
|
52
|
+
mcp.run()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|