dsh-deepmoyu 2.7.1 → 2.8.0

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 (59) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +10 -3
  5. package/SECURITY.md +4 -0
  6. package/client.js +509 -296
  7. package/docs/ACTIVITY_TUNING.md +50 -0
  8. package/docs/CONTINUOUS_BROWSING.md +35 -0
  9. package/docs/FUN_FEATURE_REFERENCES.md +128 -0
  10. package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
  11. package/docs/NATURAL_AUTONOMY.md +91 -0
  12. package/docs/PRIVACY.md +7 -0
  13. package/docs/RELEASE_2_8_0.md +27 -0
  14. package/docs/RELEASE_AUDIT.md +16 -2
  15. package/docs/RELEASING.md +15 -0
  16. package/docs/TEST_MATRIX.md +9 -0
  17. package/docs/WORKFLOW_REFERENCES.md +57 -0
  18. package/docs/tool-evidence.json +6 -1
  19. package/index.js +941 -268
  20. package/lib/asr.py +22 -0
  21. package/lib/background-tools.mjs +1 -1
  22. package/lib/browse-preferences.mjs +154 -0
  23. package/lib/browse-progress.mjs +165 -0
  24. package/lib/comment.mjs +29 -46
  25. package/lib/config-policy.mjs +2 -1
  26. package/lib/curiosity.mjs +153 -0
  27. package/lib/favops.py +38 -6
  28. package/lib/learn-review.mjs +13 -3
  29. package/lib/learn-store.mjs +7 -0
  30. package/lib/learn.mjs +234 -69
  31. package/lib/login.mjs +20 -3
  32. package/lib/mcp.mjs +5 -2
  33. package/lib/memory-center.mjs +21 -19
  34. package/lib/mirror.mjs +15 -1
  35. package/lib/mode.mjs +23 -9
  36. package/lib/model-relay-adapter.mjs +1 -1
  37. package/lib/model-relay.mjs +21 -4
  38. package/lib/pace.mjs +15 -7
  39. package/lib/persona.mjs +41 -30
  40. package/lib/pet-session.mjs +9 -3
  41. package/lib/product-status.mjs +38 -2
  42. package/lib/prompts.mjs +38 -37
  43. package/lib/pv-session.mjs +16 -1
  44. package/lib/pv.mjs +18 -3
  45. package/lib/pv.py +168 -30
  46. package/lib/pv_cover.py +11 -1
  47. package/lib/pv_editorial.py +28 -4
  48. package/lib/pv_plan.py +20 -2
  49. package/lib/pv_verify.py +14 -0
  50. package/lib/session-continuation.mjs +47 -0
  51. package/lib/state.mjs +6 -2
  52. package/lib/sticker-automation.mjs +16 -2
  53. package/lib/sticker.mjs +4 -1
  54. package/lib/uvfetch.mjs +38 -12
  55. package/package.json +1 -1
  56. package/presets/deepmoyu.patch.yml +8 -10
  57. package/prompts/duty.md +30 -38
  58. package/vendor/bilibili-mcp/mcp_server.py +204 -47
  59. package/vendor/bilibili-mcp/runtime_guard.py +164 -31
@@ -131,16 +131,17 @@ def _guard_blocked(reservation, kind="send", why=""):
131
131
  return json.dumps(out, ensure_ascii=False)
132
132
  if kind == "read":
133
133
  if status == "pending":
134
- message = "另一个进程正在读这条字幕 —— 这一轮先换别的视频"
134
+ message = "另一个进程正在取这条字幕,先看其他候选,完成后可从缓存补查"
135
135
  else:
136
136
  message = "这条已经刷过了(永久账本里有,重启也不会丢)—— 换一条"
137
137
  out = {
138
- "already_seen": True,
138
+ "already_seen": status == "seen",
139
139
  "seen_recorded": False,
140
140
  "seen_at": at,
141
141
  "guard_status": status,
142
142
  "message": message,
143
- "hint": "already_seen=true ⇒ 直接换别的视频;确实要重读,就在 bili_say 里说清为什么",
143
+ "hint": "已读标记用于选片去重,不禁止补查原文。读取正在进行时先处理其他候选,不要连续轮询。",
144
+ "next_action": "read-another-candidate",
144
145
  }
145
146
  if reservation.get("title"):
146
147
  out["title"] = reservation.get("title")
@@ -149,8 +150,10 @@ def _guard_blocked(reservation, kind="send", why=""):
149
150
  return json.dumps({
150
151
  "success": False, "already_replied": True, "retry_blocked": True,
151
152
  "guard_status": "unknown", "seen_at": at,
153
+ "slot_key": reservation.get("key", ""),
152
154
  "error": "上一次发出去的结果不确定(超时 / 回执不明)—— 不许自动重发",
153
- "hint": "先自己去评论区 / 私信里看一眼到底发出去没有;确认没有,再手工重试",
155
+ "hint": ("先自己去评论区 / 私信里看一眼到底发出去没有;**确认没发出去**后,用 "
156
+ "bili_guard_status(release=['这条的 slot_key']) 人工解除 unknown 槽位(P1-2),再重试"),
154
157
  }, ensure_ascii=False)
155
158
  if status == "pending":
156
159
  return json.dumps({
@@ -167,6 +170,18 @@ def _guard_blocked(reservation, kind="send", why=""):
167
170
  }, ensure_ascii=False)
168
171
 
169
172
 
173
+ # P3-46: 守卫库被锁(SQLite busy>15s)时的统一 fail-closed 话术,措辞对齐 bili_subtitle
174
+ # 的「判重账本不可用」兜底回执 —— 原始 traceback 绝不能直接漏给模型。
175
+ _GUARD_LOCKED_ERROR = "判重账本暂不可用(可能被锁),先别重发;稍后用 bili_guard_status 核对坑位状态"
176
+
177
+
178
+ def _guard_unavailable(exc) -> str:
179
+ """占坑阶段守卫异常 → JSON 错误回执(此时还没发任何东西,可以稍后重试)。"""
180
+ return json.dumps({"success": False, "error": _GUARD_LOCKED_ERROR,
181
+ "detail": f"{type(exc).__name__}: {exc}",
182
+ "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
183
+
184
+
170
185
  async def _guarded_send(guard, reservation, runner, record=None):
171
186
  """在**已经占好坑**的前提下真正发出去,并如实落账。
172
187
 
@@ -196,7 +211,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
196
211
  return None
197
212
 
198
213
  def _rejected(error, result, code):
199
- pace = guard.reject_send(reservation, code)
214
+ try:
215
+ pace = guard.reject_send(reservation, code)
216
+ except Exception as exc:
217
+ # P3-46: 拒绝落账失败(库被锁)不能裸抛 —— 事务已回滚、坑位留在 pending 正好挡重发,
218
+ # 回执转 fail-closed(guard_status=unknown ⇒ 调用方会回「别自动重发」的话术)。
219
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
220
+ "result": result, "code": code, "ledger_detail": f"{type(exc).__name__}: {exc}"}
200
221
  out = {"guard_status": "rejected", "error": error, "retry_blocked": False,
201
222
  "result": result, "code": code}
202
223
  if code == 49044:
@@ -232,8 +253,15 @@ async def _guarded_send(guard, reservation, runner, record=None):
232
253
  return {"guard_status": "unknown", "error": str(result.get("error") or "没有确定的回执"),
233
254
  "retry_blocked": True, "result": result, "code": code}
234
255
 
235
- guard.commit_send(reservation, record)
236
- return {"guard_status": "sent", "error": "", "retry_blocked": False, "result": result, "code": None}
256
+ try:
257
+ guard.commit_send(reservation, record)
258
+ except Exception as exc:
259
+ # P3-46: 落账失败(库被锁 >15s)不能裸抛 —— 发送本身可能已成功,坑位留在 pending
260
+ # 挡住重发;回执转 fail-closed JSON 并指引去 bili_guard_status 核对,而不是 traceback。
261
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
262
+ "result": result, "code": None, "ledger_detail": f"{type(exc).__name__}: {exc}"}
263
+ return {"guard_status": "sent", "error": "", "retry_blocked": False, "result": result, "code": None,
264
+ "pace_token": reservation.get("token", "")}
237
265
 
238
266
 
239
267
  def ts_to_day(ts) -> str:
@@ -407,10 +435,13 @@ async def bili_login_check() -> str:
407
435
 
408
436
  elif state == QrCodeLoginEvents.TIMEOUT:
409
437
  _login_session = None
410
- # 清理二维码文件
438
+ # 清理二维码文件(P3-47: Windows 下 PNG 被看图程序占用会 PermissionError,别让它炸回执)
411
439
  qr_file = Path(__file__).parent / "qrcode_login.png"
412
440
  if qr_file.exists():
413
- qr_file.unlink()
441
+ try:
442
+ qr_file.unlink()
443
+ except OSError as e:
444
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
414
445
  return json.dumps({
415
446
  "status": "timeout",
416
447
  "message": "二维码已过期,请重新调用 bili_login 生成新二维码",
@@ -431,10 +462,13 @@ async def bili_login_check() -> str:
431
462
  }, f)
432
463
  os.replace(tmp_cred, CRED_FILE)
433
464
  _login_session = None
434
- # 清理二维码文件
465
+ # 清理二维码文件(P3-47: 删失败只记 stderr —— DONE 分支反复重入比带着孤儿 PNG 重坏了)
435
466
  qr_file = Path(__file__).parent / "qrcode_login.png"
436
467
  if qr_file.exists():
437
- qr_file.unlink()
468
+ try:
469
+ qr_file.unlink()
470
+ except OSError as e:
471
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
438
472
  return json.dumps({
439
473
  "status": "done",
440
474
  "message": "登录成功!凭证已保存,现在可以使用所有B站功能了",
@@ -484,7 +518,7 @@ async def bili_check_credential() -> str:
484
518
  # ========== Tool 1: 搜索视频 ==========
485
519
 
486
520
  @tool()
487
- async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", days: int = 0) -> str:
521
+ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", days: int = 0, page: StrictInt = 1) -> str:
488
522
  """
489
523
  搜索B站视频
490
524
 
@@ -493,12 +527,16 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
493
527
  num: 返回视频数量,默认10,最大50
494
528
  order: 排序方式 totalrank=综合 click=播放量 pubdate=最新 dm=弹幕
495
529
  days: 只看最近几天发布的(0=不限)
530
+ page: 搜索页码,1至50,默认1;同一关键词可翻下一页找新候选
496
531
 
497
532
  Returns:
498
533
  JSON格式的视频列表,包含标题、BV号、播放量、评论数、UP主、发布时间等
499
534
  """
500
535
  from datetime import datetime
501
536
 
537
+ if type(page) is not int or not 1 <= page <= 50:
538
+ return json.dumps({"ok": False, "error": "page 需要1至50之间的整数"}, ensure_ascii=False)
539
+
502
540
  def _pubdate_str(ts) -> str:
503
541
  """unix 秒 → 本地时区 YYYY-MM-DD;拿不到给空串。"""
504
542
  try:
@@ -517,7 +555,7 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
517
555
  result = await search.search_by_type(
518
556
  keyword=keyword,
519
557
  search_type=search.SearchObjectType.VIDEO,
520
- page=1,
558
+ page=page,
521
559
  order_type=order_enum,
522
560
  )
523
561
 
@@ -558,7 +596,29 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
558
596
  videos = videos[:min(num, 5)]
559
597
  hint = f"近 {days} 天没有匹配的,下面是综合排序的兜底结果"
560
598
 
561
- out = {"keyword": keyword, "count": len(videos), "days": days, "videos": videos}
599
+ def _count(value):
600
+ if isinstance(value, bool):
601
+ return None
602
+ if isinstance(value, int) and value >= 0:
603
+ return value
604
+ if isinstance(value, str) and value.isdigit():
605
+ return int(value)
606
+ return None
607
+
608
+ # Do not infer exhaustion from our num/date filtering or a short result list.
609
+ # Unknown platform pagination remains null rather than claiming another page exists.
610
+ total_pages = _count(result.get("numPages"))
611
+ total_results = _count(result.get("numResults"))
612
+ page_size = _count(result.get("pagesize", result.get("page_size")))
613
+ has_more = None
614
+ if total_pages is not None:
615
+ has_more = page < min(total_pages, 50)
616
+ elif total_results == 0:
617
+ has_more = False
618
+ elif total_results is not None and page_size:
619
+ has_more = page < 50 and page * page_size < total_results
620
+ out = {"keyword": keyword, "count": len(videos), "days": days, "page": page,
621
+ "has_more": has_more, "videos": videos}
562
622
  if filtered_out_all:
563
623
  out["filtered_out_all"] = True
564
624
  out["hint"] = hint
@@ -758,6 +818,31 @@ async def _fetch_subtitle(bvid: str, with_time: bool = False) -> str:
758
818
  return json.dumps(stringify_ids(out), ensure_ascii=False)
759
819
 
760
820
 
821
+ def _subtitle_view(payload, with_time, *, cached=False):
822
+ """Render both timestamp modes from the same cached, public source snapshot."""
823
+ out = dict(payload)
824
+ segments = payload.get("segments_detail")
825
+ if isinstance(segments, list) and segments:
826
+ text = []
827
+ for item in segments:
828
+ content = str(item.get("s") or "") if isinstance(item, dict) else ""
829
+ if not content.strip():
830
+ continue
831
+ try:
832
+ second = max(0.0, float(item.get("t") or 0))
833
+ except (TypeError, ValueError):
834
+ second = 0.0
835
+ prefix = f"[{int(second // 60):02d}:{second % 60:04.1f}] " if with_time else ""
836
+ text.append(prefix + content)
837
+ out["text"] = "\n".join(text)
838
+ if not with_time:
839
+ out.pop("segments_detail", None)
840
+ out.update(cached=cached, seen_recorded=not cached, already_seen=cached)
841
+ if cached:
842
+ out["hint"] = "这是上次实际取得的字幕快照,可补查时间点;不会重复抓取或增加新看片数。"
843
+ return out
844
+
845
+
761
846
  @tool()
762
847
  async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
763
848
  """
@@ -769,18 +854,28 @@ async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
769
854
 
770
855
  Returns:
771
856
  视频的完整字幕文本(含 duration;with_time=True 时含时间戳与分段时间轴)。
772
- ⭐ 永久账本里已有这条 ⇒ 直接返回 already_seen=true,**不再花一次网络请求**。
857
+ 已读视频优先返回本地字幕快照,可补查时间点;缺少快照时取一次原文。
858
+ 永久历史只用于选片去重,读请求仍有跨进程单飞租约。
773
859
  """
774
860
  guard = _get_runtime_guard()
775
861
  try:
776
- reservation = guard.reserve_read(bvid)
862
+ cached = guard.cached_subtitle(bvid)
863
+ if cached is not None:
864
+ return json.dumps(_subtitle_view(cached, with_time, cached=True), ensure_ascii=False)
865
+ reservation = guard.reserve_subtitle(bvid)
777
866
  except Exception as e:
778
867
  return json.dumps({"error": f"判重账本不可用,先不抓字幕:{e}"}, ensure_ascii=False)
779
868
  if not reservation.get("allowed"):
780
869
  return _guard_blocked(reservation, "read")
781
870
 
782
871
  try:
783
- raw = await asyncio.wait_for(_fetch_subtitle(bvid, with_time), timeout=READ_TIMEOUT_SECONDS)
872
+ # Another reader may have published between our first cache lookup and this lease.
873
+ cached = guard.cached_subtitle(bvid)
874
+ if cached is not None:
875
+ guard.release(reservation)
876
+ return json.dumps(_subtitle_view(cached, with_time, cached=True), ensure_ascii=False)
877
+ # Always keep the timeline so a later with_time=True needs no second network read.
878
+ raw = await asyncio.wait_for(_fetch_subtitle(bvid, True), timeout=READ_TIMEOUT_SECONDS)
784
879
  except BaseException:
785
880
  # 读是幂等的:超时 / 出错都该把坑退掉,下次还能重试
786
881
  guard.release(reservation)
@@ -805,9 +900,15 @@ async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
805
900
  guard.release(reservation)
806
901
  return json.dumps(payload, ensure_ascii=False)
807
902
 
808
- guard.commit_read(reservation, {"bvid": bvid, "title": payload.get("title", "")})
809
- payload["seen_recorded"] = True
810
- return json.dumps(payload, ensure_ascii=False)
903
+ payload["bvid"] = bvid
904
+ try:
905
+ stored = guard.commit_subtitle(reservation, payload)
906
+ except Exception:
907
+ guard.release(reservation)
908
+ raise
909
+ out = _subtitle_view(payload, with_time)
910
+ out["cache_stored"] = stored
911
+ return json.dumps(out, ensure_ascii=False)
811
912
 
812
913
 
813
914
  # ========== Tool 4: 获取弹幕 ==========
@@ -992,13 +1093,17 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
992
1093
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
993
1094
 
994
1095
  # ② 占坑:同一条评论(rpid)全机器只允许发一次;新评论按 BV 也只允许留一条
995
- guard = _get_runtime_guard()
996
1096
  try:
997
1097
  key = key_video_reply(bvid, rpid)
998
1098
  except Exception as e:
999
1099
  return json.dumps({"success": False, "error": str(e),
1000
1100
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1001
- reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1101
+ try:
1102
+ guard = _get_runtime_guard()
1103
+ # P3-46: SQLite busy>15s 的原始异常不能外泄 —— 包成 fail-closed JSON 回执
1104
+ reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1105
+ except Exception as e:
1106
+ return _guard_unavailable(e)
1002
1107
  if not reservation.get("allowed"):
1003
1108
  return _guard_blocked(reservation, "send",
1004
1109
  "这条评论已经回过了" if rpid != "0" else "这条视频已经留过一条评论了")
@@ -1052,7 +1157,7 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
1052
1157
  # ④ 把回执里的 rpid 带回去:判重表要靠它记「我们发过哪条」,宿主的评论复核
1053
1158
  # (lib/verify.mjs)也要拿它去 B站 查公开可见性。只回 success 的话那条链是断的。
1054
1159
  result = outcome["result"]
1055
- out = {"success": True, "message": "评论发送成功", "guard_status": "sent"}
1160
+ out = {"success": True, "message": "评论发送成功", "guard_status": "sent", "pace_token": outcome["pace_token"]}
1056
1161
  if isinstance(result, dict):
1057
1162
  for name in ("rpid", "rpid_str", "root", "parent"):
1058
1163
  val = result.get(name)
@@ -1193,10 +1298,14 @@ async def bili_send_dynamic(
1193
1298
 
1194
1299
  text = text.strip()
1195
1300
  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
- }))
1301
+ try:
1302
+ guard = _get_runtime_guard()
1303
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1304
+ reservation = guard.reserve_send(key_moment("dynamic", {
1305
+ "text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
1306
+ }))
1307
+ except Exception as exc:
1308
+ return _guard_unavailable(exc)
1200
1309
  if not reservation["allowed"]:
1201
1310
  return _guard_blocked(reservation)
1202
1311
  try:
@@ -1235,6 +1344,7 @@ async def bili_send_dynamic(
1235
1344
  return json.dumps(payload, ensure_ascii=False)
1236
1345
  result = outcome["result"]
1237
1346
  return json.dumps({"success": True, "message": "动态发布成功", "guard_status": "sent",
1347
+ "pace_token": outcome["pace_token"],
1238
1348
  "data": stringify_ids(result) if isinstance(result, dict) else str(result)}, ensure_ascii=False)
1239
1349
 
1240
1350
 
@@ -1272,6 +1382,7 @@ async def bili_upload_video(
1272
1382
 
1273
1383
  Returns:
1274
1384
  上传结果,包含BV号
1385
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1275
1386
  """
1276
1387
  # ---- 参数校验 ----
1277
1388
  if not video_path or not video_path.strip():
@@ -1333,12 +1444,20 @@ async def bili_upload_video(
1333
1444
  async def on_event(data: dict):
1334
1445
  progress_info["phase"] = str(data)
1335
1446
 
1336
- result = await uploader.start()
1447
+ # P2-17: uploader.start() 没有超时的话,挂死的上传在客户端超时后仍在后台继续,
1448
+ # 公开投稿完成却无任何记录 —— 这里给 15 分钟墙钟,超时明确报错。
1449
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1337
1450
  return json.dumps({
1338
1451
  "success": True,
1339
1452
  "message": "视频上传成功",
1340
1453
  "data": result if isinstance(result, dict) else str(result),
1341
1454
  }, ensure_ascii=False)
1455
+ except asyncio.TimeoutError:
1456
+ return json.dumps({
1457
+ "success": False,
1458
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1459
+ "last_phase": progress_info.get("phase", "unknown") if "progress_info" in dir() else "init",
1460
+ }, ensure_ascii=False)
1342
1461
  except Exception as e:
1343
1462
  return json.dumps({
1344
1463
  "success": False,
@@ -1375,6 +1494,7 @@ async def bili_upload_video_multi(
1375
1494
 
1376
1495
  Returns:
1377
1496
  上传结果
1497
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1378
1498
  """
1379
1499
  cred = get_cred()
1380
1500
 
@@ -1418,12 +1538,18 @@ async def bili_upload_video_multi(
1418
1538
 
1419
1539
  uploader = VideoUploader(pages=pages, meta=meta, credential=cred)
1420
1540
 
1421
- result = await uploader.start()
1541
+ # P2-17: 同 bili_upload_video —— 15 分钟墙钟,挂死的上传不能在后台无人知晓地继续
1542
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1422
1543
  return json.dumps({
1423
1544
  "success": True,
1424
1545
  "message": f"多P视频上传成功(共{len(pages)}P)",
1425
1546
  "data": result if isinstance(result, dict) else str(result),
1426
1547
  }, ensure_ascii=False)
1548
+ except asyncio.TimeoutError:
1549
+ return json.dumps({
1550
+ "success": False,
1551
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1552
+ }, ensure_ascii=False)
1427
1553
  except Exception as e:
1428
1554
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
1429
1555
 
@@ -1450,10 +1576,14 @@ async def bili_send_opus(
1450
1576
  发布结果
1451
1577
  """
1452
1578
  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
- }))
1579
+ try:
1580
+ guard = _get_runtime_guard()
1581
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1582
+ reservation = guard.reserve_send(key_moment("opus", {
1583
+ "title": title, "content": content, "images": image_paths, "category_id": str(category_id),
1584
+ }))
1585
+ except Exception as exc:
1586
+ return _guard_unavailable(exc)
1457
1587
  if not reservation["allowed"]:
1458
1588
  return _guard_blocked(reservation)
1459
1589
  try:
@@ -1487,6 +1617,7 @@ async def bili_send_opus(
1487
1617
  return json.dumps(payload, ensure_ascii=False)
1488
1618
  result = outcome["result"]
1489
1619
  return json.dumps({"success": True, "message": "图文发布成功", "guard_status": "sent",
1620
+ "pace_token": outcome["pace_token"],
1490
1621
  "data": stringify_ids(result) if isinstance(result, dict) else str(result)}, ensure_ascii=False)
1491
1622
 
1492
1623
 
@@ -1798,11 +1929,14 @@ async def bili_favorite_lists(uid: int = 0) -> str:
1798
1929
  """
1799
1930
  cred = get_cred()
1800
1931
  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))
1932
+ # 从凭证获取自己的UID(P3-43: 改走硬化的 load_credential —— 原来裸 open 读凭证文件,
1933
+ # dedeuserid 空串 → ValueError、文件中途被删 → FileNotFoundError,整工具裸 traceback)
1934
+ own = load_credential()
1935
+ dede = str(own.dedeuserid).strip() if own else ""
1936
+ if not dede.isdigit():
1937
+ return json.dumps({"error": "未登录或凭证不可读:拿不到自己的 UID(dedeuserid 为空或缺失),"
1938
+ "请先调用 bili_login 重新扫码登录"}, ensure_ascii=False)
1939
+ uid = int(dede)
1806
1940
 
1807
1941
  result = await favorite_list.get_video_favorite_list(uid=uid, credential=cred)
1808
1942
  fav_lists = []
@@ -1890,13 +2024,17 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
1890
2024
  return json.dumps({"success": False, "error": str(e),
1891
2025
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1892
2026
 
1893
- guard = _get_runtime_guard()
1894
2027
  try:
1895
2028
  key = key_dm_reply(receiver_uid, body, msg_seqno)
1896
2029
  except Exception as e:
1897
2030
  return json.dumps({"success": False, "error": str(e),
1898
2031
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1899
- reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
2032
+ try:
2033
+ guard = _get_runtime_guard()
2034
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
2035
+ reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
2036
+ except Exception as e:
2037
+ return _guard_unavailable(e)
1900
2038
  if not reservation.get("allowed"):
1901
2039
  return _guard_blocked(reservation, "send", f"这条私信已经回过了(UID:{receiver_uid})")
1902
2040
 
@@ -1929,6 +2067,7 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
1929
2067
  "success": True,
1930
2068
  "message": f"私信已发送给UID:{receiver_uid}",
1931
2069
  "guard_status": "sent",
2070
+ "pace_token": outcome["pace_token"],
1932
2071
  "data": result if isinstance(result, dict) else str(result),
1933
2072
  }, ensure_ascii=False)
1934
2073
 
@@ -2483,7 +2622,6 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2483
2622
  return json.dumps({"success": False, "error": f"取不到这条动态的评论区:{e}",
2484
2623
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
2485
2624
 
2486
- guard = _get_runtime_guard()
2487
2625
  try:
2488
2626
  key = key_opus_reply(oid_str, rpid)
2489
2627
  except Exception as e:
@@ -2493,7 +2631,12 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2493
2631
  # 否则升级那一刻,昨天回过的那些今天会再回一遍。
2494
2632
  # 🔁 2026-10-25(使用者报「回一个人一条评论两条消息」):**视频通道回过的同号 rpid 也认**
2495
2633
  # —— 同一条评论只回一次,不管上一次走的是 bili_reply 还是 bili_reply_opus。
2496
- reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2634
+ try:
2635
+ guard = _get_runtime_guard()
2636
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
2637
+ reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2638
+ except Exception as e:
2639
+ return _guard_unavailable(e)
2497
2640
  if not reservation.get("allowed"):
2498
2641
  return _guard_blocked(reservation, "send", "这条动态评论已经回过了")
2499
2642
 
@@ -2527,6 +2670,7 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2527
2670
  # ⭐ opus_id 一律回字符串(JS 侧 1e18 这一档 number 必掉精度,账本键会错)
2528
2671
  result = outcome["result"]
2529
2672
  out = {"success": True, "message": "动态评论回复成功", "guard_status": "sent",
2673
+ "pace_token": outcome["pace_token"],
2530
2674
  "opus_id": oid_str, "oid": str(tgt["oid"]), "ctype": tgt["ctype"]}
2531
2675
  if isinstance(result, dict):
2532
2676
  for name in ("rpid", "rpid_str", "root", "parent"):
@@ -2689,27 +2833,40 @@ async def bili_my_video_comments(num: int = 10, videos: int = 3) -> str:
2689
2833
  # ========== Tool: 判重账本状态(只读) ==========
2690
2834
 
2691
2835
  @tool()
2692
- async def bili_guard_status(bvids: list = None, limit: int = 200) -> str:
2836
+ async def bili_guard_status(bvids: list = None, limit: int = 200, release: list = None) -> str:
2693
2837
  """
2694
2838
  看一眼**永久判重账本**(SQLite,重启不丢、跨进程共用)
2695
2839
 
2696
2840
  Args:
2697
2841
  bvids: 想核对的 BV 号列表(给了就只查这几条;不给就返回最近刷过的一批)
2698
2842
  limit: 最多返回几条(默认 200,上限 500)
2843
+ release: 【unknown 槽位的人工解除入口】要解除的坑位 key 列表。只在人工核实
2844
+ 「那次结果不明的发送确实没发出去」之后才用 —— 只有 status='unknown'
2845
+ 的行会被删(pending/sent 绝不碰);非空时本工具不再纯只读。
2699
2846
 
2700
2847
  Returns:
2701
2848
  counts(seen / sent / pending / unknown 各多少)、seenVideos(已刷过的 BV)、
2702
- activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**)
2849
+ activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**);
2850
+ 传了 release 时额外带 released:每个 key 是否真的解除了一行
2703
2851
  """
2704
2852
  guard = _get_runtime_guard()
2853
+ released = []
2854
+ if release:
2855
+ # P1-2: unknown 槽位的人工解除入口 —— release_unknown 只删 unknown 行,
2856
+ # 误传 pending/sent 的 key 时返回 false 并原样保留那行,绝不误删。
2857
+ for key in release:
2858
+ try:
2859
+ released.append({"key": str(key), "released": bool(guard.release_unknown(str(key)))})
2860
+ except Exception as e:
2861
+ released.append({"key": str(key), "released": False, "error": str(e)})
2705
2862
  try:
2706
2863
  data = guard.snapshot(bvids=bvids, limit=limit)
2707
2864
  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)
2865
+ return json.dumps({"ok": False, "error": str(e), "released": released}, ensure_ascii=False)
2866
+ return json.dumps({"ok": True, "released": released, **data}, ensure_ascii=False)
2710
2867
 
2711
2868
 
2712
2869
  # ========== 启动 ==========
2713
2870
 
2714
2871
  if __name__ == "__main__":
2715
- mcp.run(transport="stdio")
2872
+ mcp.run(transport="stdio")