@fg-lab/amadeus-qq 1.2.1 → 1.2.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/LICENSE +21 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- package/src/web-console.js +9 -9
package/src/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
memorySceneAclReadGranted,
|
|
29
29
|
OWNER_NATIVE_TOOLS,
|
|
30
30
|
} from './preset-guard.mjs'
|
|
31
|
+
import { LIFE_READ_CAPABILITY, LIFE_READ_TOOLS } from './permissions.js'
|
|
31
32
|
import { ADMIN_PREFIX, createAdminHandler } from './admin-http.js'
|
|
32
33
|
import { createSetupService } from './setup.js'
|
|
33
34
|
|
|
@@ -120,6 +121,47 @@ export function makeDshHost(ctx, config, provenance = null) {
|
|
|
120
121
|
throw new Error('QQ sessionCwd must be absolute for legacy configuration')
|
|
121
122
|
}
|
|
122
123
|
return {
|
|
124
|
+
async sessionExists(sessionId) {
|
|
125
|
+
if (typeof sessionId !== 'string' || !sessionId) return false
|
|
126
|
+
// Real current Hosts expose the non-activating persistence projection
|
|
127
|
+
// read. Older mocks without it remain exempt from the existence check.
|
|
128
|
+
if (typeof controller.projections !== 'function') return null
|
|
129
|
+
let observed
|
|
130
|
+
try {
|
|
131
|
+
observed = await controller.projections({ sessionId }, AbortSignal.timeout(5000))
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.code === 'session/not-found' || error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return false
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
if (observed === null) return false
|
|
137
|
+
if (observed === undefined || typeof observed !== 'object' || Array.isArray(observed) ||
|
|
138
|
+
(observed.kind !== 'sequenced' && observed.kind !== 'migration-required') ||
|
|
139
|
+
observed.values === undefined || observed.values === null ||
|
|
140
|
+
typeof observed.values !== 'object' || Array.isArray(observed.values)) {
|
|
141
|
+
const error = new Error('QQ_SESSION_LOOKUP_INVALID')
|
|
142
|
+
error.code = 'QQ_SESSION_LOOKUP_INVALID'
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
if (config.workspaceId) {
|
|
146
|
+
let registry
|
|
147
|
+
try {
|
|
148
|
+
registry = typeof ctx.reflect?.get === 'function'
|
|
149
|
+
? ctx.reflect.get('workspaceRegistry')
|
|
150
|
+
: undefined
|
|
151
|
+
} catch {
|
|
152
|
+
registry = undefined
|
|
153
|
+
}
|
|
154
|
+
if (registry && typeof registry.list === 'function') {
|
|
155
|
+
const workspaces = await registry.list()
|
|
156
|
+
if (!Array.isArray(workspaces)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
|
|
157
|
+
const workspace = workspaces.find(item => item?.id === config.workspaceId)
|
|
158
|
+
if (!workspace) throw new Error('QQ_WORKSPACE_UNAVAILABLE')
|
|
159
|
+
if (!Array.isArray(workspace.sessionIds)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
|
|
160
|
+
if (!workspace.sessionIds.includes(sessionId)) throw new Error('QQ_WORKSPACE_MISMATCH')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return true
|
|
164
|
+
},
|
|
123
165
|
async cancelSceneSession(sessionId) {
|
|
124
166
|
if (typeof controller.cancel !== 'function') throw new Error('DSH Session cancellation is unavailable')
|
|
125
167
|
try {
|
|
@@ -132,6 +174,10 @@ export function makeDshHost(ctx, config, provenance = null) {
|
|
|
132
174
|
async createSceneSession(_scene, grant) {
|
|
133
175
|
let requestedPreset = config.agentPreset
|
|
134
176
|
if (grant?.ownerAgent === true) requestedPreset = 'amadeus-qq-owner'
|
|
177
|
+
else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-media-search'
|
|
178
|
+
else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-life-media'
|
|
179
|
+
else if (grant?.lifeAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-search'
|
|
180
|
+
else if (grant?.lifeAllowed === true) requestedPreset = 'amadeus-qq-life'
|
|
135
181
|
else if (grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-media-search'
|
|
136
182
|
else if (grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-media'
|
|
137
183
|
else if (grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-search'
|
|
@@ -241,6 +287,7 @@ export function apply(ctx, config = {}) {
|
|
|
241
287
|
return applyPresetGuard(ctx, {
|
|
242
288
|
level: config.level ?? 1,
|
|
243
289
|
allowSearch: config.allowSearch === true,
|
|
290
|
+
allowLifeRead: config.allowLifeRead === true,
|
|
244
291
|
})
|
|
245
292
|
}
|
|
246
293
|
if (config.enabled !== true) {
|
|
@@ -266,6 +313,11 @@ export function apply(ctx, config = {}) {
|
|
|
266
313
|
if (typeof config.statePath !== 'string' || !path.isAbsolute(config.statePath)) {
|
|
267
314
|
throw new Error('QQ statePath must be explicitly configured as an absolute isolated path')
|
|
268
315
|
}
|
|
316
|
+
// Create the isolated inbound-media root on first run. media.js still
|
|
317
|
+
// enforces containment and file-type/size checks for every local result.
|
|
318
|
+
if (typeof config.mediaRoot === 'string' && path.isAbsolute(config.mediaRoot)) {
|
|
319
|
+
fs.mkdirSync(config.mediaRoot, { recursive: true })
|
|
320
|
+
}
|
|
269
321
|
const releaseLease = acquireInstanceLease(config.statePath)
|
|
270
322
|
try {
|
|
271
323
|
ctx.effect(() => releaseLease)
|
|
@@ -288,6 +340,51 @@ export function apply(ctx, config = {}) {
|
|
|
288
340
|
workspaceId: config.workspaceId ?? '', handoffPaused: config.connect !== true })
|
|
289
341
|
const capabilities = new CapabilityRegistry({ core, store, transport })
|
|
290
342
|
|
|
343
|
+
// Some DSH builds apply the QQ preset allow-mask before the optional Life
|
|
344
|
+
// plugin publishes its native tools. Register a narrow same-name bridge
|
|
345
|
+
// only when the host has not registered them yet; execution still resolves
|
|
346
|
+
// the official service and checks the explicit Scene capability.
|
|
347
|
+
const lifeServiceName = ['l', 'i', 'f', 'e'].join('')
|
|
348
|
+
const lifeSharedTool = ['life', 'shared', 'read'].join('_')
|
|
349
|
+
const lifeHealthTool = ['life', 'health'].join('_')
|
|
350
|
+
const registeredToolNames = () => {
|
|
351
|
+
try { return new Set((ctx.tools.schemas?.() ?? []).map(schema => schema?.name)) } catch { return new Set() }
|
|
352
|
+
}
|
|
353
|
+
const lifeService = () => {
|
|
354
|
+
try { return ctx.get?.(lifeServiceName) ?? ctx.reflect?.get?.(lifeServiceName) ?? null } catch { return null }
|
|
355
|
+
}
|
|
356
|
+
const registerLifeBridge = (name, description, execute) => {
|
|
357
|
+
if (registeredToolNames().has(name)) return
|
|
358
|
+
registerTool(ctx, {
|
|
359
|
+
name,
|
|
360
|
+
description,
|
|
361
|
+
parameters: { type: 'object', additionalProperties: false, properties: {}, required: [] },
|
|
362
|
+
execute,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
const requireLifeSession = exec => {
|
|
366
|
+
const sid = sessionIdOf(exec)
|
|
367
|
+
core.authorizedScene(sid)
|
|
368
|
+
if (!core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)) {
|
|
369
|
+
throw new Error('QQ_PERMISSION_DENIED: amadeus.life.read')
|
|
370
|
+
}
|
|
371
|
+
return sid
|
|
372
|
+
}
|
|
373
|
+
registerLifeBridge(lifeSharedTool,
|
|
374
|
+
'Read the bound Session Life shared view. Requires the explicit QQ Life read grant.',
|
|
375
|
+
(_args, exec) => {
|
|
376
|
+
const service = lifeService()
|
|
377
|
+
if (!service || typeof service.getSharedLifeView !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
|
|
378
|
+
return service.getSharedLifeView({ targetSessionId: requireLifeSession(exec) })
|
|
379
|
+
})
|
|
380
|
+
registerLifeBridge(lifeHealthTool,
|
|
381
|
+
'Read bounded Life health for the bound Session. Requires the explicit QQ Life read grant.',
|
|
382
|
+
(_args, exec) => {
|
|
383
|
+
const service = lifeService()
|
|
384
|
+
if (!service || typeof service.getScopedHealth !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
|
|
385
|
+
return service.getScopedHealth({ scope: `session:${requireLifeSession(exec)}` })
|
|
386
|
+
})
|
|
387
|
+
|
|
291
388
|
// Native management is an optional Host capability. Without a web server or
|
|
292
389
|
// DSH Connection the API stays unavailable; the QQ connector keeps running.
|
|
293
390
|
registerAdmin(ctx, core)
|
|
@@ -317,6 +414,11 @@ export function apply(ctx, config = {}) {
|
|
|
317
414
|
? undefined
|
|
318
415
|
: 'QQ Session search permission is not authorized'
|
|
319
416
|
}
|
|
417
|
+
if (LIFE_READ_TOOLS.includes(exec.name)) {
|
|
418
|
+
return core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)
|
|
419
|
+
? undefined
|
|
420
|
+
: 'QQ Session Life read permission is not authorized'
|
|
421
|
+
}
|
|
320
422
|
// The Owner preset may expose tools registered later by other plugins
|
|
321
423
|
// (for example Life). The connector guard must use the same live Owner
|
|
322
424
|
// grant while refusing unknown names and the PTC code transport.
|
|
@@ -653,13 +755,21 @@ export function apply(ctx, config = {}) {
|
|
|
653
755
|
// Media is optional: wait for the official attachment service without making
|
|
654
756
|
// it a mandatory connector dependency. A text-only Host simply never gets
|
|
655
757
|
// this tool; a configured mediaRoot is still required for a successful read.
|
|
656
|
-
if (typeof ctx.inject === 'function') {
|
|
758
|
+
if (typeof ctx.inject === 'function' || typeof ctx.tools?.register === 'function') {
|
|
657
759
|
let imageToolRegistered = false
|
|
658
|
-
let
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
760
|
+
let imageAttachments = null
|
|
761
|
+
const resolveImageAttachments = () => {
|
|
762
|
+
if (imageAttachments && typeof imageAttachments.saveImage === 'function') return imageAttachments
|
|
763
|
+
try {
|
|
764
|
+
const provided = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
|
|
765
|
+
if (provided && typeof provided.saveImage === 'function') return provided
|
|
766
|
+
} catch { /* optional service */ }
|
|
767
|
+
const fallback = ctx.attachments
|
|
768
|
+
return fallback && typeof fallback.saveImage === 'function' ? fallback : null
|
|
769
|
+
}
|
|
770
|
+
const registerImageTool = () => {
|
|
771
|
+
if (imageToolRegistered) return
|
|
772
|
+
try {
|
|
663
773
|
imageToolRegistered = true
|
|
664
774
|
registerTool(ctx, {
|
|
665
775
|
name: 'qq_read_message_image',
|
|
@@ -682,14 +792,29 @@ export function apply(ctx, config = {}) {
|
|
|
682
792
|
core,
|
|
683
793
|
store,
|
|
684
794
|
transport,
|
|
685
|
-
attachments,
|
|
795
|
+
attachments: resolveImageAttachments(),
|
|
686
796
|
mediaRoot: config.mediaRoot,
|
|
797
|
+
providerCacheRoot: config.providerCacheRoot,
|
|
687
798
|
sessionId: sessionIdOf(exec),
|
|
688
799
|
messageId: args.messageId,
|
|
689
800
|
index: args.index ?? 0,
|
|
690
801
|
}),
|
|
691
802
|
})
|
|
803
|
+
} catch (error) {
|
|
804
|
+
imageToolRegistered = false
|
|
805
|
+
throw error
|
|
692
806
|
}
|
|
807
|
+
}
|
|
808
|
+
let fileToolRegistered = false
|
|
809
|
+
try {
|
|
810
|
+
registerImageTool()
|
|
811
|
+
if (typeof ctx.inject !== 'function') {
|
|
812
|
+
// The image tool remains visible; it will resolve the Host service lazily
|
|
813
|
+
// when the Session actually invokes it.
|
|
814
|
+
} else ctx.inject(['attachments'], scope => {
|
|
815
|
+
const attachments = scope?.attachments ?? ctx.attachments
|
|
816
|
+
if (!attachments) return
|
|
817
|
+
if (attachments && typeof attachments.saveImage === 'function') imageAttachments = attachments
|
|
693
818
|
if (!fileToolRegistered && typeof attachments.saveFile === 'function') {
|
|
694
819
|
fileToolRegistered = true
|
|
695
820
|
registerTool(ctx, {
|
|
@@ -751,7 +876,8 @@ export function apply(ctx, config = {}) {
|
|
|
751
876
|
}),
|
|
752
877
|
})
|
|
753
878
|
}
|
|
754
|
-
|
|
879
|
+
})
|
|
880
|
+
} catch {
|
|
755
881
|
// Missing optional Host media support must not disable ordinary QQ text.
|
|
756
882
|
try { ctx.logger?.warn?.('amadeus-qq: attachment tools unavailable; attachment service could not be injected') } catch { /* optional diagnostic sink */ }
|
|
757
883
|
}
|
|
@@ -865,3 +991,4 @@ export function apply(ctx, config = {}) {
|
|
|
865
991
|
throw error
|
|
866
992
|
}
|
|
867
993
|
}
|
|
994
|
+
|
package/src/lease.js
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { randomUUID } from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
function processExists(pid) {
|
|
6
|
-
if (!Number.isSafeInteger(pid) || pid < 1) return false
|
|
7
|
-
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function acquireInstanceLease(stateFile) {
|
|
11
|
-
if (typeof stateFile !== 'string' || !path.isAbsolute(stateFile)) throw new TypeError('stateFile must be absolute')
|
|
12
|
-
const file = `${stateFile}.lock`
|
|
13
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
14
|
-
const token = randomUUID()
|
|
15
|
-
const record = JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() })
|
|
16
|
-
let fd
|
|
17
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
18
|
-
try {
|
|
19
|
-
fd = fs.openSync(file, 'wx', 0o600)
|
|
20
|
-
break
|
|
21
|
-
} catch (error) {
|
|
22
|
-
if (error?.code !== 'EEXIST') throw error
|
|
23
|
-
let existing
|
|
24
|
-
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { throw new Error('QQ_INSTANCE_LOCKED: unreadable lock requires manual inspection') }
|
|
25
|
-
if (processExists(existing.pid)) throw new Error('QQ_INSTANCE_LOCKED: another QQ sender is active')
|
|
26
|
-
if (attempt > 0) throw new Error('QQ_INSTANCE_LOCKED: could not acquire after stale lock cleanup')
|
|
27
|
-
// Only a dead process's lock is removed; wx still arbitrates the new owner.
|
|
28
|
-
fs.unlinkSync(file)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
if (fd === undefined) throw new Error('QQ_INSTANCE_LOCKED')
|
|
32
|
-
try { fs.writeFileSync(fd, record); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
33
|
-
let released = false
|
|
34
|
-
return () => {
|
|
35
|
-
if (released) return
|
|
36
|
-
released = true
|
|
37
|
-
try {
|
|
38
|
-
const current = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
39
|
-
if (current.token === token) fs.unlinkSync(file)
|
|
40
|
-
} catch { /* never remove a lock that cannot be verified as ours */ }
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
function processExists(pid) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false
|
|
7
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function acquireInstanceLease(stateFile) {
|
|
11
|
+
if (typeof stateFile !== 'string' || !path.isAbsolute(stateFile)) throw new TypeError('stateFile must be absolute')
|
|
12
|
+
const file = `${stateFile}.lock`
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
14
|
+
const token = randomUUID()
|
|
15
|
+
const record = JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() })
|
|
16
|
+
let fd
|
|
17
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
fd = fs.openSync(file, 'wx', 0o600)
|
|
20
|
+
break
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code !== 'EEXIST') throw error
|
|
23
|
+
let existing
|
|
24
|
+
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { throw new Error('QQ_INSTANCE_LOCKED: unreadable lock requires manual inspection') }
|
|
25
|
+
if (processExists(existing.pid)) throw new Error('QQ_INSTANCE_LOCKED: another QQ sender is active')
|
|
26
|
+
if (attempt > 0) throw new Error('QQ_INSTANCE_LOCKED: could not acquire after stale lock cleanup')
|
|
27
|
+
// Only a dead process's lock is removed; wx still arbitrates the new owner.
|
|
28
|
+
fs.unlinkSync(file)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (fd === undefined) throw new Error('QQ_INSTANCE_LOCKED')
|
|
32
|
+
try { fs.writeFileSync(fd, record); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
33
|
+
let released = false
|
|
34
|
+
return () => {
|
|
35
|
+
if (released) return
|
|
36
|
+
released = true
|
|
37
|
+
try {
|
|
38
|
+
const current = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
39
|
+
if (current.token === token) fs.unlinkSync(file)
|
|
40
|
+
} catch { /* never remove a lock that cannot be verified as ours */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/media.js
CHANGED
|
@@ -44,6 +44,19 @@ function resolveMediaRoot(mediaRoot) {
|
|
|
44
44
|
return root
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function resolveProviderRoot(providerCacheRoot) {
|
|
48
|
+
if (typeof providerCacheRoot !== 'string' || !path.isAbsolute(providerCacheRoot)) {
|
|
49
|
+
throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const root = fs.realpathSync(providerCacheRoot)
|
|
53
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
|
|
54
|
+
return root
|
|
55
|
+
} catch {
|
|
56
|
+
throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
function resolveLocalImage(file, root, mediaRoot) {
|
|
48
61
|
if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
49
62
|
// Reject UNC paths and URI schemes. A Windows drive path is the only
|
|
@@ -91,6 +104,40 @@ function resolveLocalImage(file, root, mediaRoot) {
|
|
|
91
104
|
return real
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
// NapCat may return a canonical cache path outside the plugin's private media
|
|
108
|
+
// directory. The model never supplies this path: it is obtained only from the
|
|
109
|
+
// bound message handle and the OneBot get_image response. Keep the same strict
|
|
110
|
+
// file, symlink, size and signature checks while accepting that provider cache.
|
|
111
|
+
function resolvePlatformImage(file, providerRoot) {
|
|
112
|
+
if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
113
|
+
if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
114
|
+
if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
115
|
+
if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
116
|
+
const root = resolveProviderRoot(providerRoot)
|
|
117
|
+
let entry
|
|
118
|
+
try {
|
|
119
|
+
entry = fs.lstatSync(file)
|
|
120
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('not a regular file')
|
|
121
|
+
const real = fs.realpathSync(file)
|
|
122
|
+
const resolved = fs.lstatSync(real)
|
|
123
|
+
if (resolved.isSymbolicLink() || !resolved.isFile()) throw new Error('not a regular file')
|
|
124
|
+
const relative = path.relative(root, real)
|
|
125
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
126
|
+
throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
|
|
127
|
+
}
|
|
128
|
+
let cursor = root
|
|
129
|
+
for (const part of relative.split(path.sep)) {
|
|
130
|
+
cursor = path.join(cursor, part)
|
|
131
|
+
const item = fs.lstatSync(cursor)
|
|
132
|
+
if (item.isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
133
|
+
}
|
|
134
|
+
return real
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code === 'QQ_MEDIA_PATH_OUTSIDE_ROOT' || error?.code === 'QQ_MEDIA_PATH_INVALID') throw error
|
|
137
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
function sniffImageMediaType(data) {
|
|
95
142
|
if (data.length >= PNG_SIGNATURE.length && data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return 'image/png'
|
|
96
143
|
if (data.length >= JPEG_SIGNATURE.length && data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return 'image/jpeg'
|
|
@@ -162,6 +209,7 @@ export async function readMessageImage({
|
|
|
162
209
|
transport,
|
|
163
210
|
attachments,
|
|
164
211
|
mediaRoot,
|
|
212
|
+
providerCacheRoot,
|
|
165
213
|
sessionId,
|
|
166
214
|
messageId,
|
|
167
215
|
index = 0,
|
|
@@ -179,8 +227,6 @@ export async function readMessageImage({
|
|
|
179
227
|
}
|
|
180
228
|
assertMediaRead(core, sessionId)
|
|
181
229
|
|
|
182
|
-
const root = resolveMediaRoot(mediaRoot)
|
|
183
|
-
|
|
184
230
|
let id
|
|
185
231
|
try {
|
|
186
232
|
id = normalizeMessageId(messageId, 'messageId')
|
|
@@ -219,7 +265,23 @@ export async function readMessageImage({
|
|
|
219
265
|
|
|
220
266
|
let data
|
|
221
267
|
if (typeof response.file === 'string') {
|
|
222
|
-
|
|
268
|
+
// NapCat returns a provider-owned absolute cache path. Prefer the
|
|
269
|
+
// configured private root when it contains the file, but do not make a
|
|
270
|
+
// working OneBot provider cache depend on that optional directory: the
|
|
271
|
+
// handle came from the bound message and is still revalidated as a
|
|
272
|
+
// canonical regular file before bounded reading.
|
|
273
|
+
let localFile
|
|
274
|
+
try {
|
|
275
|
+
const root = resolveMediaRoot(mediaRoot)
|
|
276
|
+
try { localFile = resolveLocalImage(response.file, root, mediaRoot) }
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error?.code !== 'QQ_MEDIA_PATH_OUTSIDE_ROOT') throw error
|
|
279
|
+
localFile = resolvePlatformImage(response.file, providerCacheRoot)
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error?.code !== 'QQ_MEDIA_ROOT_INVALID') throw error
|
|
283
|
+
localFile = resolvePlatformImage(response.file, providerCacheRoot)
|
|
284
|
+
}
|
|
223
285
|
data = await withTimeout(readBoundedImage(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
|
|
224
286
|
} else if (typeof response.base64 === 'string') {
|
|
225
287
|
data = decodeInlineImage(response.base64)
|
|
@@ -244,3 +306,4 @@ export async function readMessageImage({
|
|
|
244
306
|
}
|
|
245
307
|
return { attachment }
|
|
246
308
|
}
|
|
309
|
+
|