@fg-lab/amadeus-qq 0.0.0-stage → 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 -0
- package/README.md +54 -2
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +28 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- 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/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +92 -4
- package/preset.patch.yml +166 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +50 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +541 -0
- package/src/console/index.html +67 -0
- package/src/core.js +909 -0
- package/src/files.js +230 -0
- package/src/index.js +994 -0
- package/src/lease.js +42 -0
- package/src/media.js +309 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +270 -0
- package/src/preset-guard.mjs +593 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- package/src/web-console.js +9 -0
package/preset.patch.yml
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Dedicated DSH Agent preset shipped by the same @fg-lab/amadeus-qq package.
|
|
2
|
+
# It declares only the QQ platform operation contract (tool boundary) and no
|
|
3
|
+
# persona or character card. The guard lives with the preset, so a persisted QQ
|
|
4
|
+
# Session keeps its tool boundary even when the connector is disabled or absent.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: preset-amadeus-qq
|
|
7
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
8
|
+
config:
|
|
9
|
+
id: amadeus-qq
|
|
10
|
+
name: Amadeus QQ
|
|
11
|
+
description: Restricted continuous QQ conversation Session
|
|
12
|
+
order: 20
|
|
13
|
+
plugins:
|
|
14
|
+
- id: qq-tool-boundary
|
|
15
|
+
# Mount the package root here. DSH's preset registry resolves the
|
|
16
|
+
# package root reliably; a package subpath export imports under
|
|
17
|
+
# plain Node but leaves this row "never started" in the registry.
|
|
18
|
+
name: '@fg-lab/amadeus-qq'
|
|
19
|
+
config:
|
|
20
|
+
presetGuard: true
|
|
21
|
+
- id: preset-amadeus-qq-search
|
|
22
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
23
|
+
config:
|
|
24
|
+
id: amadeus-qq-search
|
|
25
|
+
name: Amadeus QQ Search
|
|
26
|
+
description: Restricted QQ conversation with the Host web search boundary
|
|
27
|
+
order: 21
|
|
28
|
+
plugins:
|
|
29
|
+
- id: qq-web-search
|
|
30
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
31
|
+
config:
|
|
32
|
+
search: true
|
|
33
|
+
fetch: false
|
|
34
|
+
searchTimeoutMs: 60000
|
|
35
|
+
- id: qq-search-boundary
|
|
36
|
+
name: '@fg-lab/amadeus-qq'
|
|
37
|
+
config:
|
|
38
|
+
presetGuard: true
|
|
39
|
+
allowSearch: true
|
|
40
|
+
- id: preset-amadeus-qq-media
|
|
41
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
42
|
+
config:
|
|
43
|
+
id: amadeus-qq-media
|
|
44
|
+
name: Amadeus QQ Media
|
|
45
|
+
description: Restricted QQ conversation with an enhanced media read boundary
|
|
46
|
+
order: 22
|
|
47
|
+
plugins:
|
|
48
|
+
- id: qq-media-boundary
|
|
49
|
+
name: '@fg-lab/amadeus-qq'
|
|
50
|
+
config:
|
|
51
|
+
presetGuard: true
|
|
52
|
+
level: 2
|
|
53
|
+
- id: preset-amadeus-qq-media-search
|
|
54
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
55
|
+
config:
|
|
56
|
+
id: amadeus-qq-media-search
|
|
57
|
+
name: Amadeus QQ Media Search
|
|
58
|
+
description: Restricted QQ conversation with enhanced media read and Host web search
|
|
59
|
+
order: 23
|
|
60
|
+
plugins:
|
|
61
|
+
- id: qq-media-web-search
|
|
62
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
63
|
+
config:
|
|
64
|
+
search: true
|
|
65
|
+
fetch: false
|
|
66
|
+
searchTimeoutMs: 60000
|
|
67
|
+
- id: qq-media-search-boundary
|
|
68
|
+
name: '@fg-lab/amadeus-qq'
|
|
69
|
+
config:
|
|
70
|
+
presetGuard: true
|
|
71
|
+
level: 2
|
|
72
|
+
allowSearch: true
|
|
73
|
+
- id: preset-amadeus-qq-owner
|
|
74
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
75
|
+
config:
|
|
76
|
+
id: amadeus-qq-owner
|
|
77
|
+
name: Amadeus QQ Owner
|
|
78
|
+
description: Restricted Owner workspace; selected only for a trusted Owner Agent Scene
|
|
79
|
+
order: 24
|
|
80
|
+
plugins:
|
|
81
|
+
- id: qq-owner-boundary
|
|
82
|
+
# Level 4 guards the QQ tool surface. The Owner workspace tool is
|
|
83
|
+
# mounted only after an operator supplies ownerWorkspaceRoot; until
|
|
84
|
+
# then Settings shows it as pending and no file tool exists.
|
|
85
|
+
name: '@fg-lab/amadeus-qq'
|
|
86
|
+
config:
|
|
87
|
+
presetGuard: true
|
|
88
|
+
level: 4
|
|
89
|
+
- id: owner-tool-pwsh
|
|
90
|
+
name: '@deepseek-ai/dsh-tool-pwsh'
|
|
91
|
+
- id: owner-tool-fs
|
|
92
|
+
name: '@deepseek-ai/dsh-tool-fs'
|
|
93
|
+
- id: owner-tool-fs-search
|
|
94
|
+
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
95
|
+
config:
|
|
96
|
+
sampleOverCapGlobResults: false
|
|
97
|
+
- id: owner-tool-jobs
|
|
98
|
+
name: '@deepseek-ai/dsh-tool-jobs'
|
|
99
|
+
- id: owner-tool-web
|
|
100
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
101
|
+
config:
|
|
102
|
+
search: true
|
|
103
|
+
fetch: true
|
|
104
|
+
- id: preset-amadeus-qq-life
|
|
105
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
106
|
+
config:
|
|
107
|
+
id: amadeus-qq-life
|
|
108
|
+
name: Amadeus QQ Life
|
|
109
|
+
description: Restricted QQ conversation with explicit Life read access
|
|
110
|
+
order: 25
|
|
111
|
+
plugins:
|
|
112
|
+
- id: qq-life-boundary
|
|
113
|
+
name: '@fg-lab/amadeus-qq'
|
|
114
|
+
config:
|
|
115
|
+
presetGuard: true
|
|
116
|
+
allowLifeRead: true
|
|
117
|
+
- id: preset-amadeus-qq-life-search
|
|
118
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
119
|
+
config:
|
|
120
|
+
id: amadeus-qq-life-search
|
|
121
|
+
name: Amadeus QQ Life Search
|
|
122
|
+
description: Restricted QQ conversation with Life read access and Host web search
|
|
123
|
+
order: 26
|
|
124
|
+
plugins:
|
|
125
|
+
- id: qq-life-search-boundary
|
|
126
|
+
name: '@fg-lab/amadeus-qq'
|
|
127
|
+
config:
|
|
128
|
+
presetGuard: true
|
|
129
|
+
allowLifeRead: true
|
|
130
|
+
allowSearch: true
|
|
131
|
+
- id: preset-amadeus-qq-life-media
|
|
132
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
133
|
+
config:
|
|
134
|
+
id: amadeus-qq-life-media
|
|
135
|
+
name: Amadeus QQ Life Media
|
|
136
|
+
description: Restricted QQ conversation with Life and media read access
|
|
137
|
+
order: 27
|
|
138
|
+
plugins:
|
|
139
|
+
- id: qq-life-media-boundary
|
|
140
|
+
name: '@fg-lab/amadeus-qq'
|
|
141
|
+
config:
|
|
142
|
+
presetGuard: true
|
|
143
|
+
level: 2
|
|
144
|
+
allowLifeRead: true
|
|
145
|
+
- id: preset-amadeus-qq-life-media-search
|
|
146
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
147
|
+
config:
|
|
148
|
+
id: amadeus-qq-life-media-search
|
|
149
|
+
name: Amadeus QQ Life Media Search
|
|
150
|
+
description: Restricted QQ conversation with Life, media read access and Host web search
|
|
151
|
+
order: 28
|
|
152
|
+
plugins:
|
|
153
|
+
- id: qq-life-media-web-search
|
|
154
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
155
|
+
config:
|
|
156
|
+
search: true
|
|
157
|
+
fetch: false
|
|
158
|
+
searchTimeoutMs: 60000
|
|
159
|
+
- id: qq-life-media-search-boundary
|
|
160
|
+
name: '@fg-lab/amadeus-qq'
|
|
161
|
+
config:
|
|
162
|
+
presetGuard: true
|
|
163
|
+
level: 2
|
|
164
|
+
allowLifeRead: true
|
|
165
|
+
allowSearch: true
|
|
166
|
+
|
package/screenshots.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the browser half as a DSH lazy-CJS module.
|
|
3
|
+
* The template is plain JavaScript (no ESM import/export); this script wraps it
|
|
4
|
+
* in the official ModuleLoader format and verifies the result parses.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import vm from 'node:vm'
|
|
10
|
+
|
|
11
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
12
|
+
const templatePath = join(root, 'src', 'client', 'factory.template.js')
|
|
13
|
+
const logicPath = join(root, 'src', 'client', 'logic.js')
|
|
14
|
+
const outputPath = join(root, 'lib', 'client.js')
|
|
15
|
+
|
|
16
|
+
const template = readFileSync(templatePath, 'utf8')
|
|
17
|
+
const logic = readFileSync(logicPath, 'utf8').replace(/^export\s+/gm, '')
|
|
18
|
+
for (const source of [template, logic]) {
|
|
19
|
+
if (/^\s*(import|export)\s/m.test(source)) {
|
|
20
|
+
throw new Error('client factory must not contain ESM import/export statements')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const body = template.replace('__CLIENT_LOGIC__', logic)
|
|
25
|
+
if (body.includes('__CLIENT_LOGIC__')) throw new Error('client logic placeholder was not replaced')
|
|
26
|
+
|
|
27
|
+
const bundle = `window.__ModuleLoader__.load({
|
|
28
|
+
id: '@fg-lab/amadeus-qq',
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
const React = require('react');
|
|
31
|
+
const h = React.createElement;
|
|
32
|
+
${body}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
new vm.Script(bundle, { filename: 'lib/client.js' })
|
|
38
|
+
|
|
39
|
+
for (const marker of [
|
|
40
|
+
'settings.section',
|
|
41
|
+
"id: 'amadeus-qq'",
|
|
42
|
+
"/api/amadeus-qq",
|
|
43
|
+
"credentials: 'same-origin'",
|
|
44
|
+
'打开 Web Console',
|
|
45
|
+
'开始首次设置',
|
|
46
|
+
]) {
|
|
47
|
+
if (!bundle.includes(marker)) throw new Error(`client bundle is missing required marker: ${marker}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mkdirSync(dirname(outputPath), { recursive: true })
|
|
51
|
+
writeFileSync(outputPath, bundle, 'utf8')
|
|
52
|
+
process.stdout.write(`built lib/client.js (${Buffer.byteLength(bundle)} bytes)\n`)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
const pkg=JSON.parse(fs.readFileSync(new URL('../package.json',import.meta.url),'utf8'));
|
|
5
|
+
assert.match(pkg.name,/^@fg-lab\/amadeus-(qq|memory|life|state|persona)$/);
|
|
6
|
+
assert.match(pkg.version,/^\d+\.\d+\.\d+$/);
|
|
7
|
+
assert.notEqual(pkg.private,true,'Private packages cannot be published');
|
|
8
|
+
assert.equal(pkg.publishConfig?.access,'public');
|
|
9
|
+
const response=await fetch('https://registry.npmjs.org/'+encodeURIComponent(pkg.name)+'/'+encodeURIComponent(pkg.version),{signal:AbortSignal.timeout(20000)});
|
|
10
|
+
if(response.status===200)throw new Error('This npm version already exists; never overwrite or reuse it.');
|
|
11
|
+
if(response.status!==404)throw new Error('Registry availability could not be verified (HTTP '+response.status+'); stop publishing.');
|
|
12
|
+
console.log('Registry preflight: target version does not exist. Scope authorization still requires npm authentication.');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
6
|
+
const requirements = {
|
|
7
|
+
'@fg-lab/amadeus-qq': ['src/index.js', 'lib/client.js', 'src/console/index.html', 'src/console/console.js', 'src/console/console.css', 'cordis.patch.yml', 'preset.patch.yml'],
|
|
8
|
+
'@fg-lab/amadeus-memory': ['lib/index.js', 'lib/client.js', 'cordis.patch.yml', 'runtime-manifest.json'],
|
|
9
|
+
'@fg-lab/amadeus-life': ['src/index.js', 'lib/client.js', 'cordis.patch.yml', 'icon.svg', 'locale/zh.json', 'locale/en.json'],
|
|
10
|
+
'@fg-lab/amadeus-state': ['src/index.js', 'lib/client.js', 'cordis.patch.yml', 'icon.svg', 'locale/zh.json', 'locale/en.json'],
|
|
11
|
+
'@fg-lab/amadeus-persona': ['src/index.js', 'src/client.js', 'src/console/index.html', 'src/console/console.js', 'src/console/console.css', 'cordis.patch.yml']
|
|
12
|
+
}
|
|
13
|
+
for (const required of Object.values(requirements)) required.push('assets/market-icon.png', 'screenshots.json', 'docs/INSTALL_V1_2.md')
|
|
14
|
+
if (pkg.license === 'MIT') requirements[pkg.name]?.push('LICENSE')
|
|
15
|
+
const expectedId = pkg.name === '@fg-lab/amadeus-memory' ? 'dsh-mneme' : pkg.name.replace('@fg-lab/', '')
|
|
16
|
+
const errors = []
|
|
17
|
+
const warnings = []
|
|
18
|
+
if (!requirements[pkg.name]) errors.push(`unexpected package name: ${pkg.name}`)
|
|
19
|
+
if (pkg.scripts?.prepare || pkg.scripts?.postinstall || pkg.scripts?.install) errors.push('install-time script is forbidden for the prebuilt package')
|
|
20
|
+
if (!pkg.dsh?.bundle?.patch || !pkg.dsh?.client) errors.push('missing DSH bundle patch or client manifest')
|
|
21
|
+
if (!pkg.engines?.dsh) errors.push('missing tested engines.dsh range')
|
|
22
|
+
if (!readFileSync('cordis.patch.yml', 'utf8').includes(`id: ${expectedId}`)) errors.push(`patch does not preserve plugin id ${expectedId}`)
|
|
23
|
+
if (pkg.private === true) warnings.push('private:true prevents public npm publish')
|
|
24
|
+
if (!pkg.license || pkg.license === 'UNLICENSED') warnings.push('public distribution license needs owner review')
|
|
25
|
+
|
|
26
|
+
let manifest
|
|
27
|
+
try {
|
|
28
|
+
const raw = execSync('npm pack --dry-run --json --ignore-scripts', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
29
|
+
const parsed = JSON.parse(raw)
|
|
30
|
+
manifest = Array.isArray(parsed) ? parsed[0] : parsed[pkg.name] ?? Object.values(parsed)[0]
|
|
31
|
+
} catch (error) {
|
|
32
|
+
errors.push(`npm pack failed: ${error.stderr?.toString().trim() || error.message}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (manifest) {
|
|
36
|
+
const names = new Set(manifest.files.map(file => file.path.replaceAll('\\', '/')))
|
|
37
|
+
for (const path of requirements[pkg.name] ?? []) if (!names.has(path)) errors.push(`missing runtime asset: ${path}`)
|
|
38
|
+
try {
|
|
39
|
+
const screenshots = JSON.parse(readFileSync('screenshots.json', 'utf8'))
|
|
40
|
+
if (!Array.isArray(screenshots) || screenshots.length < 1 || screenshots.length > 8) errors.push('screenshots.json must list 1–8 images')
|
|
41
|
+
else for (const path of screenshots) {
|
|
42
|
+
if (typeof path !== 'string' || path.startsWith('/') || path.includes('..') || !/\.(png|jpe?g|webp)$/i.test(path)) errors.push(`invalid screenshot path: ${path}`)
|
|
43
|
+
else if (!names.has(path)) errors.push(`screenshot is absent from package: ${path}`)
|
|
44
|
+
}
|
|
45
|
+
} catch (error) { errors.push(`invalid screenshots.json: ${error.message}`) }
|
|
46
|
+
const forbiddenName = /(^|\/)(?:node_modules|private|profiles|logs|\.git|\.env[^/]*|\.npmrc|credentials(?:\.json)?|secrets?)(?:\/|$)|^runtime\/|\.(?:db|sqlite(?:-wal|-shm)?|log|key|pem)$/i
|
|
47
|
+
const secret = /gh[pousr]_[A-Za-z0-9_]{30,}|(?:sk-|sk_)[A-Za-z0-9]{32,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY-----|DEEPSEEK_API_KEY\s*[:=]\s*['"]?[A-Za-z0-9_-]{16,}/
|
|
48
|
+
for (const path of names) {
|
|
49
|
+
if (forbiddenName.test(path)) errors.push(`forbidden package path: ${path}`)
|
|
50
|
+
if (path === 'package.json') continue
|
|
51
|
+
const file = readFileSync(resolve(path))
|
|
52
|
+
if (file.length <= 2_000_000 && !file.includes(0) && secret.test(file.toString('utf8'))) errors.push(`possible credential in package file: ${path}`)
|
|
53
|
+
}
|
|
54
|
+
console.log(JSON.stringify({ name: manifest.name, version: manifest.version, files: names.size, unpackedBytes: manifest.unpackedSize, warnings }, null, 2))
|
|
55
|
+
}
|
|
56
|
+
if (errors.length) {
|
|
57
|
+
for (const error of errors) console.error(`ERROR ${error}`)
|
|
58
|
+
process.exitCode = 1
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process';
|
|
2
|
+
if(process.platform!=='linux')throw Error('Run this release gate on Linux; Windows skips do not satisfy it');
|
|
3
|
+
const files=['tests/audio-read.test.mjs','tests/media-read.test.mjs','tests/file-read.test.mjs','tests/outbound-media.test.mjs'];
|
|
4
|
+
const result=spawnSync(process.execPath,['--test','--test-name-pattern=symlink',...files],{encoding:'utf8'});
|
|
5
|
+
process.stdout.write(result.stdout??'');process.stderr.write(result.stderr??'');
|
|
6
|
+
const cases=(result.stdout??'').split(/\r?\n/).filter(line=>/^(?:not )?ok \d+ - .*symlink/i.test(line));
|
|
7
|
+
if(result.status!==0||cases.length!==4||cases.some(line=>!line.startsWith('ok ')||/# SKIP|# TODO/.test(line)))throw Error('All four symlink security cases must execute and pass without skips');
|
|
8
|
+
console.log('RELEASE_GATE: 4 symlink safety tests executed and passed');
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { snapshotControlledImage } from './outbound-media.js'
|
|
4
|
+
|
|
5
|
+
const STATUS_VALUES = new Set([10, 30, 40, 50, 60, 70])
|
|
6
|
+
const MAX_SIGNATURE = 200
|
|
7
|
+
const MAX_NICKNAME = 36
|
|
8
|
+
|
|
9
|
+
function profileError(code) {
|
|
10
|
+
const error = new Error(code)
|
|
11
|
+
error.code = code
|
|
12
|
+
return error
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cleanText(value, max, code) {
|
|
16
|
+
if (typeof value !== 'string') throw profileError(code)
|
|
17
|
+
const text = value.replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
18
|
+
if (text.length === 0 || text.length > max) throw profileError(code)
|
|
19
|
+
return text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decimalId(value) {
|
|
23
|
+
const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
|
|
24
|
+
if (typeof id !== 'string' || !/^[1-9]\d{0,19}$/.test(id)) throw profileError('QQ_PROFILE_RESPONSE_INVALID')
|
|
25
|
+
return id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function responseInvalid() {
|
|
29
|
+
return profileError('QQ_PROFILE_RESPONSE_INVALID')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function profileFingerprint(scene, action, payload) {
|
|
33
|
+
return createHash('sha256')
|
|
34
|
+
.update(JSON.stringify({
|
|
35
|
+
account: scene.botId,
|
|
36
|
+
scene: scene.peerId,
|
|
37
|
+
action,
|
|
38
|
+
payload,
|
|
39
|
+
}))
|
|
40
|
+
.digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAccountProfile({ core, transport, authorize, ledger, sendRoot, tempRoot }) {
|
|
44
|
+
if (typeof authorize !== 'function') throw profileError('QQ_PROFILE_UNAUTHORIZED')
|
|
45
|
+
if (!transport || typeof transport.action !== 'function') throw profileError('QQ_PROFILE_UNAVAILABLE')
|
|
46
|
+
|
|
47
|
+
async function check(sessionId, action, write) {
|
|
48
|
+
let scene
|
|
49
|
+
try {
|
|
50
|
+
scene = core.authorizedScene(sessionId)
|
|
51
|
+
} catch {
|
|
52
|
+
throw profileError('QQ_PROFILE_UNAUTHORIZED')
|
|
53
|
+
}
|
|
54
|
+
if (write && scene.kind !== 'private') throw profileError('QQ_PROFILE_OWNER_REQUIRED')
|
|
55
|
+
let allowed = false
|
|
56
|
+
try {
|
|
57
|
+
allowed = await authorize({ action, write, sessionId, scene }) === true
|
|
58
|
+
} catch {
|
|
59
|
+
allowed = false
|
|
60
|
+
}
|
|
61
|
+
if (!allowed) throw profileError('QQ_PROFILE_UNAUTHORIZED')
|
|
62
|
+
return scene
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function call(action, params, write) {
|
|
66
|
+
let data
|
|
67
|
+
try {
|
|
68
|
+
data = await transport.action(action, params, 10_000)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!write) throw profileError('QQ_PROFILE_UNAVAILABLE')
|
|
71
|
+
if (error?.code === 'ONEBOT_REJECTED') throw profileError('QQ_PROFILE_REJECTED')
|
|
72
|
+
throw profileError('QQ_PROFILE_UNKNOWN')
|
|
73
|
+
}
|
|
74
|
+
if (data === null || data === undefined) {
|
|
75
|
+
if (write) return {}
|
|
76
|
+
throw responseInvalid()
|
|
77
|
+
}
|
|
78
|
+
if (typeof data !== 'object') throw responseInvalid()
|
|
79
|
+
return data
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loginInfo(sessionId, write = false) {
|
|
83
|
+
const data = await call('get_login_info', {}, write)
|
|
84
|
+
return { userId: decimalId(data.user_id), nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, MAX_NICKNAME) : '' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function strangerInfo(sessionId, userId, write = false) {
|
|
88
|
+
const data = await call('get_stranger_info', { user_id: userId, no_cache: true }, write)
|
|
89
|
+
return {
|
|
90
|
+
longNick: typeof data.long_nick === 'string' ? data.long_nick.slice(0, MAX_SIGNATURE) : '',
|
|
91
|
+
status: Number.isFinite(Number(data.status)) ? Number(data.status) : null,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function userStatus(userId, write = false) {
|
|
96
|
+
const data = await call('nc_get_user_status', { user_id: userId }, write)
|
|
97
|
+
const status = Number(data.status)
|
|
98
|
+
const extStatus = Number(data.ext_status)
|
|
99
|
+
if (!Number.isSafeInteger(status) || !Number.isSafeInteger(extStatus)) throw responseInvalid()
|
|
100
|
+
return { status, extStatus }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readProfileInternal(sessionId, write = false) {
|
|
104
|
+
const login = await loginInfo(sessionId, write)
|
|
105
|
+
const stranger = await strangerInfo(sessionId, login.userId, write)
|
|
106
|
+
const online = await userStatus(login.userId, write)
|
|
107
|
+
return {
|
|
108
|
+
userId: login.userId,
|
|
109
|
+
nickname: login.nickname,
|
|
110
|
+
signature: stranger.longNick,
|
|
111
|
+
status: online.status,
|
|
112
|
+
extStatus: online.extStatus,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function writeWithLedger(sessionId, action, payload, actionId, verify) {
|
|
117
|
+
const scene = await check(sessionId, action, true)
|
|
118
|
+
if (!ledger || typeof ledger.run !== 'function') throw profileError('QQ_PROFILE_LEDGER_REQUIRED')
|
|
119
|
+
if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw profileError('QQ_PROFILE_ACTION_ID_INVALID')
|
|
120
|
+
const fingerprint = profileFingerprint(scene, action, payload)
|
|
121
|
+
return ledger.run({
|
|
122
|
+
actionId,
|
|
123
|
+
fingerprint,
|
|
124
|
+
action,
|
|
125
|
+
sessionId,
|
|
126
|
+
sceneKey: `${scene.botId}:${scene.kind}:${scene.peerId}`,
|
|
127
|
+
execute: async () => {
|
|
128
|
+
await call(action, payload, true)
|
|
129
|
+
try {
|
|
130
|
+
await check(sessionId, action, true)
|
|
131
|
+
} catch {
|
|
132
|
+
// The platform write already happened; do not report a clean failure.
|
|
133
|
+
throw profileError('QQ_PROFILE_UNKNOWN')
|
|
134
|
+
}
|
|
135
|
+
let profile
|
|
136
|
+
try {
|
|
137
|
+
profile = await readProfileInternal(sessionId, true)
|
|
138
|
+
if (!(await verify(profile))) throw profileError('QQ_PROFILE_UNKNOWN')
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === 'QQ_PROFILE_UNKNOWN') throw error
|
|
141
|
+
throw profileError('QQ_PROFILE_UNKNOWN')
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await check(sessionId, action, true)
|
|
145
|
+
} catch {
|
|
146
|
+
throw profileError('QQ_PROFILE_UNKNOWN')
|
|
147
|
+
}
|
|
148
|
+
return profile
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
async readProfile({ sessionId }) {
|
|
155
|
+
await check(sessionId, 'get_login_info', false)
|
|
156
|
+
const profile = await readProfileInternal(sessionId)
|
|
157
|
+
await check(sessionId, 'get_login_info', false)
|
|
158
|
+
return profile
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async setSignature({ sessionId, signature, actionId }) {
|
|
162
|
+
const value = cleanText(signature, MAX_SIGNATURE, 'QQ_PROFILE_SIGNATURE_INVALID')
|
|
163
|
+
return writeWithLedger(sessionId, 'set_self_longnick', { longNick: value }, actionId,
|
|
164
|
+
profile => profile.signature === value)
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async setNickname({ sessionId, nickname, actionId }) {
|
|
168
|
+
const value = cleanText(nickname, MAX_NICKNAME, 'QQ_PROFILE_NICKNAME_INVALID')
|
|
169
|
+
return writeWithLedger(sessionId, 'set_qq_profile', { nickname: value }, actionId,
|
|
170
|
+
profile => profile.nickname === value)
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async setOnlineStatus({ sessionId, status, extStatus, batteryStatus, actionId }) {
|
|
174
|
+
if (!Number.isSafeInteger(status) || !STATUS_VALUES.has(status)) throw profileError('QQ_PROFILE_STATUS_INVALID')
|
|
175
|
+
if (!Number.isSafeInteger(extStatus) || extStatus < 0 || extStatus > 9999) throw profileError('QQ_PROFILE_STATUS_INVALID')
|
|
176
|
+
if (!Number.isSafeInteger(batteryStatus) || batteryStatus < 0 || batteryStatus > 100) throw profileError('QQ_PROFILE_STATUS_INVALID')
|
|
177
|
+
return writeWithLedger(sessionId, 'set_online_status', {
|
|
178
|
+
status, ext_status: extStatus, battery_status: batteryStatus,
|
|
179
|
+
}, actionId, profile => profile.status === status && profile.extStatus === extStatus)
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async setAvatar({ sessionId, path: relativePath, actionId }) {
|
|
183
|
+
if (typeof sendRoot !== 'string' || !path.isAbsolute(sendRoot)) {
|
|
184
|
+
throw profileError('QQ_PROFILE_AVATAR_ROOT_INVALID')
|
|
185
|
+
}
|
|
186
|
+
const scene = await check(sessionId, 'set_qq_avatar', true)
|
|
187
|
+
if (!ledger || typeof ledger.run !== 'function') throw profileError('QQ_PROFILE_LEDGER_REQUIRED')
|
|
188
|
+
if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw profileError('QQ_PROFILE_ACTION_ID_INVALID')
|
|
189
|
+
|
|
190
|
+
let snapshot
|
|
191
|
+
try {
|
|
192
|
+
snapshot = await snapshotControlledImage({ sendRoot, relativePath, tempRoot })
|
|
193
|
+
} catch {
|
|
194
|
+
throw profileError('QQ_PROFILE_AVATAR_INVALID')
|
|
195
|
+
}
|
|
196
|
+
const fingerprint = profileFingerprint(scene, 'set_qq_avatar', {
|
|
197
|
+
sha256: snapshot.sha256,
|
|
198
|
+
mediaType: snapshot.mediaType,
|
|
199
|
+
})
|
|
200
|
+
try {
|
|
201
|
+
return await ledger.run({
|
|
202
|
+
actionId,
|
|
203
|
+
fingerprint,
|
|
204
|
+
action: 'set_qq_avatar',
|
|
205
|
+
sessionId,
|
|
206
|
+
sceneKey: `${scene.botId}:${scene.kind}:${scene.peerId}`,
|
|
207
|
+
execute: async () => {
|
|
208
|
+
await call('set_qq_avatar', { file: snapshot.path }, true)
|
|
209
|
+
try {
|
|
210
|
+
await check(sessionId, 'set_qq_avatar', true)
|
|
211
|
+
} catch {
|
|
212
|
+
// The platform accepted the write; a later revocation is unknown.
|
|
213
|
+
throw profileError('QQ_PROFILE_UNKNOWN')
|
|
214
|
+
}
|
|
215
|
+
return { status: 'confirmed', mediaType: snapshot.mediaType, bytes: snapshot.bytes }
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
} finally {
|
|
219
|
+
await snapshot.cleanup()
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
}
|