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/sticker.test.mjs
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
|
|
7
|
-
const root = mkdtempSync(join(tmpdir(), 'fish-sticker-test-')), builtin = join(root, 'builtin')
|
|
8
|
-
mkdirSync(builtin)
|
|
9
|
-
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=', 'base64')
|
|
10
|
-
const alternate = Buffer.concat([png, Buffer.from('another image')])
|
|
11
|
-
writeFileSync(join(builtin, '贴贴.png'), png)
|
|
12
|
-
const priorBuiltin = process.env.DAFEIYU_STICKER_DIR
|
|
13
|
-
process.env.DAFEIYU_STICKER_DIR = builtin
|
|
14
|
-
const { listStickers, collectSticker, forgetSticker, readStickerImage, resolveSticker, safeName } = await import('../lib/sticker.mjs')
|
|
15
|
-
const { downloadStickerImage, publicImageUrl, isPublicImageAddress } = await import('../lib/sticker-download.mjs')
|
|
16
|
-
const { createStickerAutomation, STICKER_DEFAULTS, clampStickerSettings, commentStickerCandidates } = await import('../lib/sticker-automation.mjs')
|
|
17
|
-
const lookupImpl = async () => [{ address: '8.8.8.8', family: 4 }]
|
|
18
|
-
const imageFetch = async () => new Response(alternate, { headers: { 'content-type': 'image/png' } })
|
|
19
|
-
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
20
|
-
let sequence = 0
|
|
21
|
-
const home = () => { const dir = join(root, 'home-' + ++sequence); mkdirSync(dir); writeFileSync(join(dir, 'state.json'), JSON.stringify({ tokensTotal: 1234, unrelated: { retained: true } })); return dir }
|
|
22
|
-
const comments = (suffix = 'one') => ({ comments: [{ rpid: '12345678901234567', images: [{ url: `https://images.example.com/${suffix}.png`, w: 256, h: 256 }] }] })
|
|
23
|
-
const collectionCfg = { ...STICKER_DEFAULTS, stickerAutoCollect: true, stickerCollectProbability: 100, stickerCollectCooldown: 0 }
|
|
24
|
-
|
|
25
|
-
test.after(() => { if (priorBuiltin === undefined) delete process.env.DAFEIYU_STICKER_DIR; else process.env.DAFEIYU_STICKER_DIR = priorBuiltin; rmSync(root, { recursive: true, force: true }) })
|
|
26
|
-
|
|
27
|
-
test('sticker settings keep separate switches, exact defaults and bounded percentages/capacity', () => {
|
|
28
|
-
assert.deepEqual(STICKER_DEFAULTS, { stickerAutoCollect: false, stickerCollectProbability: 35, stickerCollectCooldown: 60, stickerAutoSend: false, stickerSendProbability: 30, stickerSendCooldown: 180, stickerCapacity: 60 })
|
|
29
|
-
assert.deepEqual(clampStickerSettings({ stickerAutoCollect: 'true', stickerAutoSend: true, stickerCollectProbability: 200, stickerSendProbability: -10, stickerCollectCooldown: -3, stickerSendCooldown: 999999, stickerCapacity: 500 }), { stickerAutoCollect: false, stickerAutoSend: true, stickerCollectProbability: 100, stickerSendProbability: 0, stickerCollectCooldown: 0, stickerSendCooldown: 86400, stickerCapacity: 60 })
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('public-image validation rejects local addresses, credentials, ports and special address ranges', () => {
|
|
33
|
-
for (const url of ['file:///C:/secrets', 'https://localhost/image', 'http://127.0.0.1/a', 'https://0x7f000001/a', 'http://169.254.169.254/a', 'http://[::1]/a', 'http://[::ffff:127.0.0.1]/a', 'https://a.internal/a', 'https://user:pass@images.example.com/a', 'https://images.example.com:8080/a']) assert.throws(() => publicImageUrl(url), url)
|
|
34
|
-
for (const ip of ['0.0.0.0', '100.64.0.1', '172.31.0.1', '192.168.1.1', '198.18.0.1', '224.0.0.1', '2001:db8::1', '2002:7f00:1::', 'fc00::1']) assert.equal(isPublicImageAddress(ip), false, ip)
|
|
35
|
-
assert.equal(isPublicImageAddress('8.8.8.8'), true)
|
|
36
|
-
assert.equal(isPublicImageAddress('2606:4700:4700::1111'), true)
|
|
37
|
-
assert.equal(publicImageUrl('https://images.example.com/a#fragment').href, 'https://images.example.com/a')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('downloader validates DNS and every redirect before fetching and enforces byte/type limits', async () => {
|
|
41
|
-
let calls = 0
|
|
42
|
-
await assert.rejects(downloadStickerImage('https://images.example.com/a', { lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }], fetchImpl: async () => { calls++; return new Response(png) } }), /内网/)
|
|
43
|
-
assert.equal(calls, 0)
|
|
44
|
-
await assert.rejects(downloadStickerImage('https://images.example.com/a', { lookupImpl, fetchImpl: async () => { calls++; return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/private' } }) } }), /内网/)
|
|
45
|
-
assert.equal(calls, 1)
|
|
46
|
-
await assert.rejects(downloadStickerImage('https://images.example.com/a', { lookupImpl, fetchImpl: async () => new Response(png, { headers: { 'content-length': '9999999' } }) }), /5MB/)
|
|
47
|
-
await assert.rejects(downloadStickerImage('https://images.example.com/a', { lookupImpl, maxBytes: 16, fetchImpl: imageFetch }), /5MB/)
|
|
48
|
-
await assert.rejects(downloadStickerImage('https://images.example.com/a', { lookupImpl, fetchImpl: async () => new Response('<svg/>', { headers: { 'content-type': 'image/svg+xml' } }) }), /图片格式/)
|
|
49
|
-
assert.deepEqual((await downloadStickerImage('https://images.example.com/a', { lookupImpl, fetchImpl: imageFetch })).bytes, alternate)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('collection preserves provenance/state, deduplicates URL/content and keeps builtins read-only', async () => {
|
|
53
|
-
const dir = home(), url = 'https://images.example.com/a.png'
|
|
54
|
-
const added = await collectSticker(dir, { name: '惊喜', url, lookupImpl, fetchImpl: imageFetch, bvid: 'BV1234567890', rpid: '12345678901234567', from: '评论区', tags: ['开心'], description: '笑起来的表情' })
|
|
55
|
-
assert.equal(added.ok, true)
|
|
56
|
-
const state = JSON.parse(readFileSync(join(dir, 'state.json')))
|
|
57
|
-
assert.equal(state.tokensTotal, 1234); assert.deepEqual(state.unrelated, { retained: true })
|
|
58
|
-
assert.equal(state.stickers.惊喜.sourceUrl, url); assert.equal(state.stickers.惊喜.bvid, 'BV1234567890'); assert.equal(state.stickers.惊喜.rpid, '12345678901234567')
|
|
59
|
-
assert.deepEqual(state.stickers.惊喜.tags, ['开心'])
|
|
60
|
-
assert.equal((await collectSticker(dir, { name: '另一个名字', url, lookupImpl, fetchImpl: () => { throw new Error('must not redownload') } })).duplicate, true)
|
|
61
|
-
assert.equal((await collectSticker(dir, { name: '同内容', url: 'https://images.example.com/changed-url.png', lookupImpl, fetchImpl: imageFetch })).duplicate, true)
|
|
62
|
-
assert.equal((await collectSticker(dir, { name: '同内置', url: 'https://images.example.com/builtin.png', lookupImpl, fetchImpl: async () => new Response(png) })).duplicate, true)
|
|
63
|
-
assert.equal((await collectSticker(dir, { name: '贴贴', url, lookupImpl, fetchImpl: imageFetch })).ok, false)
|
|
64
|
-
assert.equal(forgetSticker(dir, '贴贴').ok, false)
|
|
65
|
-
assert.deepEqual(readFileSync(join(builtin, '贴贴.png')), png)
|
|
66
|
-
assert.equal(listStickers(dir).length, 2)
|
|
67
|
-
const image = readStickerImage(dir, '惊喜', 'collected'); assert.deepEqual(image.bytes, alternate); assert.equal(image.mime, 'image/png'); assert.ok(image.etag)
|
|
68
|
-
assert.equal(forgetSticker(dir, '惊喜').ok, true); assert.equal(listStickers(dir).length, 1)
|
|
69
|
-
assert.equal(JSON.parse(readFileSync(join(dir, 'state.json'))).tokensTotal, 1234)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('library allows explicit builtin copies while rejecting traversal, arbitrary files and corrupt ledger deletion', async () => {
|
|
73
|
-
const dir = home()
|
|
74
|
-
assert.equal((await collectSticker(dir, { name: '别名', path: '贴贴' })).ok, true)
|
|
75
|
-
assert.equal(resolveSticker(dir, '贴贴'), join(builtin, '贴贴.png'))
|
|
76
|
-
assert.equal(resolveSticker(dir, join(root, 'state.json')), '')
|
|
77
|
-
for (const name of ['../逃逸', '..\\逃逸', 'con', '__proto__']) assert.equal((await collectSticker(dir, { name, path: '贴贴' })).ok, false, name)
|
|
78
|
-
assert.equal(safeName('hello world.png'), 'hello-world')
|
|
79
|
-
assert.equal(readStickerImage(dir, '../state', 'collected'), null)
|
|
80
|
-
const stateFile = join(dir, 'state.json')
|
|
81
|
-
writeFileSync(stateFile, JSON.stringify({ tokensTotal: 88, stickers: { 假图: { file: '../state.json' } } }))
|
|
82
|
-
assert.equal(forgetSticker(dir, '假图').ok, false)
|
|
83
|
-
assert.equal(JSON.parse(readFileSync(stateFile)).tokensTotal, 88)
|
|
84
|
-
writeFileSync(stateFile, '{broken')
|
|
85
|
-
assert.equal((await collectSticker(dir, { name: '图', path: '贴贴' })).ok, false)
|
|
86
|
-
assert.equal(readFileSync(stateFile, 'utf8'), '{broken')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('capacity, auto naming, actual image magic and commit cancellation apply before storage', async () => {
|
|
90
|
-
const dir = home()
|
|
91
|
-
assert.equal((await collectSticker(dir, { name: '贴贴', path: '贴贴', autoName: true, capacity: 1 })).name, '贴贴-2')
|
|
92
|
-
assert.equal((await collectSticker(dir, { name: '第二张', path: '贴贴', capacity: 1 })).ok, false)
|
|
93
|
-
const other = home()
|
|
94
|
-
assert.match((await collectSticker(other, { name: 'HTML', url: 'https://images.example.com/html', lookupImpl, fetchImpl: async () => new Response('<html>not image</html>') })).error, /图片格式/)
|
|
95
|
-
assert.equal((await collectSticker(other, { name: '取消', url: 'https://images.example.com/canceled', lookupImpl, fetchImpl: imageFetch, shouldCommit: () => false })).ok, false)
|
|
96
|
-
assert.equal(listStickers(other, { source: 'collected' }).length, 0)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('comment candidates are bounded and exclude emotes, duplicate images and unreasonable aspect ratios', () => {
|
|
100
|
-
const rows = comments()
|
|
101
|
-
rows.comments[0].images.push({ url: 'http://127.0.0.1/a', w: 256, h: 256 }, { url: 'https://images.example.com/tiny', w: 24, h: 24 }, { url: 'https://images.example.com/wide', w: 2000, h: 50 }, rows.comments[0].images[0])
|
|
102
|
-
rows.comments[0].emotes = [{ url: 'https://images.example.com/emote' }]
|
|
103
|
-
assert.equal(commentStickerCandidates(rows, 'BV1234567890').length, 1)
|
|
104
|
-
assert.equal(commentStickerCandidates(rows, 'BV1234567890')[0].rpid, '12345678901234567')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('automatic collection executes off the comments path with switch, probability, cooldown and concurrency bounds', async () => {
|
|
108
|
-
const dir = home(), calls = [], events = []
|
|
109
|
-
let cfg = { ...collectionCfg, stickerAutoCollect: false }, at = 10000
|
|
110
|
-
const auto = createStickerAutomation({ stateDir: dir, getConfig: () => cfg, now: () => at, random: () => 0, collect: async (_dir, opts) => { calls.push(opts); return { ok: true, name: opts.name } }, onEvent: (kind, data) => events.push({ kind, data }) })
|
|
111
|
-
assert.equal(auto.offer(comments(), 'BV1234567890').queued, 0)
|
|
112
|
-
cfg = { ...cfg, stickerAutoCollect: true, stickerCollectProbability: 0 }
|
|
113
|
-
assert.equal(auto.offer(comments(), 'BV1234567890').queued, 0)
|
|
114
|
-
cfg = { ...cfg, stickerCollectProbability: 100, stickerCollectCooldown: 60 }
|
|
115
|
-
assert.equal(auto.offer(comments(), 'BV1234567890').queued, 1); assert.equal(calls.length, 0)
|
|
116
|
-
await flush(); assert.equal(calls.length, 1); assert.equal(auto.snapshot().lastCollectedAt, at)
|
|
117
|
-
assert.equal(auto.offer(comments('second'), 'BV1234567890').queued, 0)
|
|
118
|
-
at += 60001
|
|
119
|
-
assert.equal(auto.offer(comments('second'), 'BV1234567890').queued, 1)
|
|
120
|
-
await flush(); assert.equal(calls.length, 2); assert.equal(events.filter(x => x.kind === 'collected').length, 2)
|
|
121
|
-
auto.stop()
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('queued and inflight source cancellation prevent disk writes without disabling later runs', async () => {
|
|
125
|
-
const dir = home(), queued = new AbortController(), inflight = new AbortController()
|
|
126
|
-
let requests = 0, started
|
|
127
|
-
const begun = new Promise(resolve => { started = resolve })
|
|
128
|
-
const auto = createStickerAutomation({ stateDir: dir, getConfig: () => collectionCfg, random: () => 0,
|
|
129
|
-
collect: (stateDir, opts) => collectSticker(stateDir, { ...opts, lookupImpl, fetchImpl: async () => { requests++; if (requests === 1) { started(); return new Promise(() => {}) }; return new Response(alternate) } }) })
|
|
130
|
-
auto.offer(comments('queued'), 'BV1234567890', { signal: queued.signal }); queued.abort(new Error('round stopped'))
|
|
131
|
-
await flush(); assert.equal(requests, 0)
|
|
132
|
-
auto.offer(comments('inflight'), 'BV1234567890', { signal: inflight.signal })
|
|
133
|
-
await begun; inflight.abort(new Error('round stopped'))
|
|
134
|
-
await flush(); await flush()
|
|
135
|
-
assert.equal(listStickers(dir, { source: 'collected' }).length, 0)
|
|
136
|
-
assert.equal(auto.snapshot().collectPending, 0)
|
|
137
|
-
auto.offer(comments('next-run'), 'BV1234567890', { signal: new AbortController().signal })
|
|
138
|
-
await flush(); await flush()
|
|
139
|
-
assert.equal(listStickers(dir, { source: 'collected' }).length, 1)
|
|
140
|
-
assert.equal(requests, 2)
|
|
141
|
-
auto.stop()
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('global disposal aborts active download, bounds pending work and prevents new offers', async () => {
|
|
145
|
-
const dir = home(), calls = []; let release
|
|
146
|
-
const auto = createStickerAutomation({ stateDir: dir, getConfig: () => collectionCfg, random: () => 0,
|
|
147
|
-
collect: async (_dir, opts) => { calls.push(opts); return new Promise(resolve => { release = resolve }) } })
|
|
148
|
-
for (let i = 0; i < 10; i++) auto.offer(comments(String(i)), 'BV1234567890')
|
|
149
|
-
assert.equal(auto.snapshot().collectPending, 3)
|
|
150
|
-
await flush(); assert.equal(calls.length, 1)
|
|
151
|
-
auto.stop(); assert.equal(calls[0].signal.aborted, true); assert.equal(calls[0].shouldCommit(), false)
|
|
152
|
-
release({ ok: false, error: 'canceled' }); await flush()
|
|
153
|
-
assert.equal(auto.offer(comments('later'), 'BV1234567890').queued, 0)
|
|
154
|
-
assert.equal(auto.snapshot().collectPending, 0)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('automatic send respects independent gates, reservations, successful-only cooldown and explicit completion', () => {
|
|
158
|
-
const dir = home(); let at = 10000, cfg = { ...STICKER_DEFAULTS, stickerAutoSend: true, stickerSendProbability: 0 }
|
|
159
|
-
const auto = createStickerAutomation({ stateDir: dir, getConfig: () => cfg, now: () => at, random: () => 0, list: () => [{ name: '贴贴', file: '/library/one.png', tags: ['喜欢'] }, { name: '惊讶', file: '/library/two.png', tags: ['惊讶'] }] })
|
|
160
|
-
assert.equal(auto.pickForSend('喜欢这一段'), null)
|
|
161
|
-
cfg = { ...cfg, stickerSendProbability: 100, stickerSendCooldown: 180 }
|
|
162
|
-
const first = auto.pickForSend('喜欢这一段'); assert.equal(first.name, '贴贴')
|
|
163
|
-
assert.equal(auto.pickForSend('另一个'), null, 'concurrent selection cannot reserve twice')
|
|
164
|
-
first.finish(false); const retry = auto.pickForSend('喜欢这一段'); assert.ok(retry); retry.finish(true)
|
|
165
|
-
assert.equal(auto.snapshot().lastSentAt, at); assert.equal(auto.pickForSend('惊讶'), null)
|
|
166
|
-
at += 180001; const second = auto.pickForSend('惊讶'); assert.equal(second.name, '惊讶'); second.finish(true)
|
|
167
|
-
at += 180001; cfg.stickerAutoSend = false; assert.equal(auto.pickForSend('喜欢'), null)
|
|
168
|
-
auto.stop()
|
|
169
|
-
})
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
"""Offline regression for cross-channel reply dedup (2026-10-25).
|
|
2
|
-
|
|
3
|
-
使用者报:「有的时候回复动态或者评论,会回一个人一条评论两条消息,修复一下,
|
|
4
|
-
不要重复回,一条回一个就行」。
|
|
5
|
-
|
|
6
|
-
真机根因:同一条评论(同一个 rpid)能从两条通道各回一次,两条通道各记各的键、
|
|
7
|
-
谁也不认谁 ——
|
|
8
|
-
|
|
9
|
-
· bili_reply(bvid, rpid) → key_video_reply = video:<rpid>
|
|
10
|
-
· bili_reply_opus(opus_id, rpid) → key_opus_reply = opus-reply:<opusId>:<rpid>
|
|
11
|
-
|
|
12
|
-
账本里实测有 34 个 rpid 这样重复过,最近一例相隔 4 分 24 秒(rpid 318036124737)。
|
|
13
|
-
|
|
14
|
-
这个文件钉住 RuntimeGuard 的跨通道认账:
|
|
15
|
-
· 视频通道:账本里任意 `opus-reply:*:<rpid>` 都算同一条评论;
|
|
16
|
-
· 动态通道:额外认 `video:<rpid>`;
|
|
17
|
-
· 但**别的动态**下同号楼层仍然不拦(别走回 2026-10-20 那个静默漏回的坑)。
|
|
18
|
-
|
|
19
|
-
时钟是注入的假钟,测判重时把时间推过节奏闸,免得 pace 把结论搅浑。
|
|
20
|
-
|
|
21
|
-
跑:python tests/test_guard_cross_channel.py 纯 SQLite,不联网、不碰凭证。
|
|
22
|
-
"""
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import sys
|
|
26
|
-
import tempfile
|
|
27
|
-
import unittest
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
ROOT = Path(__file__).resolve().parents[1]
|
|
31
|
-
sys.path.insert(0, str(ROOT / "vendor" / "bilibili-mcp"))
|
|
32
|
-
|
|
33
|
-
from runtime_guard import ( # noqa: E402
|
|
34
|
-
RuntimeGuard, cross_channel_keys, key_opus_reply, key_video_reply, legacy_opus_keys,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
RPID = "318036124737"
|
|
38
|
-
OPUS = "1262700971822153744"
|
|
39
|
-
OTHER_OPUS = "1262712387822157856"
|
|
40
|
-
BVID = "BV14AHP6VExR"
|
|
41
|
-
HOUR = 3600.0
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class Clock:
|
|
45
|
-
"""可推进的假钟:判重看的是账本,不是墙上时间。"""
|
|
46
|
-
|
|
47
|
-
def __init__(self, t=1_760_000_000.0):
|
|
48
|
-
self.t = t
|
|
49
|
-
|
|
50
|
-
def __call__(self):
|
|
51
|
-
return self.t
|
|
52
|
-
|
|
53
|
-
def tick(self, seconds=HOUR):
|
|
54
|
-
self.t += seconds
|
|
55
|
-
return self
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class CrossChannelDedup(unittest.TestCase):
|
|
59
|
-
def setUp(self):
|
|
60
|
-
self._tmp = tempfile.TemporaryDirectory()
|
|
61
|
-
self.clock = Clock()
|
|
62
|
-
self.guard = RuntimeGuard(Path(self._tmp.name), clock=self.clock)
|
|
63
|
-
|
|
64
|
-
def tearDown(self):
|
|
65
|
-
self._tmp.cleanup()
|
|
66
|
-
|
|
67
|
-
def sent(self, key, legacy=(), meta=None):
|
|
68
|
-
"""占一次坑并落成 sent,模拟「这条真的回出去了」。"""
|
|
69
|
-
self.clock.tick()
|
|
70
|
-
res = self.guard.reserve_send(key, list(legacy))
|
|
71
|
-
self.assertTrue(res.get("allowed"), f"占坑被拒:{res}")
|
|
72
|
-
self.guard.commit_send(res, meta or {})
|
|
73
|
-
return res
|
|
74
|
-
|
|
75
|
-
# ── 视频通道 ────────────────────────────────────────────────────────────
|
|
76
|
-
def test_video_channel_recognizes_opus_reply_of_same_rpid(self):
|
|
77
|
-
"""先回动态、再回视频:同一条评论不许回第二遍(这次的真机事故)。"""
|
|
78
|
-
self.sent(key_opus_reply(OPUS, RPID), meta={"opus_id": OPUS, "rpid": RPID})
|
|
79
|
-
self.clock.tick()
|
|
80
|
-
res = self.guard.reserve_send(key_video_reply(BVID, RPID))
|
|
81
|
-
self.assertFalse(res.get("allowed"), "同一条评论从视频通道又被放行了")
|
|
82
|
-
self.assertEqual(res.get("key"), f"opus-reply:{OPUS}:{RPID}")
|
|
83
|
-
|
|
84
|
-
def test_video_channel_allows_different_rpid(self):
|
|
85
|
-
self.sent(key_opus_reply(OPUS, RPID), meta={"opus_id": OPUS, "rpid": RPID})
|
|
86
|
-
self.clock.tick()
|
|
87
|
-
res = self.guard.reserve_send(key_video_reply(BVID, "318036124738"))
|
|
88
|
-
self.assertTrue(res.get("allowed"), f"不相干的评论被拦:{res}")
|
|
89
|
-
|
|
90
|
-
def test_video_channel_ignores_rpid_suffix_collision(self):
|
|
91
|
-
"""后缀必须整段相等,别被短 rpid 或末尾几位糊弄。"""
|
|
92
|
-
self.sent(key_opus_reply(OPUS, RPID), meta={"opus_id": OPUS, "rpid": RPID})
|
|
93
|
-
for other in ("360124737", "24737", "7", "3180361247"):
|
|
94
|
-
self.clock.tick()
|
|
95
|
-
res = self.guard.reserve_send(key_video_reply(BVID, other))
|
|
96
|
-
self.assertTrue(res.get("allowed"), f"{other} 被误判成回过了")
|
|
97
|
-
|
|
98
|
-
# ── 动态通道 ────────────────────────────────────────────────────────────
|
|
99
|
-
def test_opus_channel_recognizes_video_reply_of_same_rpid(self):
|
|
100
|
-
"""先回视频、再回动态:反过来的那一半。"""
|
|
101
|
-
self.sent(key_video_reply(BVID, RPID), meta={"bvid": BVID, "rpid": RPID})
|
|
102
|
-
self.clock.tick()
|
|
103
|
-
res = self.guard.reserve_send(
|
|
104
|
-
key_opus_reply(OPUS, RPID),
|
|
105
|
-
[*legacy_opus_keys(RPID, "0"), *cross_channel_keys(RPID)],
|
|
106
|
-
)
|
|
107
|
-
self.assertFalse(res.get("allowed"), "视频通道回过的评论又从动态通道回了一遍")
|
|
108
|
-
|
|
109
|
-
def test_opus_channel_still_blocks_same_floor(self):
|
|
110
|
-
self.sent(key_opus_reply(OPUS, RPID), meta={"opus_id": OPUS, "rpid": RPID})
|
|
111
|
-
self.clock.tick()
|
|
112
|
-
res = self.guard.reserve_send(key_opus_reply(OPUS, RPID),
|
|
113
|
-
[*legacy_opus_keys(RPID, "0"), *cross_channel_keys(RPID)])
|
|
114
|
-
self.assertFalse(res.get("allowed"))
|
|
115
|
-
|
|
116
|
-
def test_opus_channel_does_not_borrow_other_dynamic(self):
|
|
117
|
-
"""⚠️ 别的动态下同号楼层仍然不拦 —— 2026-10-20 静默漏回的那个坑。"""
|
|
118
|
-
self.sent(key_opus_reply(OPUS, RPID), meta={"opus_id": OPUS, "rpid": RPID})
|
|
119
|
-
self.clock.tick()
|
|
120
|
-
res = self.guard.reserve_send(key_opus_reply(OTHER_OPUS, RPID),
|
|
121
|
-
[*legacy_opus_keys(RPID, "0"), *cross_channel_keys(RPID)])
|
|
122
|
-
self.assertTrue(res.get("allowed"), f"别的动态下的同号楼层被误判成回过了:{res}")
|
|
123
|
-
|
|
124
|
-
# ── 边界 ────────────────────────────────────────────────────────────────
|
|
125
|
-
def test_cross_channel_keys_handles_zero_and_bad_ids(self):
|
|
126
|
-
self.assertEqual(cross_channel_keys(0), [])
|
|
127
|
-
self.assertEqual(cross_channel_keys("0"), [])
|
|
128
|
-
self.assertEqual(cross_channel_keys(RPID), ["video:" + RPID])
|
|
129
|
-
with self.assertRaises((TypeError, ValueError)):
|
|
130
|
-
cross_channel_keys("abc")
|
|
131
|
-
|
|
132
|
-
def test_new_comment_key_is_not_cross_channel(self):
|
|
133
|
-
"""发新评论(rpid=0)走 nu:<bvid>,不该被 rpid 判重串味。"""
|
|
134
|
-
self.sent(key_video_reply(BVID, RPID), meta={"bvid": BVID, "rpid": RPID})
|
|
135
|
-
self.clock.tick()
|
|
136
|
-
res = self.guard.reserve_send(key_video_reply(BVID, 0))
|
|
137
|
-
self.assertTrue(res.get("allowed"), f"发新评论被楼层判重拦了:{res}")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if __name__ == "__main__":
|
|
141
|
-
unittest.main(verbosity=2)
|
package/tests/test_pv_cover.py
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"""Cover selection/render regressions. No network, TTS or rendering loop."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import importlib.util
|
|
5
|
-
import sys
|
|
6
|
-
import tempfile
|
|
7
|
-
import unittest
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from PIL import Image
|
|
11
|
-
|
|
12
|
-
ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
-
sys.path.insert(0, str(ROOT / "lib"))
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def module(name):
|
|
17
|
-
spec = importlib.util.spec_from_file_location(name, ROOT / "lib" / f"{name}.py")
|
|
18
|
-
result = importlib.util.module_from_spec(spec)
|
|
19
|
-
spec.loader.exec_module(result)
|
|
20
|
-
return result
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
covers = module("pv_cover")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class Titles(unittest.TestCase):
|
|
27
|
-
def test_rejects_dates_report_labels_and_placeholders(self):
|
|
28
|
-
for bad in ("2026-10-22 日报", "十月二十二日蜻蜓消失", "在10月22号看见蜻蜓",
|
|
29
|
-
"2026.10.22 蜻蜓消失", "大肥鱼日报", "今日日报", "TODO 起标题", "BV1abcDEF234"):
|
|
30
|
-
with self.assertRaises(ValueError, msg=bad):
|
|
31
|
-
covers.validate_title(bad)
|
|
32
|
-
|
|
33
|
-
def test_keeps_concrete_event_phrases_and_decimal_numbers(self):
|
|
34
|
-
for good in ("蜻蜓的产房没了", "四个字收获746个赞", "三张工牌,同样送外卖",
|
|
35
|
-
"葡萄酒八年跌七成,扫码价高81倍"):
|
|
36
|
-
self.assertEqual(covers.validate_title(good), good)
|
|
37
|
-
|
|
38
|
-
def test_explicit_cover_wins_but_invalid_metadata_is_an_error(self):
|
|
39
|
-
plan = {"date": "2026-10-22", "cover": {"title": "蜻蜓的产房没了"},
|
|
40
|
-
"shots": [{"kind": "story", "title": "别的故事"}]}
|
|
41
|
-
self.assertEqual(covers.choose_title(plan), "蜻蜓的产房没了")
|
|
42
|
-
with self.assertRaises(ValueError):
|
|
43
|
-
covers.choose_title({"cover": {"title": "2026-10-22 日报"}})
|
|
44
|
-
|
|
45
|
-
def test_selection_uses_story_then_list_then_sources(self):
|
|
46
|
-
self.assertEqual(covers.choose_title({"shots": [
|
|
47
|
-
{"kind": "title", "title": "大肥鱼日报"},
|
|
48
|
-
{"kind": "story", "title": "蜻蜓的产房没了"},
|
|
49
|
-
{"kind": "list", "entries": [{"title": "别的条目"}]},
|
|
50
|
-
]}), "蜻蜓的产房没了")
|
|
51
|
-
self.assertEqual(covers.choose_title({"shots": [
|
|
52
|
-
{"kind": "list", "entries": [{"title": "三张工牌,同样送外卖"}]},
|
|
53
|
-
]}), "三张工牌,同样送外卖")
|
|
54
|
-
self.assertEqual(covers.choose_title({"sources": [{"title": "夜市大哥分了半块饼"}]}),
|
|
55
|
-
"夜市大哥分了半块饼")
|
|
56
|
-
with self.assertRaises(ValueError):
|
|
57
|
-
covers.choose_title({"shots": [{"kind": "talk", "text": "只有台词没有标题"}]})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class Artwork(unittest.TestCase):
|
|
61
|
-
def test_both_ratios_are_exact_and_headline_fits(self):
|
|
62
|
-
for ratio, size in (("16x9", (1920, 1080)), ("4x3", (1440, 1080))):
|
|
63
|
-
image = covers.render_cover("三张工牌,同样送外卖", ratio)
|
|
64
|
-
self.assertEqual(image.size, size)
|
|
65
|
-
self.assertEqual(image.mode, "RGBA")
|
|
66
|
-
|
|
67
|
-
def test_multiline_headline_never_starts_with_punctuation(self):
|
|
68
|
-
draw = covers.ImageDraw.Draw(Image.new("RGB", (2, 2)))
|
|
69
|
-
font = covers.NF(90, "Bold")
|
|
70
|
-
lines = covers._wrap_measured(draw, "三张工牌,同样送外卖", font, 760, 2)
|
|
71
|
-
self.assertTrue(lines)
|
|
72
|
-
for line in lines:
|
|
73
|
-
self.assertNotIn(line[0], ",。!?、;:…", line)
|
|
74
|
-
|
|
75
|
-
def test_generation_validates_dimensions_and_preserves_only_missing(self):
|
|
76
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
77
|
-
out = Path(directory)
|
|
78
|
-
covers.generate_covers(out, "蜻蜓的产房没了")
|
|
79
|
-
first = (out / "封面_16x9.png").read_bytes()
|
|
80
|
-
(out / "封面_4x3.png").write_bytes(b"broken")
|
|
81
|
-
covers.generate_covers(out, "蜻蜓的产房没了", only_missing=True)
|
|
82
|
-
self.assertEqual((out / "封面_16x9.png").read_bytes(), first)
|
|
83
|
-
self.assertTrue(covers.valid_cover(out / "封面_4x3.png", "4x3"))
|
|
84
|
-
|
|
85
|
-
def test_invalid_dimension_is_not_a_valid_cover(self):
|
|
86
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
87
|
-
path = Path(directory) / "封面_16x9.png"
|
|
88
|
-
image = covers.render_cover("蜻蜓的产房没了", "4x3")
|
|
89
|
-
image.save(path)
|
|
90
|
-
self.assertFalse(covers.valid_cover(path, "16x9"))
|
|
91
|
-
self.assertTrue(covers.valid_cover(path, "4x3"))
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if __name__ == "__main__":
|
|
95
|
-
unittest.main()
|