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,203 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import test from 'node:test'
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
4
- import { dirname, join } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import { setTimeout as delay } from 'node:timers/promises'
7
- import { createPv, listPv, pvDateOk, pvFileOf, PV_PREFIX } from '../lib/pv.mjs'
8
-
9
- const here = dirname(fileURLToPath(import.meta.url))
10
- const python = process.env.PV_TEST_PYTHON || 'python'
11
-
12
- // Minimal valid 1920x1080 / 1440x1080 PNG headers: the coordinator checks
13
- // dimensions, not pixels. No Pillow is needed by this fixture.
14
- const FAKE_COVER = String.raw`
15
- import os, struct, sys, zlib
16
- from pathlib import Path
17
- args = sys.argv
18
- outdir = Path(args[args.index('--outdir') + 1])
19
- only_missing = '--only-missing' in args
20
- if os.environ.get('PV_FAKE_COVER_FAIL') == '1':
21
- raise SystemExit(3)
22
- def png(width, height):
23
- def chunk(kind, data):
24
- return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
25
- ihdr = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)
26
- return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IEND', b'')
27
- for name, size in (('封面_16x9.png', (1920, 1080)), ('封面_4x3.png', (1440, 1080))):
28
- target = outdir / name
29
- if only_missing and target.is_file() and target.stat().st_size > 24:
30
- continue
31
- target.parent.mkdir(parents=True, exist_ok=True)
32
- target.write_bytes(png(*size))
33
- `
34
-
35
- const FAKE_PLAN = `import json,sys
36
- from pathlib import Path
37
- p = Path(sys.argv[sys.argv.index('-o')+1])
38
- p.write_text(json.dumps({'date': p.stem.replace('plan_',''), 'cover': {'title': '蜻蜓的产房没了'},
39
- 'shots': [{'kind': 'story', 'title': '蜻蜓的产房没了', 'text': 'fixture'}]}, ensure_ascii=False), encoding='utf-8')
40
- `
41
-
42
- const FAKE_RENDER = `import os,sys
43
- from pathlib import Path
44
- if os.environ.get('PV_FAKE_FAIL') == '1':
45
- raise SystemExit(7)
46
- marker = os.environ.get('PV_RENDER_MARKER')
47
- if marker:
48
- Path(marker).write_text('rendered', encoding='utf-8')
49
- out = Path(sys.argv[sys.argv.index('--out')+1]); out.parent.mkdir(parents=True, exist_ok=True)
50
- out.write_bytes(b'new-render' + bytes(1600))
51
- work = Path(sys.argv[sys.argv.index('--work')+1]); work.mkdir(parents=True, exist_ok=True)
52
- (work / 'timeline.json').write_text('{}', encoding='utf-8')
53
- `
54
-
55
- async function fixture(run) {
56
- const root = mkdtempSync(join(here, '.tmp-pv-'))
57
- const state = join(root, 'state')
58
- const plugin = join(root, 'plugin')
59
- const work = join(root, 'work')
60
- const out = join(root, 'out')
61
- for (const path of [state, join(plugin, 'lib'), work, out]) mkdirSync(path, { recursive: true })
62
- // A stale home copy must never win script selection.
63
- for (const name of ['pv.py', 'pv_plan.py', 'pv_cover.py']) {
64
- writeFileSync(join(state, name), 'raise RuntimeError("stale home script used")\n')
65
- }
66
- writeFileSync(join(plugin, 'lib', 'pv_plan.py'), FAKE_PLAN, 'utf-8')
67
- writeFileSync(join(plugin, 'lib', 'pv.py'), FAKE_RENDER, 'utf-8')
68
- writeFileSync(join(plugin, 'lib', 'pv_cover.py'), FAKE_COVER, 'utf-8')
69
- try { await run({ root, state, plugin, work, out }) }
70
- finally {
71
- assert.ok(root.startsWith(join(here, '.tmp-pv-')))
72
- rmSync(root, { recursive: true, force: true })
73
- }
74
- }
75
-
76
- async function settled(controller) {
77
- for (let attempt = 0; attempt < 300; attempt++) {
78
- const state = controller.status()
79
- if (state.stage === 'done' || state.stage === 'failed') return state
80
- await delay(20)
81
- }
82
- controller.stop()
83
- throw new Error('video coordinator did not settle within test timeout')
84
- }
85
-
86
- const dayDir = (out, day) => join(out, day)
87
- const dayMovie = (out, day) => join(dayDir(out, day), `${PV_PREFIX}_${day}.mp4`)
88
-
89
- test('video dates require real calendar dates', () => {
90
- for (const day of ['2024-02-29', '2026-10-21']) assert.equal(pvDateOk(day), true)
91
- for (const day of ['2026-02-29', '2026-04-31', '2026-00-01', '2026-1-01', 'foo', '../2026-10-21']) {
92
- assert.equal(pvDateOk(day), false)
93
- }
94
- })
95
-
96
- test('listing prefers dated folders and still sees legacy flat files', async () => {
97
- await fixture(async ({ out }) => {
98
- mkdirSync(dayDir(out, '2026-10-21'))
99
- writeFileSync(dayMovie(out, '2026-10-21'), Buffer.alloc(1500))
100
- writeFileSync(join(out, `${PV_PREFIX}_2026-10-21.mp4`), Buffer.alloc(1300))
101
- writeFileSync(join(out, `${PV_PREFIX}_2026-10-20.mp4`), Buffer.alloc(1500))
102
- writeFileSync(join(out, `${PV_PREFIX}_2026-10-19.partial.mp4`), Buffer.alloc(1500))
103
- writeFileSync(join(out, `${PV_PREFIX}_2026-10-18.mp4`), Buffer.alloc(0))
104
- mkdirSync(join(out, `${PV_PREFIX}_2026-10-17.mp4`))
105
- mkdirSync(dayDir(out, '2026-13-01'))
106
- const rows = listPv(out)
107
- assert.deepEqual(rows.map((x) => x.date), ['2026-10-21', '2026-10-20'])
108
- assert.equal(rows[0].file, dayMovie(out, '2026-10-21'))
109
- assert.equal(rows[0].canonical, true)
110
- assert.equal(pvFileOf(out, '2026-10-20'), join(out, `${PV_PREFIX}_2026-10-20.mp4`))
111
- assert.equal(pvFileOf(out, '2026-10-18'), dayMovie(out, '2026-10-18'))
112
- })
113
- })
114
-
115
- test('successful render publishes movie and both covers into the dated folder', async () => {
116
- await fixture(async ({ state, plugin, work, out }) => {
117
- const controller = createPv({ stateDir: state, pluginDir: plugin, python: () => python,
118
- dirs: () => ({ root: out, out, work, legacy: out, legacyDirs: [out] }),
119
- env: { ...process.env, PV_FAKE_FAIL: '0' } })
120
- const started = controller.start('2026-10-21', { force: true })
121
- assert.equal(started.started, true)
122
- const status = await settled(controller)
123
- assert.equal(status.stage, 'done', status.tail)
124
- assert.equal(status.file, dayMovie(out, '2026-10-21'))
125
- assert.equal(readFileSync(dayMovie(out, '2026-10-21')).subarray(0, 10).toString(), 'new-render')
126
- assert.equal(readFileSync(join(dayDir(out, '2026-10-21'), '封面_16x9.png')).readUInt32BE(16), 1920)
127
- assert.equal(readFileSync(join(dayDir(out, '2026-10-21'), '封面_4x3.png')).readUInt32BE(16), 1440)
128
- assert.ok(existsSync(status.plan))
129
- assert.ok(existsSync(status.timeline))
130
- assert.equal(existsSync(join(out, `${PV_PREFIX}_2026-10-21.mp4`)), false)
131
- })
132
- })
133
-
134
- test('failed render retains the previous published movie and covers', async () => {
135
- await fixture(async ({ state, plugin, work, out }) => {
136
- const movie = dayMovie(out, '2026-10-21')
137
- const previous = Buffer.alloc(1400, 66)
138
- mkdirSync(dayDir(out, '2026-10-21'))
139
- writeFileSync(movie, previous)
140
- const controller = createPv({ stateDir: state, pluginDir: plugin, python: () => python,
141
- dirs: () => ({ root: out, out, work, legacy: out, legacyDirs: [out] }),
142
- env: { ...process.env, PV_FAKE_FAIL: '1' } })
143
- controller.start('2026-10-21', { force: true })
144
- const status = await settled(controller)
145
- assert.equal(status.stage, 'failed')
146
- assert.match(status.error, /exit 7/)
147
- assert.deepEqual(readFileSync(movie), previous)
148
- })
149
- })
150
-
151
- test('cover failure is a package failure, not a silent delivery', async () => {
152
- await fixture(async ({ state, plugin, work, out }) => {
153
- const controller = createPv({ stateDir: state, pluginDir: plugin, python: () => python,
154
- dirs: () => ({ root: out, out, work, legacy: out, legacyDirs: [out] }),
155
- env: { ...process.env, PV_FAKE_FAIL: '0', PV_FAKE_COVER_FAIL: '1' } })
156
- controller.start('2026-10-21', { force: true })
157
- const status = await settled(controller)
158
- assert.equal(status.stage, 'failed')
159
- assert.match(status.error, /封面生成失败/)
160
- assert.equal(existsSync(dayMovie(out, '2026-10-21')), false)
161
- })
162
- })
163
-
164
- test('existing movie with missing covers repairs only artwork, never renders', async () => {
165
- await fixture(async ({ state, plugin, work, out }) => {
166
- const marker = join(out, 'render-was-called')
167
- mkdirSync(dayDir(out, '2026-10-21'))
168
- const movie = dayMovie(out, '2026-10-21')
169
- const previous = Buffer.alloc(1400, 67)
170
- writeFileSync(movie, previous)
171
- const controller = createPv({ stateDir: state, pluginDir: plugin, python: () => python,
172
- dirs: () => ({ root: out, out, work, legacy: out, legacyDirs: [out] }),
173
- env: { ...process.env, PV_FAKE_FAIL: '0', PV_RENDER_MARKER: marker } })
174
- const started = controller.start('2026-10-21')
175
- assert.equal(started.started, true)
176
- assert.equal(started.repairing, true)
177
- const status = await settled(controller)
178
- assert.equal(status.stage, 'done', status.tail)
179
- assert.equal(existsSync(marker), false)
180
- assert.deepEqual(readFileSync(movie), previous)
181
- assert.ok(existsSync(join(dayDir(out, '2026-10-21'), '封面_16x9.png')))
182
- assert.ok(existsSync(join(dayDir(out, '2026-10-21'), '封面_4x3.png')))
183
- })
184
- })
185
-
186
- test('legacy flat movie is archived without re-rendering the film', async () => {
187
- await fixture(async ({ state, plugin, work, out }) => {
188
- const marker = join(out, 'render-was-called')
189
- const legacy = join(out, `${PV_PREFIX}_2026-10-21.mp4`)
190
- const previous = Buffer.alloc(1400, 68)
191
- writeFileSync(legacy, previous)
192
- const controller = createPv({ stateDir: state, pluginDir: plugin, python: () => python,
193
- dirs: () => ({ root: out, out, work, legacy: out, legacyDirs: [out] }),
194
- env: { ...process.env, PV_FAKE_FAIL: '0', PV_RENDER_MARKER: marker } })
195
- const started = controller.start('2026-10-21')
196
- assert.equal(started.repairing, true)
197
- const status = await settled(controller)
198
- assert.equal(status.stage, 'done', status.tail)
199
- assert.equal(existsSync(marker), false)
200
- assert.deepEqual(readFileSync(dayMovie(out, '2026-10-21')), previous)
201
- assert.ok(existsSync(join(dayDir(out, '2026-10-21'), '封面_4x3.png')))
202
- })
203
- })
@@ -1,141 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { EventEmitter } from 'node:events'
4
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
5
- import { join } from 'node:path'
6
- import { tmpdir } from 'node:os'
7
- import { createRequire, syncBuiltinESMExports } from 'node:module'
8
- import { Readable } from 'node:stream'
9
-
10
- const root = mkdtempSync(join(tmpdir(), 'fish-sticker-routes-')), builtin = join(root, 'builtin'), home = join(root, 'bili-duty')
11
- mkdirSync(builtin); mkdirSync(home)
12
- const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=', 'base64')
13
- writeFileSync(join(builtin, '贴贴.png'), png)
14
- const priorBuiltin = process.env.DAFEIYU_STICKER_DIR; process.env.DAFEIYU_STICKER_DIR = builtin
15
- const { apply } = await import('../index.js')
16
- const { McpClient } = await import('../lib/mcp.mjs')
17
-
18
- test('gallery HTTP is safe and on demand; real reply wrapper auto-selects only eligible authorized new comments', async () => {
19
- writeFileSync(join(home, 'config.json'), JSON.stringify({ reportLevel: 'off', learnAutoReview: false, autoComment: false, stickerAutoSend: true, stickerSendProbability: 100, stickerSendCooldown: 0 }))
20
- const cleanups = [], tools = new Map(), calls = []
21
- let handler
22
- const agent = { id: 'fish', session: { header: { agentPreset: 'dafeiyu', cwd: root } } }
23
- const ctx = { logger: { info() {} }, get: key => key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [agent] } : undefined,
24
- tools: { register(spec) { tools.set(spec.name, spec); return spec } }, settings: { describe: () => [] }, on() { return () => {} },
25
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
26
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
27
- }
28
- async function request(path, body, method = 'GET', headers = {}) {
29
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = headers; req.destroy = () => {}
30
- const res = { writeHead(code, headers) { this.code = code; this.headers = headers }, end(data) { this.raw = data; this.body = Buffer.isBuffer(data) || !data ? data : JSON.parse(data) } }
31
- const pending = handler(req, res)
32
- if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body || {}))); req.emit('end') }
33
- await pending; return res
34
- }
35
- const actualCall = McpClient.prototype.call
36
- McpClient.prototype.call = async function(name, args) {
37
- calls.push({ name, args })
38
- if (name === 'bili_reply') return { success: false, error: 'fixture send rejected; no network' }
39
- if (name === 'bili_comments') return { comments: [] }
40
- return {}
41
- }
42
- const reply = (args, fish = true) => tools.get('bili_reply').execute({ bvid: 'BV1234567890', text: '镜头停在倒影上的那几秒,光线变化特别清楚。', ...args }, fish ? { agent } : {})
43
- try {
44
- apply(ctx, {}); await new Promise(resolve => setImmediate(resolve))
45
- const gallery = await request('/stickers')
46
- assert.equal(gallery.body.ok, true); assert.deepEqual(gallery.body.counts, { total: 1, builtin: 1, collected: 0 })
47
- assert.equal(gallery.body.settings.stickerCollectProbability, 35); assert.equal(gallery.body.capacity, 60)
48
- assert.equal(gallery.body.stickers[0].removable, false); assert.ok(!Object.hasOwn(gallery.body.stickers[0], 'file'))
49
- const image = await request(gallery.body.stickers[0].imageUrl.replace('/api/bili-duty', ''))
50
- assert.equal(image.code, 200); assert.equal(image.headers['content-type'], 'image/png'); assert.deepEqual(image.raw, png)
51
- assert.equal((await request('/stickers/image?name=贴贴&source=builtin', null, 'GET', { 'if-none-match': image.headers.etag })).code, 304)
52
- for (const query of ['name=..%2Fconfig.json', 'name=贴贴&source=../../', 'name=unknown', '']) assert.equal((await request('/stickers/image?' + query)).code, 404)
53
- assert.equal((await request('/stickers/collect', { name: '逃逸', path: join(root, 'private.png') }, 'POST')).body.ok, false)
54
- assert.equal((await request('/stickers/collect', { name: '内网', url: 'http://127.0.0.1/secrets' }, 'POST')).body.ok, false)
55
- assert.equal((await request('/stickers/forget', { name: '贴贴' }, 'POST')).body.ok, false)
56
- assert.equal((await request('/stickers/forget', { name: 'x' }, 'POST', { origin: 'https://outside.example.com' })).code, 403)
57
- const copied = await tools.get('bili_sticker').execute({ action: 'collect', name: '别名', path: '贴贴', description: '同一张图换个名字' }, { agent })
58
- assert.equal(copied.ok, true)
59
- assert.equal((await request('/stickers')).body.counts.collected, 1)
60
- assert.equal((await request('/stickers/forget', { name: '别名' }, 'POST')).body.ok, true)
61
- const config = (await request('/config', { stickerCollectProbability: 123, stickerCapacity: 999, stickerAutoSend: true }, 'POST')).body
62
- assert.equal(config.config.stickerCollectProbability, 100); assert.equal(config.config.stickerCapacity, 60)
63
- const blocked = await reply({})
64
- assert.equal(blocked.blocked, true); assert.equal(calls.filter(x => x.name === 'bili_reply').length, 0)
65
- const sent = await reply({ force: true })
66
- assert.equal(sent.auto_picture, '贴贴'); assert.deepEqual(calls.at(-1).args.pictures, [join(builtin, '贴贴.png')])
67
- await reply({ force: true, picture: '' }); assert.equal(calls.at(-1).args.pictures, undefined)
68
- await reply({ force: true, picture: '贴贴' }); assert.deepEqual(calls.at(-1).args.pictures, [join(builtin, '贴贴.png')])
69
- await reply({ force: true, rpid: '123456789' }); assert.equal(calls.at(-1).args.pictures, undefined)
70
- await reply({ force: true }, false); assert.equal(calls.at(-1).args.pictures, undefined, 'ordinary mode never auto-attaches fish stickers')
71
- const beforeUnknown = calls.length
72
- assert.equal((await reply({ force: true, picture: '../private' })).sticker_unknown, true); assert.equal(calls.length, beforeUnknown)
73
- assert.equal((await reply({ force: true, rpid: '987654321', picture: '贴贴' })).picture_floor, true)
74
- const status = (await request('/status')).body
75
- assert.ok(!Object.hasOwn(status, 'stickers')); assert.equal(status.tokensTotal, 0)
76
- const unchanged = (await request('/status?rev=' + encodeURIComponent(status.rev))).body
77
- assert.equal(unchanged.unchanged, true)
78
- assert.equal(JSON.parse(readFileSync(join(home, 'config.json'))).autoComment, false, 'image settings never grant posting authority')
79
- } finally {
80
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
81
- McpClient.prototype.call = actualCall
82
- }
83
- })
84
-
85
- test.after(() => { if (priorBuiltin === undefined) delete process.env.DAFEIYU_STICKER_DIR; else process.env.DAFEIYU_STICKER_DIR = priorBuiltin; rmSync(root, { recursive: true, force: true }) })
86
-
87
- test('session stop cancels only that session collection queue and download; resume accepts a fresh scope', async () => {
88
- const isolated = join(root, 'scopes'), duty = join(isolated, 'bili-duty'); mkdirSync(duty, { recursive: true })
89
- writeFileSync(join(duty, 'config.json'), JSON.stringify({ reportLevel: 'off', learnAutoReview: false, stickerAutoCollect: true, stickerCollectProbability: 100, stickerCollectCooldown: 0 }))
90
- const agents = ['a', 'b'].map(id => ({ id, session: { header: { agentPreset: 'dafeiyu', cwd: isolated } } })), goals = new Map()
91
- for (const agent of agents) goals.set(agent.id, { id: agent.id + '-goal', objective: '🐟 鲸鱼摸鱼模式 fixture', phase: 'active', activation: 'armed', revision: 1, roundsStarted: 1, maxGoalRounds: 10 })
92
- const cleanups = [], tools = new Map(), require = createRequire(import.meta.url)
93
- const http = require('node:https'), dns = require('node:dns'), oldRequest = http.request, oldLookup = dns.promises.lookup
94
- const actualCall = McpClient.prototype.call
95
- let handler, urlCounter = 0, imageRequests = 0, pendingRequest, requestBegun
96
- dns.promises.lookup = async () => [{ address: '8.8.8.8', family: 4 }]
97
- // Native transport is intercepted after URL and DNS validation; there is no external connection.
98
- http.request = (_url, options, respond) => {
99
- const request = new EventEmitter()
100
- request.end = () => {
101
- imageRequests++
102
- if (imageRequests === 1) { pendingRequest = request; requestBegun?.(); return }
103
- const response = Readable.from([Buffer.concat([png, Buffer.from(String(imageRequests))])]); response.headers = { 'content-type': 'image/png' }; response.statusCode = 200
104
- queueMicrotask(() => respond(response))
105
- }
106
- const abort = () => request.emit('error', options.signal.reason)
107
- options.signal.addEventListener('abort', abort, { once: true })
108
- request.once('error', () => options.signal.removeEventListener('abort', abort))
109
- return request
110
- }
111
- syncBuiltinESMExports()
112
- McpClient.prototype.call = async function(name) { return name === 'bili_comments' ? { comments: [{ rpid: String(++urlCounter), images: [{ url: 'https://images.example.com/' + urlCounter + '.png', w: 256, h: 256 }] }] } : {} }
113
- const services = { dshHomePath: (...parts) => join(isolated, ...parts), agents: { list: () => agents, get: id => agents.find(a => a.id === id) }, goals: { get: agent => goals.get(agent.id), pause(agent) { const g = goals.get(agent.id); g.phase = 'paused'; g.activation = 'disarmed'; return g }, resume(agent) { const g = goals.get(agent.id); g.phase = 'active'; g.activation = 'armed'; return g } } }
114
- const ctx = { logger: { info() {} }, get: key => services[key], tools: { register(spec) { tools.set(spec.name, spec); return spec } }, settings: { describe: () => [] }, on() { return () => {} }, effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) }, webServer: { register(route) { handler = route.handler; return { dispose() {} } } } }
115
- async function post(action, agent) {
116
- const req = new EventEmitter(); req.url = '/api/bili-duty/feiyu'; req.method = 'POST'; req.headers = {}; req.destroy = () => {}
117
- const res = { writeHead() {}, end(text) { this.body = JSON.parse(text) } }, promise = handler(req, res)
118
- req.emit('data', Buffer.from(JSON.stringify({ action, sessionId: agent.id }))); req.emit('end'); await promise; return res.body
119
- }
120
- const comments = agent => tools.get('bili_comments').execute({ bvid: 'BV1234567890' }, { agent })
121
- const flush = () => new Promise(resolve => setImmediate(resolve))
122
- const collected = () => Object.keys(JSON.parse(readFileSync(join(duty, 'state.json'), 'utf8')).stickers || {})
123
- try {
124
- apply(ctx, {}); await flush()
125
- await comments(agents[0]); await post('stop', agents[0]); await flush()
126
- assert.equal(imageRequests, 0, 'queued download is withdrawn even though host tool signal is absent')
127
- assert.equal((await post('resume', agents[0])).ok, true)
128
- const begun = new Promise(resolve => { requestBegun = resolve })
129
- await comments(agents[0]); await begun
130
- await comments(agents[1]); await post('stop', agents[0]); await flush(); await flush()
131
- assert.ok(pendingRequest.listenerCount('error') > 0)
132
- assert.equal(imageRequests, 2, 'other session continues after one session stops')
133
- assert.equal(collected().length, 1, 'stopped inflight session never commits')
134
- assert.equal((await post('resume', agents[0])).ok, true)
135
- await comments(agents[0]); await flush(); await flush()
136
- assert.equal(imageRequests, 3); assert.equal(collected().length, 2, 'resumed session gets a fresh collection scope')
137
- } finally {
138
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
139
- http.request = oldRequest; dns.promises.lookup = oldLookup; McpClient.prototype.call = actualCall; syncBuiltinESMExports()
140
- }
141
- })
@@ -1,97 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { EventEmitter } from 'node:events'
4
- import { createServer, request as httpRequest } from 'node:http'
5
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
6
- import { join } from 'node:path'
7
- import { tmpdir } from 'node:os'
8
-
9
- const root = mkdtempSync(join(tmpdir(), 'fish-sticker-upload-route-')), builtin = join(root, 'builtin'), home = join(root, 'bili-duty')
10
- mkdirSync(builtin); mkdirSync(home)
11
- const dataBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGP8r+DAwMDAxAAGABFdAWPYSWFdAAAAAElFTkSuQmCC'
12
- const gif = Buffer.from('R0lGODdhAgACAIEAAP8gQAAAAAAAAAAAACwAAAAAAgACAAAIBgABCAQQEAA7', 'base64')
13
- writeFileSync(join(builtin, '内置.gif'), gif)
14
- const priorBuiltin = process.env.DAFEIYU_STICKER_DIR; process.env.DAFEIYU_STICKER_DIR = builtin
15
- const { apply } = await import('../index.js')
16
- const cleanups = [], tools = new Map()
17
- let handler
18
- const ctx = { logger: { info() {} }, get: key => key === 'dshHomePath' ? (...parts) => join(root, ...parts) : undefined,
19
- tools: { register(spec) { tools.set(spec.name, spec); return spec } }, settings: { describe: () => [] }, on() { return () => {} },
20
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
21
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
22
- }
23
-
24
- async function mockRequest(path, raw, headers = {}, chunks) {
25
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = 'POST'; req.headers = headers
26
- let destroyed = false; req.destroy = () => { destroyed = true }
27
- const res = { writeHead(status, headers) { this.status = status; this.headers = headers }, end(data) { this.body = JSON.parse(data) } }
28
- const pending = handler(req, res)
29
- for (const bytes of chunks || [Buffer.from(raw)]) req.emit('data', bytes)
30
- req.emit('end'); await pending
31
- return { ...res, destroyed }
32
- }
33
- const post = (body, headers, chunks) => mockRequest('/stickers/upload', JSON.stringify(body), headers, chunks)
34
-
35
- writeFileSync(join(home, 'config.json'), JSON.stringify({ reportLevel: 'off', learnAutoReview: false, autoComment: false, autoDm: false, autoMoment: false }))
36
- apply(ctx, {})
37
-
38
- test.after(() => {
39
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
40
- if (priorBuiltin === undefined) delete process.env.DAFEIYU_STICKER_DIR; else process.env.DAFEIYU_STICKER_DIR = priorBuiltin
41
- rmSync(root, { recursive: true, force: true })
42
- })
43
-
44
- test('HTTP upload provides stable errors, preserves Origin and posting gates, and exposes safe gallery image URL', async () => {
45
- assert.equal((await post({ name: '跨站', dataBase64 }, { origin: 'https://outside.example.com' })).status, 403)
46
- for (const body of [null, [], { name: '只给路径', path: join(builtin, '内置.gif') }, { name: '../escape', dataBase64 }, { name: '坏编码', dataBase64: 'a===' }]) {
47
- const result = await post(body); assert.equal(result.status, 400); assert.equal(result.body.ok, false)
48
- }
49
- assert.equal((await mockRequest('/stickers/upload', '{broken')).body.code, 'INVALID_JSON')
50
- const bad = await post({ name: '伪图片', dataBase64: Buffer.from('hello world and not image').toString('base64') })
51
- assert.equal(bad.status, 415); assert.equal(bad.body.code, 'STICKER_IMAGE_INVALID')
52
- const oversized = await post({ name: '字节超限', dataBase64: Buffer.alloc(5 * 1024 * 1024 + 1).toString('base64') })
53
- assert.equal(oversized.status, 413); assert.equal(oversized.body.code, 'STICKER_TOO_LARGE'); assert.equal(oversized.destroyed, false)
54
- // Size of the optional description pushes this otherwise valid upload beyond the old 1MB request limit.
55
- const body = { name: '本地图片', filename: 'from-device.jpg', dataBase64, description: '本地备注'.repeat(150000), tags: ['开心'] }
56
- const raw = Buffer.from(JSON.stringify(body)), firstChinese = raw.indexOf(Buffer.from('本'))
57
- assert.ok(raw.length > 1_000_000)
58
- const added = await post(body, { origin: 'http://127.0.0.1:43129' }, [raw.subarray(0, firstChinese + 1), raw.subarray(firstChinese + 1, 500000), raw.subarray(500000)])
59
- assert.equal(added.status, 200); assert.deepEqual(added.body, { ok: true, name: '本地图片', bytes: Buffer.from(dataBase64, 'base64').length, mime: 'image/png', source: 'collected', imageUrl: '/api/bili-duty/stickers/image?name=' + encodeURIComponent('本地图片') + '&source=collected' })
60
- assert.ok(!Object.hasOwn(added.body, 'file'))
61
- const duplicate = await post({ name: '同内容', dataBase64 }); assert.equal(duplicate.status, 409); assert.equal(duplicate.body.duplicate, true)
62
- const config = JSON.parse(readFileSync(join(home, 'config.json')))
63
- assert.equal(config.autoComment, false); assert.equal(config.autoDm, false); assert.equal(config.autoMoment, false)
64
- const state = JSON.parse(readFileSync(join(home, 'state.json')))
65
- assert.equal(state.stickers.本地图片.description.length, 160); assert.equal(state.stickers.本地图片.file, '本地图片.png')
66
- assert.deepEqual(readFileSync(join(builtin, '内置.gif')), gif)
67
- })
68
-
69
- test('HTTP body overrun returns 413 without destroying the request; default routes keep the 1MB limit', async () => {
70
- const overrun = await mockRequest('/stickers/upload', '', {}, [Buffer.alloc(7 * 1024 * 1024 + 1, 32), Buffer.from('ignored after limit')])
71
- assert.equal(overrun.status, 413); assert.equal(overrun.body.code, 'REQUEST_TOO_LARGE'); assert.equal(overrun.destroyed, false)
72
- const declared = await mockRequest('/stickers/upload', '', { 'content-length': String(7 * 1024 * 1024 + 1) })
73
- assert.equal(declared.status, 413); assert.equal(declared.body.code, 'REQUEST_TOO_LARGE'); assert.equal(declared.destroyed, false)
74
- const defaults = await mockRequest('/stickers/collect', JSON.stringify({ name: '超默认请求体', description: 'x'.repeat(1_000_001) }))
75
- assert.equal(defaults.body.ok, false); assert.equal(defaults.destroyed, false)
76
- })
77
-
78
- test('real loopback transport receives JSON for a chunked upload overrun and remains usable', async () => {
79
- const server = createServer((req, res) => { void handler(req, res) })
80
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
81
- const address = server.address()
82
- const call = (path, chunks) => new Promise((resolve, reject) => {
83
- const req = httpRequest({ host: '127.0.0.1', port: address.port, path: '/api/bili-duty' + path, method: 'POST', headers: { 'content-type': 'application/json' } }, res => {
84
- const data = []; res.on('data', bytes => data.push(bytes)); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(data)) }))
85
- })
86
- req.on('error', reject)
87
- req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out')))
88
- for (const bytes of chunks) req.write(bytes)
89
- req.end()
90
- })
91
- try {
92
- const tooLarge = await call('/stickers/upload', [Buffer.alloc(7 * 1024 * 1024, 32), Buffer.from('extra')])
93
- assert.equal(tooLarge.status, 413); assert.equal(tooLarge.body.code, 'REQUEST_TOO_LARGE')
94
- const after = await call('/stickers/forget', [Buffer.from(JSON.stringify({ name: '本地图片' }))])
95
- assert.equal(after.status, 200); assert.equal(after.body.ok, true)
96
- } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)) }
97
- })
@@ -1,122 +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
- import { deflateSync } from 'node:zlib'
7
-
8
- // Pillow-generated standard 2x2 RGB images. Tests need no decoder dependency or external process.
9
- const fixtures = {
10
- png: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGP8r+DAwMDAxAAGABFdAWPYSWFdAAAAAElFTkSuQmCC',
11
- jpg: '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDFoooryz9hP//Z',
12
- gif: 'R0lGODdhAgACAIEAAP8gQAAAAAAAAAAAACwAAAAAAgACAAAIBgABCAQQEAA7',
13
- webp: 'UklGRjoAAABXRUJQVlA4IC4AAACQAQCdASoCAAIAAUAmJaACdLoAA5gA/u1N38bzea1rf/ybL/y0L/y0L/knAAAA',
14
- }
15
- const root = mkdtempSync(join(tmpdir(), 'fish-sticker-upload-')), builtin = join(root, 'builtin')
16
- mkdirSync(builtin)
17
- const builtinBytes = Buffer.from(fixtures.gif, 'base64')
18
- writeFileSync(join(builtin, '内置.gif'), builtinBytes)
19
- const priorBuiltin = process.env.DAFEIYU_STICKER_DIR
20
- process.env.DAFEIYU_STICKER_DIR = builtin
21
- const { uploadSticker, collectSticker, listStickers, readStickerImage, forgetSticker, STICKER_BYTES_MAX } = await import('../lib/sticker.mjs')
22
- const { validateStickerImage } = await import('../lib/sticker-image.mjs')
23
- let sequence = 0
24
- const home = () => { const path = join(root, 'home-' + ++sequence); mkdirSync(path); writeFileSync(join(path, 'state.json'), JSON.stringify({ tokensTotal: 888, retained: { value: true } })); return path }
25
- const upload = (path, name = '本地图', dataBase64 = fixtures.png, extra = {}) => uploadSticker(path, { name, dataBase64, ...extra })
26
- const png = Buffer.from(fixtures.png, 'base64')
27
-
28
- // Build well-formed PNG chunks for exact-boundary and decompression-limit checks.
29
- const crc32 = bytes => {
30
- let crc = 0xffffffff
31
- for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1 }
32
- return (crc ^ 0xffffffff) >>> 0
33
- }
34
- const chunk = (type, data) => {
35
- const kind = Buffer.from(type), out = Buffer.alloc(data.length + 12)
36
- out.writeUInt32BE(data.length); kind.copy(out, 4); data.copy(out, 8); out.writeUInt32BE(crc32(Buffer.concat([kind, data])), data.length + 8)
37
- return out
38
- }
39
- const paddedPng = size => Buffer.concat([png.subarray(0, -12), chunk('tEXt', Buffer.alloc(size - png.length - 12, 65)), png.subarray(-12)])
40
-
41
- test.after(() => { if (priorBuiltin === undefined) delete process.env.DAFEIYU_STICKER_DIR; else process.env.DAFEIYU_STICKER_DIR = priorBuiltin; rmSync(root, { recursive: true, force: true }) })
42
-
43
- test('upload accepts standard PNG/JPEG/GIF/WebP bytes and actual format wins over filename', () => {
44
- for (const [fmt, dataBase64] of Object.entries(fixtures)) {
45
- const dir = home()
46
- // GIF fixture belongs to the built-in area; use the same validation directly for that format.
47
- validateStickerImage(Buffer.from(dataBase64, 'base64'), fmt)
48
- const result = upload(dir, '本地 图片.png', dataBase64, { filename: 'wrong-extension.txt', description: '本地挑选', tags: ['高兴', '贴贴'] })
49
- if (fmt === 'gif') { assert.equal(result.duplicate, true); continue }
50
- assert.equal(result.ok, true, `${fmt}: ${result.error}`); assert.equal(result.name, '本地-图片'); assert.equal(result.fmt, fmt)
51
- assert.equal(result.mime, { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }[fmt]); assert.equal(result.from, 'local upload: wrong-extension.txt')
52
- assert.equal(result.sourceUrl, ''); assert.equal(result.automatic, false)
53
- assert.deepEqual(readStickerImage(dir, result.name, 'collected').bytes, Buffer.from(dataBase64, 'base64'))
54
- const state = JSON.parse(readFileSync(join(dir, 'state.json')))
55
- assert.equal(state.tokensTotal, 888); assert.deepEqual(state.retained, { value: true }); assert.deepEqual(state.stickers[result.name].tags, ['高兴', '贴贴'])
56
- assert.match(state.stickers[result.name].hash, /^[a-f0-9]{64}$/)
57
- assert.equal(forgetSticker(dir, result.name).ok, true)
58
- }
59
- })
60
-
61
- test('upload accepts a distinct GIF, animated GIF/WebP, lossless WebP and progressive JPEG', () => {
62
- const variants = [
63
- ['gif', 'R0lGODdhAgACAIEAABQ8eAAAAAAAAAAAACwAAAAAAgACAAAIBgABCAQQEAA7'],
64
- ['webp', 'UklGRh4AAABXRUJQVlA4TBEAAAAvAUAAAAdQnlIUr/+BiOh/AAA='],
65
- ['webp', 'UklGRsAAAABXRUJQVlA4WAoAAAACAAAAAQAAAQAAQU5JTQYAAAAAAAAAAABBTk1GQgAAAAAAAAAAAAEAAAEAAGQAAAJWUDggKgAAADABAJ0BKgIAAgABQCYloAADcAD++F9///xvH+u7/KLgu//rb8c/mvAwAEFOTUZKAAAAAAAAAAAAAQAAAQAAZAAAAFZQOCAyAAAANAEAnQEqAgACAAAAJiWgAANwAP7y63//+bA/9vP/BHoB///S4P/+lwf/9Lg/9KQAAAA='],
66
- ['gif', 'R0lGODlhAgACAIEAABQ8eAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAgACAAAIBgABCAQQEAAh+QQBCgABACwAAAAAAgACAIH/AAAAAAAAAAAAAAAIBgABCAQQEAA7'],
67
- ['jpg', '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVAQEBAAAAAAAAAAAAAAAAAAAEBf/aAAwDAQACEAMQAAABmhQJ/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABD7/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k='],
68
- ]
69
- for (const [fmt, dataBase64] of variants) {
70
- const result = upload(home(), '多种编码', dataBase64)
71
- assert.equal(result.ok, true, `${fmt}: ${result.error}`); assert.equal(result.fmt, fmt)
72
- }
73
- })
74
-
75
- test('upload rejects invalid JSON field types, arbitrary paths and noncanonical base64 without storing anything', () => {
76
- const dir = home(), before = readFileSync(join(dir, 'state.json'), 'utf8')
77
- for (const options of [null, [], { name: {}, dataBase64: fixtures.png }, { name: '../escape', dataBase64: fixtures.png }, { name: 'con', dataBase64: fixtures.png },
78
- { name: '图', path: join(builtin, '内置.gif') }, { name: '图', dataBase64: fixtures.png, filename: 'C:\\private.png' },
79
- { name: '图', dataBase64: fixtures.png, description: {} }, { name: '图', dataBase64: fixtures.png, tags: [123] },
80
- { name: '图', dataBase64: '' }, { name: '图', dataBase64: 'data:image/png;base64,' + fixtures.png },
81
- { name: '图', dataBase64: fixtures.png + '\n' }, { name: '图', dataBase64: 'AB==' }, { name: '图', dataBase64: '!!!!' }, { name: '图', dataBase64: 'AAAA=' }]) {
82
- assert.equal(uploadSticker(dir, options).code, 'STICKER_INPUT', JSON.stringify(options))
83
- }
84
- assert.equal(readFileSync(join(dir, 'state.json'), 'utf8'), before); assert.equal(listStickers(dir, { source: 'collected' }).length, 0)
85
- })
86
-
87
- test('upload detects non-images, header-only files, missing terminators, malformed chunk lengths and CRC corruption', () => {
88
- const dir = home()
89
- const broken = [Buffer.from('<html>not an image</html>'), Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>')]
90
- for (const [fmt, encoded] of Object.entries(fixtures)) {
91
- const bytes = Buffer.from(encoded, 'base64')
92
- broken.push(bytes.subarray(0, 12), bytes.subarray(0, -1), bytes.subarray(0, Math.floor(bytes.length / 2)))
93
- if (fmt === 'png') { const corrupt = Buffer.from(bytes); corrupt[45] ^= 1; broken.push(corrupt) }
94
- if (fmt === 'webp') { const corrupt = Buffer.from(bytes); corrupt.writeUInt32LE(0x7fffffff, 16); broken.push(corrupt) }
95
- }
96
- for (const bytes of broken) assert.equal(upload(dir, '坏图', bytes.toString('base64')).code, 'STICKER_IMAGE_INVALID')
97
- assert.equal(listStickers(dir, { source: 'collected' }).length, 0)
98
- })
99
-
100
- test('upload accepts exactly 5MiB, rejects one byte over and bounds PNG decompression', () => {
101
- const accepted = paddedPng(STICKER_BYTES_MAX)
102
- assert.equal(upload(home(), '边界', accepted.toString('base64')).ok, true)
103
- assert.equal(upload(home(), '超限', Buffer.concat([accepted, Buffer.from([0])]).toString('base64')).code, 'STICKER_TOO_LARGE')
104
- const header = Buffer.from(png.subarray(16, 29)); header.writeUInt32BE(65535, 0); header.writeUInt32BE(65535, 4)
105
- const bomb = Buffer.concat([png.subarray(0, 8), chunk('IHDR', header), chunk('IDAT', deflateSync(Buffer.from([0]))), chunk('IEND', Buffer.alloc(0))])
106
- assert.match(upload(home(), '解压超限', bomb.toString('base64')).error, /64MB/)
107
- })
108
-
109
- test('upload shares names, capacity, content dedupe, state merge and builtin protection with collection', async () => {
110
- const dir = home()
111
- assert.equal(upload(dir, '内置').code, 'STICKER_NAME_EXISTS')
112
- assert.equal(upload(dir, '同内置', fixtures.gif).duplicate, true)
113
- assert.equal(upload(dir, '第一张').ok, true)
114
- const duplicate = upload(dir, '不同名字'); assert.equal(duplicate.code, 'STICKER_DUPLICATE'); assert.equal(duplicate.name, '第一张')
115
- assert.equal((await collectSticker(dir, { name: '下载同图', url: 'https://images.example.com/a.png', lookupImpl: async () => [{ address: '8.8.8.8', family: 4 }], fetchImpl: async () => new Response(png) })).duplicate, true)
116
- assert.equal(upload(dir, '第二张', fixtures.jpg, { capacity: 1 }).code, 'STICKER_CAPACITY')
117
- assert.equal(listStickers(dir, { source: 'collected' }).length, 1)
118
- assert.deepEqual(readFileSync(join(builtin, '内置.gif')), builtinBytes)
119
- const stateFile = join(dir, 'state.json'); writeFileSync(stateFile, '{broken')
120
- assert.equal(upload(dir, '账本坏了', fixtures.webp).code, 'STICKER_STORAGE'); assert.equal(readFileSync(stateFile, 'utf8'), '{broken')
121
- assert.deepEqual(readdirSync(join(dir, 'stickers')), ['第一张.png'])
122
- })