dsh-deepmoyu 2.7.0 → 2.7.2

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.
@@ -149,8 +149,10 @@ def _guard_blocked(reservation, kind="send", why=""):
149
149
  return json.dumps({
150
150
  "success": False, "already_replied": True, "retry_blocked": True,
151
151
  "guard_status": "unknown", "seen_at": at,
152
+ "slot_key": reservation.get("key", ""),
152
153
  "error": "上一次发出去的结果不确定(超时 / 回执不明)—— 不许自动重发",
153
- "hint": "先自己去评论区 / 私信里看一眼到底发出去没有;确认没有,再手工重试",
154
+ "hint": ("先自己去评论区 / 私信里看一眼到底发出去没有;**确认没发出去**后,用 "
155
+ "bili_guard_status(release=['这条的 slot_key']) 人工解除 unknown 槽位(P1-2),再重试"),
154
156
  }, ensure_ascii=False)
155
157
  if status == "pending":
156
158
  return json.dumps({
@@ -167,6 +169,18 @@ def _guard_blocked(reservation, kind="send", why=""):
167
169
  }, ensure_ascii=False)
168
170
 
169
171
 
172
+ # P3-46: 守卫库被锁(SQLite busy>15s)时的统一 fail-closed 话术,措辞对齐 bili_subtitle
173
+ # 的「判重账本不可用」兜底回执 —— 原始 traceback 绝不能直接漏给模型。
174
+ _GUARD_LOCKED_ERROR = "判重账本暂不可用(可能被锁),先别重发;稍后用 bili_guard_status 核对坑位状态"
175
+
176
+
177
+ def _guard_unavailable(exc) -> str:
178
+ """占坑阶段守卫异常 → JSON 错误回执(此时还没发任何东西,可以稍后重试)。"""
179
+ return json.dumps({"success": False, "error": _GUARD_LOCKED_ERROR,
180
+ "detail": f"{type(exc).__name__}: {exc}",
181
+ "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
182
+
183
+
170
184
  async def _guarded_send(guard, reservation, runner, record=None):
171
185
  """在**已经占好坑**的前提下真正发出去,并如实落账。
172
186
 
@@ -196,7 +210,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
196
210
  return None
197
211
 
198
212
  def _rejected(error, result, code):
199
- pace = guard.reject_send(reservation, code)
213
+ try:
214
+ pace = guard.reject_send(reservation, code)
215
+ except Exception as exc:
216
+ # P3-46: 拒绝落账失败(库被锁)不能裸抛 —— 事务已回滚、坑位留在 pending 正好挡重发,
217
+ # 回执转 fail-closed(guard_status=unknown ⇒ 调用方会回「别自动重发」的话术)。
218
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
219
+ "result": result, "code": code, "ledger_detail": f"{type(exc).__name__}: {exc}"}
200
220
  out = {"guard_status": "rejected", "error": error, "retry_blocked": False,
201
221
  "result": result, "code": code}
202
222
  if code == 49044:
@@ -232,7 +252,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
232
252
  return {"guard_status": "unknown", "error": str(result.get("error") or "没有确定的回执"),
233
253
  "retry_blocked": True, "result": result, "code": code}
234
254
 
235
- guard.commit_send(reservation, record)
255
+ try:
256
+ guard.commit_send(reservation, record)
257
+ except Exception as exc:
258
+ # P3-46: 落账失败(库被锁 >15s)不能裸抛 —— 发送本身可能已成功,坑位留在 pending
259
+ # 挡住重发;回执转 fail-closed JSON 并指引去 bili_guard_status 核对,而不是 traceback。
260
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
261
+ "result": result, "code": None, "ledger_detail": f"{type(exc).__name__}: {exc}"}
236
262
  return {"guard_status": "sent", "error": "", "retry_blocked": False, "result": result, "code": None}
237
263
 
238
264
 
@@ -407,10 +433,13 @@ async def bili_login_check() -> str:
407
433
 
408
434
  elif state == QrCodeLoginEvents.TIMEOUT:
409
435
  _login_session = None
410
- # 清理二维码文件
436
+ # 清理二维码文件(P3-47: Windows 下 PNG 被看图程序占用会 PermissionError,别让它炸回执)
411
437
  qr_file = Path(__file__).parent / "qrcode_login.png"
412
438
  if qr_file.exists():
413
- qr_file.unlink()
439
+ try:
440
+ qr_file.unlink()
441
+ except OSError as e:
442
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
414
443
  return json.dumps({
415
444
  "status": "timeout",
416
445
  "message": "二维码已过期,请重新调用 bili_login 生成新二维码",
@@ -431,10 +460,13 @@ async def bili_login_check() -> str:
431
460
  }, f)
432
461
  os.replace(tmp_cred, CRED_FILE)
433
462
  _login_session = None
434
- # 清理二维码文件
463
+ # 清理二维码文件(P3-47: 删失败只记 stderr —— DONE 分支反复重入比带着孤儿 PNG 重坏了)
435
464
  qr_file = Path(__file__).parent / "qrcode_login.png"
436
465
  if qr_file.exists():
437
- qr_file.unlink()
466
+ try:
467
+ qr_file.unlink()
468
+ except OSError as e:
469
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
438
470
  return json.dumps({
439
471
  "status": "done",
440
472
  "message": "登录成功!凭证已保存,现在可以使用所有B站功能了",
@@ -992,13 +1024,17 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
992
1024
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
993
1025
 
994
1026
  # ② 占坑:同一条评论(rpid)全机器只允许发一次;新评论按 BV 也只允许留一条
995
- guard = _get_runtime_guard()
996
1027
  try:
997
1028
  key = key_video_reply(bvid, rpid)
998
1029
  except Exception as e:
999
1030
  return json.dumps({"success": False, "error": str(e),
1000
1031
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1001
- reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1032
+ try:
1033
+ guard = _get_runtime_guard()
1034
+ # P3-46: SQLite busy>15s 的原始异常不能外泄 —— 包成 fail-closed JSON 回执
1035
+ reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1036
+ except Exception as e:
1037
+ return _guard_unavailable(e)
1002
1038
  if not reservation.get("allowed"):
1003
1039
  return _guard_blocked(reservation, "send",
1004
1040
  "这条评论已经回过了" if rpid != "0" else "这条视频已经留过一条评论了")
@@ -1193,10 +1229,14 @@ async def bili_send_dynamic(
1193
1229
 
1194
1230
  text = text.strip()
1195
1231
  image_paths = [str(path).strip() for path in (images or [])[:9] if str(path or "").strip()]
1196
- guard = _get_runtime_guard()
1197
- reservation = guard.reserve_send(key_moment("dynamic", {
1198
- "text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
1199
- }))
1232
+ try:
1233
+ guard = _get_runtime_guard()
1234
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1235
+ reservation = guard.reserve_send(key_moment("dynamic", {
1236
+ "text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
1237
+ }))
1238
+ except Exception as exc:
1239
+ return _guard_unavailable(exc)
1200
1240
  if not reservation["allowed"]:
1201
1241
  return _guard_blocked(reservation)
1202
1242
  try:
@@ -1272,6 +1312,7 @@ async def bili_upload_video(
1272
1312
 
1273
1313
  Returns:
1274
1314
  上传结果,包含BV号
1315
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1275
1316
  """
1276
1317
  # ---- 参数校验 ----
1277
1318
  if not video_path or not video_path.strip():
@@ -1333,12 +1374,20 @@ async def bili_upload_video(
1333
1374
  async def on_event(data: dict):
1334
1375
  progress_info["phase"] = str(data)
1335
1376
 
1336
- result = await uploader.start()
1377
+ # P2-17: uploader.start() 没有超时的话,挂死的上传在客户端超时后仍在后台继续,
1378
+ # 公开投稿完成却无任何记录 —— 这里给 15 分钟墙钟,超时明确报错。
1379
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1337
1380
  return json.dumps({
1338
1381
  "success": True,
1339
1382
  "message": "视频上传成功",
1340
1383
  "data": result if isinstance(result, dict) else str(result),
1341
1384
  }, ensure_ascii=False)
1385
+ except asyncio.TimeoutError:
1386
+ return json.dumps({
1387
+ "success": False,
1388
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1389
+ "last_phase": progress_info.get("phase", "unknown") if "progress_info" in dir() else "init",
1390
+ }, ensure_ascii=False)
1342
1391
  except Exception as e:
1343
1392
  return json.dumps({
1344
1393
  "success": False,
@@ -1375,6 +1424,7 @@ async def bili_upload_video_multi(
1375
1424
 
1376
1425
  Returns:
1377
1426
  上传结果
1427
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1378
1428
  """
1379
1429
  cred = get_cred()
1380
1430
 
@@ -1418,12 +1468,18 @@ async def bili_upload_video_multi(
1418
1468
 
1419
1469
  uploader = VideoUploader(pages=pages, meta=meta, credential=cred)
1420
1470
 
1421
- result = await uploader.start()
1471
+ # P2-17: 同 bili_upload_video —— 15 分钟墙钟,挂死的上传不能在后台无人知晓地继续
1472
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1422
1473
  return json.dumps({
1423
1474
  "success": True,
1424
1475
  "message": f"多P视频上传成功(共{len(pages)}P)",
1425
1476
  "data": result if isinstance(result, dict) else str(result),
1426
1477
  }, ensure_ascii=False)
1478
+ except asyncio.TimeoutError:
1479
+ return json.dumps({
1480
+ "success": False,
1481
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1482
+ }, ensure_ascii=False)
1427
1483
  except Exception as e:
1428
1484
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
1429
1485
 
@@ -1450,10 +1506,14 @@ async def bili_send_opus(
1450
1506
  发布结果
1451
1507
  """
1452
1508
  image_paths = [str(path).strip() for path in (images or []) if str(path or "").strip()]
1453
- guard = _get_runtime_guard()
1454
- reservation = guard.reserve_send(key_moment("opus", {
1455
- "title": title, "content": content, "images": image_paths, "category_id": str(category_id),
1456
- }))
1509
+ try:
1510
+ guard = _get_runtime_guard()
1511
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1512
+ reservation = guard.reserve_send(key_moment("opus", {
1513
+ "title": title, "content": content, "images": image_paths, "category_id": str(category_id),
1514
+ }))
1515
+ except Exception as exc:
1516
+ return _guard_unavailable(exc)
1457
1517
  if not reservation["allowed"]:
1458
1518
  return _guard_blocked(reservation)
1459
1519
  try:
@@ -1798,11 +1858,14 @@ async def bili_favorite_lists(uid: int = 0) -> str:
1798
1858
  """
1799
1859
  cred = get_cred()
1800
1860
  if uid == 0:
1801
- # 从凭证获取自己的UID
1802
- import json as _json
1803
- with open(CRED_FILE) as f:
1804
- data = _json.load(f)
1805
- uid = int(data.get("dedeuserid", 0))
1861
+ # 从凭证获取自己的UID(P3-43: 改走硬化的 load_credential —— 原来裸 open 读凭证文件,
1862
+ # dedeuserid 空串 → ValueError、文件中途被删 → FileNotFoundError,整工具裸 traceback)
1863
+ own = load_credential()
1864
+ dede = str(own.dedeuserid).strip() if own else ""
1865
+ if not dede.isdigit():
1866
+ return json.dumps({"error": "未登录或凭证不可读:拿不到自己的 UID(dedeuserid 为空或缺失),"
1867
+ "请先调用 bili_login 重新扫码登录"}, ensure_ascii=False)
1868
+ uid = int(dede)
1806
1869
 
1807
1870
  result = await favorite_list.get_video_favorite_list(uid=uid, credential=cred)
1808
1871
  fav_lists = []
@@ -1890,13 +1953,17 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
1890
1953
  return json.dumps({"success": False, "error": str(e),
1891
1954
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1892
1955
 
1893
- guard = _get_runtime_guard()
1894
1956
  try:
1895
1957
  key = key_dm_reply(receiver_uid, body, msg_seqno)
1896
1958
  except Exception as e:
1897
1959
  return json.dumps({"success": False, "error": str(e),
1898
1960
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1899
- reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
1961
+ try:
1962
+ guard = _get_runtime_guard()
1963
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
1964
+ reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
1965
+ except Exception as e:
1966
+ return _guard_unavailable(e)
1900
1967
  if not reservation.get("allowed"):
1901
1968
  return _guard_blocked(reservation, "send", f"这条私信已经回过了(UID:{receiver_uid})")
1902
1969
 
@@ -2483,7 +2550,6 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2483
2550
  return json.dumps({"success": False, "error": f"取不到这条动态的评论区:{e}",
2484
2551
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
2485
2552
 
2486
- guard = _get_runtime_guard()
2487
2553
  try:
2488
2554
  key = key_opus_reply(oid_str, rpid)
2489
2555
  except Exception as e:
@@ -2493,7 +2559,12 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2493
2559
  # 否则升级那一刻,昨天回过的那些今天会再回一遍。
2494
2560
  # 🔁 2026-10-25(使用者报「回一个人一条评论两条消息」):**视频通道回过的同号 rpid 也认**
2495
2561
  # —— 同一条评论只回一次,不管上一次走的是 bili_reply 还是 bili_reply_opus。
2496
- reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2562
+ try:
2563
+ guard = _get_runtime_guard()
2564
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
2565
+ reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2566
+ except Exception as e:
2567
+ return _guard_unavailable(e)
2497
2568
  if not reservation.get("allowed"):
2498
2569
  return _guard_blocked(reservation, "send", "这条动态评论已经回过了")
2499
2570
 
@@ -2689,24 +2760,37 @@ async def bili_my_video_comments(num: int = 10, videos: int = 3) -> str:
2689
2760
  # ========== Tool: 判重账本状态(只读) ==========
2690
2761
 
2691
2762
  @tool()
2692
- async def bili_guard_status(bvids: list = None, limit: int = 200) -> str:
2763
+ async def bili_guard_status(bvids: list = None, limit: int = 200, release: list = None) -> str:
2693
2764
  """
2694
2765
  看一眼**永久判重账本**(SQLite,重启不丢、跨进程共用)
2695
2766
 
2696
2767
  Args:
2697
2768
  bvids: 想核对的 BV 号列表(给了就只查这几条;不给就返回最近刷过的一批)
2698
2769
  limit: 最多返回几条(默认 200,上限 500)
2770
+ release: 【unknown 槽位的人工解除入口】要解除的坑位 key 列表。只在人工核实
2771
+ 「那次结果不明的发送确实没发出去」之后才用 —— 只有 status='unknown'
2772
+ 的行会被删(pending/sent 绝不碰);非空时本工具不再纯只读。
2699
2773
 
2700
2774
  Returns:
2701
2775
  counts(seen / sent / pending / unknown 各多少)、seenVideos(已刷过的 BV)、
2702
- activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**)
2776
+ activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**);
2777
+ 传了 release 时额外带 released:每个 key 是否真的解除了一行
2703
2778
  """
2704
2779
  guard = _get_runtime_guard()
2780
+ released = []
2781
+ if release:
2782
+ # P1-2: unknown 槽位的人工解除入口 —— release_unknown 只删 unknown 行,
2783
+ # 误传 pending/sent 的 key 时返回 false 并原样保留那行,绝不误删。
2784
+ for key in release:
2785
+ try:
2786
+ released.append({"key": str(key), "released": bool(guard.release_unknown(str(key)))})
2787
+ except Exception as e:
2788
+ released.append({"key": str(key), "released": False, "error": str(e)})
2705
2789
  try:
2706
2790
  data = guard.snapshot(bvids=bvids, limit=limit)
2707
2791
  except Exception as e:
2708
- return json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False)
2709
- return json.dumps({"ok": True, **data}, ensure_ascii=False)
2792
+ return json.dumps({"ok": False, "error": str(e), "released": released}, ensure_ascii=False)
2793
+ return json.dumps({"ok": True, "released": released, **data}, ensure_ascii=False)
2710
2794
 
2711
2795
 
2712
2796
  # ========== 启动 ==========
@@ -122,8 +122,12 @@ def stringify_ids(value):
122
122
  result[key] = str(item) # Python SDK integers are exact, unlike JSON numbers in JS.
123
123
  elif isinstance(item, str) and re.fullmatch(r"[0-9]+", item.strip()):
124
124
  result[key] = item.strip().lstrip("0") or "0"
125
+ elif isinstance(item, str):
126
+ # P3-44: 非数值字符串原样保留({"id":"hot-word-12"} 不能被静默清成 "",
127
+ # 模型会把「有数据」误读成「没有」);空串在这里自然仍是空串(缺失)。
128
+ result[key] = item
125
129
  else:
126
- result[key] = "" # unavailable/malformed is never guessed as another ID
130
+ result[key] = "" # 空值/非字符串仍按「缺失」处理,绝不猜成另一个 ID
127
131
  else:
128
132
  result[key] = stringify_ids(item)
129
133
  return result
@@ -238,10 +242,15 @@ class RuntimeGuard:
238
242
  conn.close()
239
243
 
240
244
  def _connect(self):
241
- conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
242
- conn.row_factory = sqlite3.Row
243
- conn.execute("PRAGMA busy_timeout=15000")
244
- conn.execute("PRAGMA synchronous=FULL")
245
+ try:
246
+ conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
247
+ conn.row_factory = sqlite3.Row
248
+ conn.execute("PRAGMA busy_timeout=15000")
249
+ conn.execute("PRAGMA synchronous=FULL")
250
+ except sqlite3.OperationalError as exc:
251
+ # E2: node/python 临时目录解析不一致时这里报「unable to open database file」
252
+ # 且不带路径,排障只能盲猜 —— 报错必须带上 db_path 本身。
253
+ raise RuntimeError(f"runtime guard 打不开账本库 {self.db_path}: {exc}") from exc
245
254
  return conn
246
255
 
247
256
  @contextmanager
@@ -636,10 +645,27 @@ class RuntimeGuard:
636
645
  (reservation["key"], reservation["token"])).rowcount
637
646
  if changed != 1:
638
647
  raise RuntimeError("guard reservation was lost; refusing an unsafe retry")
639
- conn.execute("DELETE FROM guard_pace_events WHERE event_key=?", ("send:" + reservation["token"],))
648
+ # P2-16: 节奏事件不再删,改标 rejected 并保留 at/next —— burst/hour/gap
649
+ # (_pace_check 按 at/next 统计、不看 status)要把「确定没发出去」的尝试也计入,
650
+ # 否则坏凭据下模型可以对 B站 无限速连打(探针实测 12 连发全放行,
651
+ # 而 -352 的最短触发路径恰恰就是高频尝试)。
652
+ conn.execute("UPDATE guard_pace_events SET status='rejected' WHERE event_key=?",
653
+ ("send:" + reservation["token"],))
640
654
  self._note_rejection(conn, code, now)
641
655
  return self._pace_check(conn, pace_kind(reservation["key"]), now)
642
656
 
657
+ def release_unknown(self, key):
658
+ """P1-2: unknown 槽位的**人工**解除入口。
659
+
660
+ 只删 status='unknown' 的行(pending/sent 绝不可删:前者正被某个进程占着,
661
+ 后者是发出去的铁账);返回是否真的解除了一行。
662
+ 调用前提是人工核实「那次结果不明的发送确实没发出去」—— 这不是自动重试的后门。
663
+ """
664
+ with self._transaction() as conn:
665
+ changed = conn.execute("DELETE FROM guard_entries WHERE key=? AND status='unknown'",
666
+ (str(key),)).rowcount
667
+ return changed == 1
668
+
643
669
  def pace_status(self, action):
644
670
  """Read the current common floor without reserving or consuming a send slot."""
645
671
  with self._transaction() as conn: