dsh-deepmoyu 2.3.0 → 2.4.1

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 (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1348 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +953 -270
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
@@ -1,250 +0,0 @@
1
- """Isolated video regressions. No Bili writes, network TTS or model loads."""
2
- from __future__ import annotations
3
-
4
- import array
5
- import importlib.util
6
- import json
7
- import math
8
- import shutil
9
- import tempfile
10
- import unittest
11
- import wave
12
- from datetime import datetime, timezone
13
- from pathlib import Path
14
- from unittest.mock import patch
15
-
16
- from PIL import ImageChops
17
-
18
- ROOT = Path(__file__).resolve().parents[1]
19
-
20
-
21
- def module(name):
22
- spec = importlib.util.spec_from_file_location(name, ROOT / "lib" / f"{name}.py")
23
- result = importlib.util.module_from_spec(spec)
24
- spec.loader.exec_module(result)
25
- return result
26
-
27
-
28
- pv = module("pv")
29
- planner = module("pv_plan")
30
- gsv = module("pv_gsv")
31
-
32
-
33
- def write_wave(path, amplitude=2400, duration=.7, rate=24000):
34
- path = Path(path)
35
- path.parent.mkdir(parents=True, exist_ok=True)
36
- samples = array.array("h", (int(amplitude * math.sin(2 * math.pi * 220 * i / rate))
37
- for i in range(round(rate * duration))))
38
- with wave.open(str(path), "wb") as handle:
39
- handle.setparams((1, 2, rate, len(samples), "NONE", "not compressed"))
40
- handle.writeframes(samples.tobytes())
41
- return samples
42
-
43
-
44
- class Sources(unittest.TestCase):
45
- def test_only_public_report_note_is_adapted(self):
46
- with tempfile.TemporaryDirectory() as directory:
47
- home = Path(directory)
48
- (home / "out").mkdir()
49
- (home / "out" / "2026-10-21.md").write_text(
50
- "# 材料\n### BV1epp96iE6i 《公共测试标题》\n"
51
- "**这段先举例再解释,容易看出问题究竟出在哪里。**\n"
52
- "- 私信:绝对不要出现在片子里的秘密\n## 私信\n隐藏正文\n", encoding="utf-8")
53
- facts = planner.gather(home, "2026-10-21")
54
- plan = planner.auto_plan(facts, first=True)
55
- raw = json.dumps(plan, ensure_ascii=False)
56
- self.assertEqual(len(plan["sources"]), 1)
57
- self.assertEqual(plan["sources"][0]["line"], 2)
58
- self.assertEqual(plan["sources"][0]["url"], "https://www.bilibili.com/video/BV1epp96iE6i")
59
- self.assertNotIn("绝对不要", raw)
60
- self.assertNotIn("隐藏正文", raw)
61
- claims = [shot for shot in plan["shots"] if shot["provenance"] == "record-adaptation"]
62
- self.assertEqual(len(claims), 2)
63
- self.assertTrue(all(shot["source_ids"] == ["note-1"] for shot in claims))
64
- self.assertIn("虚构", plan["disclosure"])
65
-
66
- def test_sensitive_lines_are_excluded_before_highlights(self):
67
- self.assertEqual(planner.public_excerpt("私信:**这段是虚构隐私,不可拿来当公开视频笔记。**"), "")
68
- self.assertEqual(planner.public_excerpt(r"来源 C:\\private\\voice.wav **这是一段不能进视频的私人路径注释。**"), "")
69
- self.assertEqual(planner.public_excerpt("- **这行是回复记录,不应被选为笔记正文。**"), "")
70
-
71
- def test_empty_day_never_inherits_old_first_day_facts(self):
72
- with tempfile.TemporaryDirectory() as directory:
73
- plan = planner.auto_plan(planner.gather(Path(directory), "2026-10-22"), first=True)
74
- raw = json.dumps(plan, ensure_ascii=False)
75
- for false_fact in ("k卷", "746", "麻姑", "岐路九秋风", "午夜", "生鱼片"):
76
- self.assertNotIn(false_fact, raw)
77
- self.assertEqual(plan["sources"], [])
78
- self.assertTrue(all(shot["provenance"] == "original-dialogue" for shot in plan["shots"]))
79
- self.assertIn("没留下能用的公开选片笔记", raw)
80
-
81
- def test_utc8_midnight(self):
82
- before = datetime(2026, 10, 20, 15, 59, 59, tzinfo=timezone.utc).timestamp() * 1000
83
- self.assertEqual(planner.day_of(before), "2026-10-20")
84
- self.assertEqual(planner.day_of(before + 1000), "2026-10-21")
85
- self.assertEqual(planner.day_of(float("nan")), "")
86
-
87
- def test_durable_index_rotation_and_clear_archive_deduplicate(self):
88
- with tempfile.TemporaryDirectory() as directory:
89
- home = Path(directory)
90
- stamp = datetime(2026, 10, 21, 2, tzinfo=timezone.utc).timestamp() * 1000
91
- event_id = "a" * 64
92
- row = {"t": stamp, "seq": 7, "kind": "done", "tool": "bili_search", "text": "公开搜索", "eventId": event_id}
93
- index = home / "records" / "days" / "2026-10-21.jsonl"
94
- event = home / "records" / "events" / "aa" / f"{event_id}.json"
95
- archived = home / "records" / "activity" / "2026-10-21" / "unique.jsonl"
96
- for path in (index, event, archived):
97
- path.parent.mkdir(parents=True, exist_ok=True)
98
- index.write_text(json.dumps({"eventId": event_id, "at": "2026-10-21T02:00:00Z"}) + "\n", encoding="utf-8")
99
- event.write_text(json.dumps({"type": "activity", "data": row}), encoding="utf-8")
100
- archived.write_text(json.dumps(row) + "\n", encoding="utf-8")
101
- (home / "activity.jsonl.2").write_text(json.dumps(row) + "\n", encoding="utf-8")
102
- (home / "activity.jsonl").write_text("{truncated\n" + json.dumps(row) + "\n", encoding="utf-8")
103
- rows = planner.load_activity(home, "2026-10-21")
104
- self.assertEqual(len(rows), 1)
105
- self.assertEqual(rows[0]["_source"]["eventId"], event_id)
106
- self.assertEqual(planner.gather(home, "2026-10-21")["stats"]["searches"], 1)
107
- self.assertEqual(planner.load_activity(home, "2026-10-22"), [])
108
-
109
-
110
- class Audio(unittest.TestCase):
111
- def test_cache_identity_changes_with_text_voice_and_reference_bytes(self):
112
- voice = {"name": "a", "rate": "+8%"}
113
- key = pv.audio_cache_key("一", voice, "edge")
114
- self.assertNotEqual(key, pv.audio_cache_key("二", voice, "edge"))
115
- self.assertNotEqual(key, pv.audio_cache_key("一", dict(voice, pitch="+2Hz"), "edge"))
116
- self.assertEqual(key, pv.audio_cache_key("一", dict(reversed(list(voice.items()))), "edge"))
117
- with tempfile.TemporaryDirectory() as directory:
118
- reference = Path(directory) / "ref.wav"
119
- reference.write_bytes(b"first")
120
- voice = {"gsvRef": str(reference), "gsvPrompt": "测试"}
121
- key = pv.audio_cache_key("一", voice, "gsv")
122
- reference.write_bytes(b"later")
123
- self.assertNotEqual(key, pv.audio_cache_key("一", voice, "gsv"))
124
-
125
- def test_modified_take_and_reordering_do_not_reuse_numbered_audio(self):
126
- with tempfile.TemporaryDirectory() as directory:
127
- work = Path(directory)
128
- (work / "tts").mkdir()
129
- (work / "wav").mkdir()
130
- calls = []
131
- def synth(text, path, *args):
132
- calls.append(text)
133
- write_wave(path, duration=.7 if text == "甲" else 1.1)
134
- return "test-tts"
135
- def convert(source, destination):
136
- shutil.copyfile(source, destination)
137
- with wave.open(str(destination), "rb") as stream:
138
- pcm = array.array("h")
139
- pcm.frombytes(stream.readframes(stream.getnframes()))
140
- return pcm, stream.getframerate()
141
- with patch.object(pv, "tts", synth), patch.object(pv, "to_wav", convert):
142
- first = pv.prepare_audio([{"text": "甲"}, {"text": "乙"}], {}, work, 12)
143
- second = pv.prepare_audio([{"text": "乙"}, {"text": "甲"}], {}, work, 12)
144
- third = pv.prepare_audio([{"text": "丙"}, {"text": "甲"}], {}, work, 12)
145
- self.assertEqual(calls, ["甲", "乙", "丙"])
146
- self.assertEqual(first[0]["cache_key"], second[1]["cache_key"])
147
- self.assertAlmostEqual(second[0]["dur"], 1.1)
148
- self.assertNotEqual(second[0]["cache_key"], third[0]["cache_key"])
149
-
150
- def test_partial_gsv_batch_keeps_full_original_order(self):
151
- with tempfile.TemporaryDirectory() as directory:
152
- work = Path(directory)
153
- (work / "wav").mkdir()
154
- calls = []
155
- def synth(texts, target, voice):
156
- calls.append(list(texts))
157
- for index, text in enumerate(texts):
158
- path = target / f"{index:02d}.wav"
159
- if not path.exists():
160
- write_wave(path, amplitude=1000 + index * 1000, duration=.5 + index * .1)
161
- def convert(source, destination):
162
- shutil.copyfile(source, destination)
163
- with wave.open(str(destination), "rb") as stream:
164
- pcm = array.array("h")
165
- pcm.frombytes(stream.readframes(stream.getnframes()))
166
- return pcm, stream.getframerate()
167
- voice = {"gsvRepo": "repo", "gsvRef": "reference", "gsvPrompt": "原话"}
168
- shots = [{"text": "甲"}, {"text": "乙"}, {"text": "丙"}]
169
- with patch.object(pv, "gsv_batch", synth), patch.object(pv, "to_wav", convert):
170
- first = pv.prepare_audio(shots, voice, work, 12)
171
- middle = next((work / "gsv").glob("*/01.wav"))
172
- middle.unlink()
173
- second = pv.prepare_audio(shots, voice, work, 12)
174
- self.assertEqual(calls, [["甲", "乙", "丙"], ["甲", "乙", "丙"]])
175
- self.assertEqual([take["dur"] for take in first], [take["dur"] for take in second])
176
-
177
- def test_inner_speaker_explicitly_selects_edge_voice(self):
178
- voice = pv.shot_voice({"gsvRepo": "repo", "gsvRef": "ref", "gsvPrompt": "原话"},
179
- {"voice": {"mode": "edge", "name": "inner"}})
180
- self.assertEqual(voice["name"], "inner")
181
- self.assertFalse(any(key.startswith("gsv") for key in voice))
182
-
183
- def test_pcm_timeline_preserves_tail_and_pause_and_does_not_overlap(self):
184
- shots = [{"text": "甲", "pause_after": .35}, {"text": "乙"}]
185
- audios = [{"dur": 1.137, "env": [0, .7, 0]}, {"dur": 2.217, "env": [.2]}]
186
- starts, total, cues = pv.make_timeline(shots, audios, .18, .12, .5, 12)
187
- self.assertAlmostEqual(starts[1], .18 + 1.137 + .35)
188
- self.assertGreaterEqual(total, cues[-1]["end"] + .5)
189
- self.assertLess(total - (cues[-1]["end"] + .5), 1 / 12)
190
- self.assertGreater(cues[1]["start"], cues[0]["end"])
191
- self.assertEqual(cues[0]["mouth_frames"], 1)
192
- with tempfile.TemporaryDirectory() as directory:
193
- path = Path(directory) / "captions.srt"
194
- pv.write_captions(cues, path)
195
- self.assertIn("00:00:00,180 --> 00:00:01,317", path.read_text(encoding="utf-8"))
196
-
197
- def test_lip_envelope_responds_to_pcm_silence(self):
198
- samples = array.array("h", [0] * 6000 + [3000, -3000] * 3000 + [0] * 6000)
199
- env = pv.envelope(samples, 24000, 12)
200
- self.assertEqual(env[0], 0)
201
- self.assertEqual(env[-1], 0)
202
- self.assertGreater(max(env), .9)
203
-
204
-
205
- class GsvHelper(unittest.TestCase):
206
- def test_output_paths_are_fixed_before_model_changes_cwd(self):
207
- with tempfile.TemporaryDirectory() as directory:
208
- batch = Path(directory) / "incoming" / "batch.json"
209
- target = Path(directory) / "audio"
210
- items = gsv.resolve_items([{"text": "甲", "out": "00.wav"}], batch, str(target))
211
- self.assertEqual(Path(items[0]["out"]), target / "00.wav")
212
- items = gsv.resolve_items([{"text": "甲", "out": "00.wav"}], batch)
213
- self.assertEqual(Path(items[0]["out"]), batch.parent / "00.wav")
214
-
215
- def test_same_output_filename_requires_matching_metadata(self):
216
- with tempfile.TemporaryDirectory() as directory:
217
- path = Path(directory) / "00.wav"
218
- write_wave(path)
219
- key = gsv.take_key("甲", {"speed": 1.06})
220
- self.assertFalse(gsv.cached_take(path, key))
221
- path.with_suffix(".json").write_text(json.dumps({"key": key}), encoding="utf-8")
222
- self.assertTrue(gsv.cached_take(path, key))
223
- self.assertFalse(gsv.cached_take(path, gsv.take_key("乙", {"speed": 1.06})))
224
-
225
-
226
- class Stage(unittest.TestCase):
227
- def test_mouth_patch_stays_on_reviewed_source_mouth(self):
228
- rig = pv.DialoguePortrait()
229
- self.assertTrue(rig.rigged)
230
- closed = rig.render(0).convert("RGB")
231
- opened = rig.render(1).convert("RGB")
232
- box = ImageChops.difference(closed, opened).getbbox()
233
- self.assertIsNotNone(box)
234
- # Original mouth is x505..520/y513..517 in the 1037x1100 source.
235
- self.assertTrue(330 <= box[0] < box[2] <= 370, box)
236
- self.assertTrue(335 <= box[1] < box[3] <= 368, box)
237
-
238
- def test_invalid_render_settings_fail_before_starting_audio(self):
239
- with tempfile.TemporaryDirectory() as directory:
240
- root = Path(directory)
241
- with self.assertRaises(ValueError):
242
- pv.build({"shots": []}, root / "out.mp4", root)
243
- with self.assertRaises(ValueError):
244
- pv.build({"shots": [{}]}, root / "out.mp4", root, fps=0)
245
- with self.assertRaises(ValueError):
246
- pv.build({"shots": [{}], "width": 900}, root / "out.mp4", root)
247
-
248
-
249
- if __name__ == "__main__":
250
- unittest.main(verbosity=2)
@@ -1,255 +0,0 @@
1
- """Offline MCP regression for bili_reply pictures (2026-10-22, sticker request).
2
-
3
- Loads the real vendor mcp_server.py with bilibili_api + guard replaced by fakes,
4
- then pins the pictures rules: floor replies refuse pictures, >2 pictures
5
- refused, missing local file refused, URL entries deferred to load_remote_pictures,
6
- and pic= actually reaches comment.send_comment only for new comments.
7
- No network, no credentials, no real comment API.
8
- """
9
- from __future__ import annotations
10
-
11
- import ast
12
- import asyncio
13
- import importlib.util
14
- import json
15
- import sys
16
- import tempfile
17
- import types
18
- import unittest
19
- from pathlib import Path
20
-
21
- ROOT = Path(__file__).resolve().parents[1]
22
- MCP = ROOT / "vendor" / "bilibili-mcp" / "mcp_server.py"
23
-
24
-
25
- def load_mcp_with_fakes():
26
- """Import mcp_server.py but stub every third-party touchpoint before exec."""
27
- source = MCP.read_text(encoding="utf-8")
28
- tree = ast.parse(source)
29
- # Syntax sanity first: file must parse clean.
30
- compile(tree, str(MCP), "exec")
31
-
32
- fake = types.ModuleType("fake_bilibili_api")
33
-
34
- class Picture:
35
- calls: list = []
36
-
37
- def __init__(self, spec):
38
- self.spec = spec
39
-
40
- @classmethod
41
- def from_file(cls, path):
42
- cls.calls.append(("from_file", path))
43
- return cls(("file", path))
44
-
45
- @classmethod
46
- async def load_url(cls, url):
47
- cls.calls.append(("load_url", url))
48
- return cls(("url", url))
49
-
50
- class CommentResourceType:
51
- VIDEO = "VIDEO"
52
- ARTICLE = "ARTICLE"
53
- AUDIO = "AUDIO"
54
- DYNAMIC = "DYNAMIC"
55
- DYNAMIC_DRAW = "DYNAMIC_DRAW"
56
-
57
- fake.Picture = Picture
58
- fake.CommentResourceType = CommentResourceType
59
-
60
- comment_mod = types.ModuleType("fake_bilibili_api.comment")
61
- comment_mod.send_comment_calls = []
62
-
63
- async def fake_send_comment(text, oid, type_, root=None, parent=None, credential=None, pic=None):
64
- comment_mod.send_comment_calls.append(
65
- {"text": text, "oid": oid, "type": type_, "root": root, "parent": parent, "pic": pic})
66
- return {"rpid": 4242, "root": root or 0, "parent": parent or 0, "rpid_str": "4242"}
67
-
68
- comment_mod.send_comment = fake_send_comment
69
- fake.comment = comment_mod
70
-
71
- utils_mod = types.ModuleType("fake_bilibili_api.utils")
72
- picture_mod = types.ModuleType("fake_bilibili_api.utils.picture")
73
- picture_mod.Picture = Picture
74
- utils_mod.picture = picture_mod
75
- fake.utils = utils_mod
76
-
77
- sentinels: dict = {}
78
-
79
- def install(name: str, **attrs):
80
- mod = types.ModuleType(name)
81
- for key, value in attrs.items():
82
- setattr(mod, key, value)
83
- sys.modules[name] = mod
84
- return mod
85
-
86
- def stub_module(name: str, extra_names: tuple = ()) -> types.ModuleType:
87
- """ModuleType fails `from X import Y` for names not set as attributes; pre-stub them."""
88
- mod = install(name)
89
- for attr in extra_names:
90
- if not hasattr(mod, attr):
91
- setattr(mod, attr, types.ModuleType(f"{name}.{attr}"))
92
- return mod
93
-
94
- stub_module("bilibili_api", ("video", "search", "comment", "Credential", "dynamic", "opus",
95
- "hot", "rank", "user", "session", "favorite_list"))
96
- sys.modules["bilibili_api"].Picture = Picture
97
- sys.modules["bilibili_api"].CommentResourceType = CommentResourceType
98
- sys.modules["bilibili_api"].Credential = object # never constructed in these tests
99
- comment_pkg = install("bilibili_api.comment", send_comment=fake_send_comment, CommentResourceType=CommentResourceType)
100
- sys.modules["bilibili_api"].comment = comment_pkg # `from bilibili_api import comment` reads the parent attr
101
- install("bilibili_api.utils", picture=picture_mod)
102
- install("bilibili_api.utils.picture", Picture=Picture)
103
- sys.modules["bilibili_api.utils"].picture = picture_mod
104
- stub_module("bilibili_api.video_uploader", ("VideoUploader", "VideoUploaderPage", "VideoMeta", "Lines"))
105
- login_stub = stub_module("bilibili_api.login_v2")
106
- # `QrCodeLogin | None` annotations evaluate eagerly at module level → need real classes, not modules.
107
- class QrCodeLoginStub: # noqa: D401 - placeholder type
108
- pass
109
- class QrCodeLoginEventsStub: # noqa: D401
110
- pass
111
- login_stub.QrCodeLogin = QrCodeLoginStub
112
- login_stub.QrCodeLoginEvents = QrCodeLoginEventsStub
113
-
114
- # video.get_info must not hit network: module imports `video` and calls video.get_info.
115
- async def fake_get_info(bvid):
116
- return {"aid": 12345, "bvid": bvid}
117
-
118
- video_mod = install("bilibili_api.video", get_info=fake_get_info)
119
- video_mod.video = types.SimpleNamespace(get_info=fake_get_info)
120
-
121
- # Guard: record reservations instead of touching real state files.
122
- guard_mod = install("bili_guard",
123
- reserve_send=lambda key: {"key": key},
124
- mark_sent=lambda key: None,
125
- mark_unknown=lambda key: None,
126
- note_risk=lambda *a, **k: None)
127
- sentinels["guard"] = guard_mod
128
-
129
- spec = importlib.util.spec_from_file_location("mcp_server_under_test", MCP)
130
- mod = importlib.util.module_from_spec(spec)
131
- spec.loader.exec_module(mod)
132
- return mod, Picture, comment_mod
133
-
134
-
135
- class BiliReplyPictures(unittest.TestCase):
136
- @classmethod
137
- def setUpClass(cls):
138
- cls.mcp, cls.Picture, cls.comment = load_mcp_with_fakes()
139
- # mcp.bili_reply may be the raw fn (no BILI_MCP_TOOLS whitelist) or the lossless wrapper.
140
- # Store as staticmethod: a plain function on the class binds `self` on instance access,
141
- # which showed up as "got multiple values for argument 'bvid'".
142
- target = cls.mcp.bili_reply
143
- target = getattr(target, "fn", target)
144
- target = getattr(target, "__wrapped__", target)
145
- cls.reply = staticmethod(target)
146
- # Neutralize every real-world side channel BEFORE any call:
147
- # get_cred reads the user's credential file, video.Video reaches the network,
148
- # _get_runtime_guard writes the home SQLite ledger. None of that may happen here.
149
- cls.mcp.get_cred = lambda: object()
150
-
151
- class FakeVideo:
152
- def __init__(self, bvid=None, credential=None):
153
- self.bvid = bvid
154
-
155
- async def get_info(self):
156
- return {"aid": 12345, "bvid": self.bvid}
157
-
158
- cls.mcp.video.Video = FakeVideo
159
-
160
- class FakeGuard:
161
- def __init__(self):
162
- self.reserved = []
163
-
164
- def reserve_send(self, key, legacy_keys=()):
165
- # 2026-10-25:mcp_server.py 改成传跨通道 legacy keys 了,
166
- # 测试桩跟着收第二个参数(收不收都行,这里只关心 key)。
167
- self.reserved.append(key)
168
- return {"allowed": True}
169
-
170
- def commit_send(self, *args, **kwargs):
171
- pass
172
-
173
- def reject_send(self, *args, **kwargs):
174
- return {}
175
-
176
- def mark_sent(self, *args, **kwargs):
177
- pass
178
-
179
- def mark_unknown(self, *args, **kwargs):
180
- pass
181
-
182
- def note_risk(self, *args, **kwargs):
183
- pass
184
-
185
- cls.guard = FakeGuard()
186
- cls.mcp._get_runtime_guard = lambda: cls.guard
187
-
188
- def test_01_signature_declares_pictures(self):
189
- import inspect
190
- params = inspect.signature(self.reply).parameters
191
- self.assertIn("pictures", params)
192
- self.assertEqual(params["pictures"].default, None)
193
-
194
- def test_02_floor_reply_with_pictures_refuses_before_send(self):
195
- before = len(self.comment.send_comment_calls)
196
- out = json.loads(asyncio.run(self.reply(bvid="BV1aaaaaaaaa", text="hi", rpid="8001", pictures=["whatever"])))
197
- self.assertFalse(out.get("success", True))
198
- self.assertIn("回复楼层", out.get("error", ""))
199
- self.assertFalse(out.get("retry_blocked", False))
200
- self.assertEqual(len(self.comment.send_comment_calls), before, "floor refusal must not call send_comment")
201
-
202
- def test_03_too_many_pictures_refused(self):
203
- out = json.loads(asyncio.run(self.reply(
204
- bvid="BV1aaaaaaaaa", text="hi", rpid="0",
205
- pictures=["a.jpg", "b.jpg", "c.jpg"])))
206
- self.assertFalse(out.get("success", True))
207
- self.assertIn("最多 2 张", out.get("error", ""))
208
-
209
- def test_04_missing_local_file_refused(self):
210
- out = json.loads(asyncio.run(self.reply(
211
- bvid="BV1aaaaaaaaa", text="hi", rpid="0",
212
- pictures=[r"C:\definitely\not\here.jpg"])))
213
- self.assertFalse(out.get("success", True))
214
- self.assertIn("图片不存在", out.get("error", ""))
215
- self.assertTrue(out.get("invalid_pictures"))
216
-
217
- def test_05_new_comment_local_picture_reaches_send_comment(self):
218
- self.Picture.calls.clear()
219
- self.comment.send_comment_calls.clear()
220
- with tempfile.TemporaryDirectory() as directory:
221
- picture = Path(directory) / "s.jpg"
222
- picture.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 12)
223
- out = json.loads(asyncio.run(self.reply(
224
- bvid="BV1aaaaaaaaa", text="带上表情", rpid="0", pictures=[str(picture)])))
225
- self.assertTrue(out.get("success"), out)
226
- kinds = [kind for kind, _ in self.Picture.calls]
227
- self.assertIn("from_file", kinds)
228
- self.assertEqual(len(self.comment.send_comment_calls), 1)
229
- call = self.comment.send_comment_calls[0]
230
- self.assertIsNotNone(call["pic"], "pic= must be passed for new comments with pictures")
231
- self.assertEqual(call["root"], None)
232
- self.assertEqual(call["parent"], None)
233
-
234
- def test_06_reply_without_pictures_stays_text_only(self):
235
- self.comment.send_comment_calls.clear()
236
- asyncio.run(self.reply(bvid="BV1aaaaaaaaa", text="纯文字回复", rpid="8001", root="9001"))
237
- call = self.comment.send_comment_calls[-1]
238
- self.assertIsNone(call["pic"], "text floor replies must not pass pic=")
239
- self.assertEqual(call["parent"], 8001)
240
-
241
- def test_07_url_picture_defers_to_load_url(self):
242
- self.Picture.calls.clear()
243
- self.comment.send_comment_calls.clear()
244
- with tempfile.TemporaryDirectory() as directory:
245
- out = json.loads(asyncio.run(self.reply(
246
- bvid="BV1aaaaaaaaa", text="网络表情", rpid="0",
247
- pictures=["https://i0.hdslb.com/bfs/x.jpg"])))
248
- self.assertTrue(out.get("success"), out)
249
- kinds = [kind for kind, _ in self.Picture.calls]
250
- self.assertIn("load_url", kinds)
251
- self.assertIn("https://i0.hdslb.com/bfs/x.jpg", [arg for _, arg in self.Picture.calls])
252
-
253
-
254
- if __name__ == "__main__":
255
- unittest.main(verbosity=2)
@@ -1,78 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, rmSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import {
7
- tokenTaskStart, tokenTaskUpdate, tokenTaskEnd, tokenLedgerSnapshot, clearTokenLedger, TOKEN_LEDGER_MAX,
8
- } from '../lib/tokens.mjs'
9
-
10
- test('session task ledger records measured tokens and ends idempotently', () => {
11
- const home = mkdtempSync(join(tmpdir(), 'dafeiyu-token-'))
12
- try {
13
- const a = tokenTaskStart(home, { kind: 'session', label: '鲸鱼摸鱼会话', model: 'p/m', budget: 100000, maxRounds: 8, why: 'session' })
14
- assert.ok(a.id, 'start returns an id for the caller to keep')
15
- assert.equal(a.tokensUnknown, false)
16
- tokenTaskUpdate(home, a.id, { rounds: 2, tokens: { input: 8000, output: 1500 } })
17
- tokenTaskUpdate(home, a.id, { rounds: 5, tokens: { input: 21000, output: 3000 } })
18
- tokenTaskEnd(home, a.id, { stopReason: 'user', tokens: { input: 26000, output: 4000, total: 30000 } })
19
- // 收账是幂等的:clear 再收一次不重写时间、不重置 token
20
- tokenTaskEnd(home, a.id, { stopReason: 'clear', rounds: 9 })
21
- const snap = tokenLedgerSnapshot(home, { limit: 10 })
22
- assert.equal(snap.entries.length, 1)
23
- const e = snap.entries[0]
24
- assert.equal(e.rounds, 5, 'end with explicit rounds overrides, idempotent end does not reset to 9')
25
- assert.equal(e.stopReason, 'clear', 'later stopReason wins')
26
- assert.equal(e.tokens.total, 30000)
27
- assert.ok(e.endedAt, 'endedAt set once')
28
- assert.ok(e.durationSec >= 0)
29
- assert.equal(snap.sums.tasks, 1)
30
- assert.equal(snap.sums.total, 30000)
31
- } finally { rmSync(home, { recursive: true, force: true }) }
32
- })
33
-
34
- test('background tasks stay unmeasured until real beacon data arrives, then count', () => {
35
- const home = mkdtempSync(join(tmpdir(), 'dafeiyu-token-'))
36
- try {
37
- // ① 没有信标对账:宿主读不到子进程的计量 ⇒ 如实标未计量,不计入总和(宁缺毋假)
38
- const b = tokenTaskStart(home, { kind: 'background', label: '桌宠后台轮', model: 'p/b', budget: 60000, why: 'pet' })
39
- assert.equal(b.tokensUnknown, true)
40
- tokenTaskEnd(home, b.id, { stopReason: 'exit', code: 0 })
41
- let snap = tokenLedgerSnapshot(home, {})
42
- assert.equal(snap.entries[0].tokens, null)
43
- assert.equal(snap.entries[0].tokensUnknown, true)
44
- assert.equal(snap.sums.tasks, 0)
45
- assert.equal(snap.sums.unknown, 1)
46
- assert.equal(snap.sums.total, 0)
47
- // ② 信标对账(reconcileBgUsage 按 pid 从 bg-usage.jsonl 读到的真实计量):
48
- // 这不是猜数,是子进程自报 ⇒ 收下、翻成已计量、计入总和
49
- const c = tokenTaskStart(home, { kind: 'background', label: '桌宠后台轮·信标', model: 'p/b', budget: 60000, why: 'pet' })
50
- assert.equal(c.tokensUnknown, true)
51
- tokenTaskEnd(home, c.id, { stopReason: 'exit', code: 0, tokens: { input: 1, output: 2, total: 3 } })
52
- snap = tokenLedgerSnapshot(home, {})
53
- assert.equal(snap.entries[0].tokensUnknown, false, '真实计量到达后未计量翻转为已计量')
54
- assert.deepEqual(snap.entries[0].tokens, { input: 1, output: 2, total: 3 })
55
- assert.equal(snap.sums.tasks, 1)
56
- assert.equal(snap.sums.unknown, 1, '没计量到的那条仍然如实留在 unknown 里')
57
- assert.equal(snap.sums.total, 3)
58
- } finally { rmSync(home, { recursive: true, force: true }) }
59
- })
60
-
61
- test('ledger is capped, newest-first in snapshots, and clear empties it', () => {
62
- const home = mkdtempSync(join(tmpdir(), 'dafeiyu-token-'))
63
- try {
64
- for (let i = 0; i < TOKEN_LEDGER_MAX + 10; i++) {
65
- const t = tokenTaskStart(home, { kind: 'background', label: 't' + i, why: 'manual', startedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString() })
66
- tokenTaskEnd(home, t.id, { stopReason: 'exit', code: 0 })
67
- }
68
- let snap = tokenLedgerSnapshot(home, { limit: 5 })
69
- assert.ok(snap.entries.length <= 5, 'snapshot respects limit')
70
- const all = tokenLedgerSnapshot(home, { limit: 200 })
71
- assert.ok(all.entries.length <= TOKEN_LEDGER_MAX, 'storage is capped')
72
- // 新任务在前:第一条应是最后一个启动的
73
- assert.equal(all.entries[0].label, 't' + (TOKEN_LEDGER_MAX + 9))
74
- clearTokenLedger(home)
75
- snap = tokenLedgerSnapshot(home, {})
76
- assert.equal(snap.entries.length, 0)
77
- } finally { rmSync(home, { recursive: true, force: true }) }
78
- })