@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +28 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  26. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  27. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  28. package/lib/client.js +380 -0
  29. package/package.json +92 -4
  30. package/preset.patch.yml +166 -0
  31. package/screenshots.json +6 -0
  32. package/scripts/build-client.mjs +52 -0
  33. package/scripts/registry-guard.mjs +12 -0
  34. package/scripts/release-audit.mjs +59 -0
  35. package/scripts/symlink-security.mjs +8 -0
  36. package/src/account-profile.js +223 -0
  37. package/src/admin-http.js +361 -0
  38. package/src/audio.js +428 -0
  39. package/src/capabilities.js +785 -0
  40. package/src/client/factory.template.js +82 -0
  41. package/src/client/logic.js +290 -0
  42. package/src/config.js +50 -0
  43. package/src/console/console.css +28 -0
  44. package/src/console/console.js +541 -0
  45. package/src/console/index.html +67 -0
  46. package/src/core.js +909 -0
  47. package/src/files.js +230 -0
  48. package/src/index.js +994 -0
  49. package/src/lease.js +42 -0
  50. package/src/media.js +309 -0
  51. package/src/napcat-action-names.js +186 -0
  52. package/src/native-asr.js +112 -0
  53. package/src/onebot.js +378 -0
  54. package/src/outbound-media.js +309 -0
  55. package/src/owner-workspace.js +394 -0
  56. package/src/permissions.js +270 -0
  57. package/src/preset-guard.mjs +593 -0
  58. package/src/qzone-provider.js +628 -0
  59. package/src/session-provenance.js +74 -0
  60. package/src/setup.js +255 -0
  61. package/src/storage.js +580 -0
  62. package/src/web-console.js +9 -0
@@ -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
+
@@ -0,0 +1,6 @@
1
+ [
2
+ "docs/screenshots/v1.2-overview.png",
3
+ "docs/screenshots/global-desktop.png",
4
+ "docs/screenshots/account-browser.png",
5
+ "docs/screenshots/account-dark-browser.png"
6
+ ]
@@ -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
+ }