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/pv-stage1.test.mjs
DELETED
|
@@ -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
|
-
})
|