dsh-deepmoyu 2.3.0 → 2.4.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.
- package/AGENTS.md +93 -153
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +143 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1404 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +1095 -285
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/update.mjs +265 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- package/tests/verify_pv_sample.py +0 -91
package/tests/test_pv_stage1.py
DELETED
|
@@ -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
|
-
})
|