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.
Files changed (41) hide show
  1. goofish_cli/__init__.py +1 -0
  2. goofish_cli/cli.py +119 -0
  3. goofish_cli/commands/__init__.py +0 -0
  4. goofish_cli/commands/auth/__init__.py +0 -0
  5. goofish_cli/commands/auth/login.py +66 -0
  6. goofish_cli/commands/auth/reset_guard.py +16 -0
  7. goofish_cli/commands/auth/status.py +40 -0
  8. goofish_cli/commands/category/__init__.py +0 -0
  9. goofish_cli/commands/category/recommend.py +64 -0
  10. goofish_cli/commands/item/__init__.py +0 -0
  11. goofish_cli/commands/item/delete.py +32 -0
  12. goofish_cli/commands/item/get.py +34 -0
  13. goofish_cli/commands/item/publish.py +175 -0
  14. goofish_cli/commands/location/__init__.py +0 -0
  15. goofish_cli/commands/location/default.py +40 -0
  16. goofish_cli/commands/media/__init__.py +0 -0
  17. goofish_cli/commands/media/upload.py +53 -0
  18. goofish_cli/commands/message/__init__.py +0 -0
  19. goofish_cli/commands/message/history.py +23 -0
  20. goofish_cli/commands/message/send.py +102 -0
  21. goofish_cli/commands/message/watch.py +58 -0
  22. goofish_cli/core/__init__.py +26 -0
  23. goofish_cli/core/errors.py +31 -0
  24. goofish_cli/core/guard.py +63 -0
  25. goofish_cli/core/limiter.py +60 -0
  26. goofish_cli/core/mtop.py +131 -0
  27. goofish_cli/core/output.py +82 -0
  28. goofish_cli/core/registry.py +78 -0
  29. goofish_cli/core/session.py +88 -0
  30. goofish_cli/core/sign.py +37 -0
  31. goofish_cli/core/strategy.py +10 -0
  32. goofish_cli/core/token.py +88 -0
  33. goofish_cli/core/ws.py +493 -0
  34. goofish_cli/mcp_server.py +56 -0
  35. goofish_cli/static/goofish_js_version_2.js +568 -0
  36. goofish_cli-0.1.0.dist-info/METADATA +279 -0
  37. goofish_cli-0.1.0.dist-info/RECORD +41 -0
  38. goofish_cli-0.1.0.dist-info/WHEEL +4 -0
  39. goofish_cli-0.1.0.dist-info/entry_points.txt +3 -0
  40. goofish_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
  41. 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()