dsh-qq-stack 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-qq-stack contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # dsh-qq-stack
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DeepSeek Harness 装上 QQ:LinuxQQ + NapCat 跟着引擎一起启动,扫码一次,之后自动登录。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ dsh plugin --profile web add dsh-qq-stack
9
+ ```
10
+
11
+ 或者把打好的 tgz 包拖进 DSH 的「插件 › 添加插件」。
12
+
13
+ 装完重启一次引擎,第一次运行会自动下载 LinuxQQ 和 NapCat(约 250MB),之后就不再下载。
14
+
15
+ ## 使用
16
+
17
+ 打开 `设置 › QQ 底座`:
18
+
19
+ - **未登录**:显示二维码,用手机 QQ 扫码并在手机上点「允许登录」
20
+ - **已登录**:显示头像、昵称、QQ 号;点「切换账号」可以换号
21
+ - 上方三行是状态:QQ 进程、OneBot 端口、登录状态,绿点是正常
22
+
23
+ 登录成功后,下次打开 DSH 会自动快登这个号,不用再扫码。
24
+
25
+ ## 配置
26
+
27
+ `~/.dsh/qq-bot/config.env`:
28
+
29
+ | 键 | 默认 | 说明 |
30
+ | --- | --- | --- |
31
+ | `DSH_QQ_UIN` | 登录后自动写入 | 自动快登的 QQ 号 |
32
+ | `DSH_QQ_WS_PORT` | `3001` | OneBot 正向 WebSocket 端口 |
33
+ | `DSH_QQ_TOKEN` | `dshfish-3001` | WebSocket 令牌,接入 OneBot 的客户端用来认证 |
34
+
35
+ 安装目录默认 `~/.dsh/qq-bot`,可用环境变量 `DSH_QQ_ROOT` 修改。
36
+
37
+ ## 要求
38
+
39
+ - Linux(aarch64 / amd64),能装 apt 包;Android 的 DSH 运行环境也可以
40
+ - Node ≥ 22.5
41
+ - 首次安装约 250MB 磁盘和下载
42
+
43
+ ## 常见问题
44
+
45
+ **扫码后一直显示未登录?**
46
+ 在手机上确认「允许登录」,只扫码不确认不算登录。确认后十几秒内状态会变成已登录。
47
+
48
+ **过一段时间掉线?**
49
+ 打不开 DSH、或者引擎重启时,插件会把 QQ 拉起来,并按保存的账号自动快登。如果 QQ 进程被系统杀掉,重新打开 DSH 即可恢复;登录态失效时,需要重新扫码一次。
50
+
51
+ **群里不回复?**
52
+ 这是负责聊天的插件自己的设置,本插件只负责让 QQ 保持在线。
53
+
54
+ ## 许可
55
+
56
+ MIT
package/client.js ADDED
@@ -0,0 +1,115 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-qq-stack',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ const react = require('react')
7
+ const h = react.createElement
8
+ const inject = ['slots']
9
+ const API = '/api/plugins/qq-stack'
10
+
11
+ async function api(method, path, body) {
12
+ try {
13
+ const r = await fetch(API + path, {
14
+ method,
15
+ headers: { 'content-type': 'application/json' },
16
+ ...(body ? { body: JSON.stringify(body) } : {}),
17
+ })
18
+ return await r.json()
19
+ } catch { return null }
20
+ }
21
+
22
+ const row = (label, ok, text) => h('div', { className: 'fs-row' },
23
+ h('span', null, label),
24
+ h('span', { className: 'fs-val' },
25
+ h('i', { className: ok ? 'fs-dot fs-on' : 'fs-dot fs-off' }),
26
+ text))
27
+
28
+ const CSS = `
29
+ .fs-wrap{display:flex;flex-direction:column;gap:12px}
30
+ .fs-card{background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.08));border-radius:14px;padding:12px}
31
+ .fs-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:13px}
32
+ .fs-val{display:flex;align-items:center;gap:6px;opacity:.9}
33
+ .fs-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
34
+ .fs-on{background:#39d98a}.fs-off{background:#ff5c5c}
35
+ .fs-center{display:flex;flex-direction:column;align-items:center;gap:8px}
36
+ .fs-qr{width:min(62vw,240px);height:auto;border-radius:12px;background:#fff;padding:8px}
37
+ .fs-hint{font-size:12px;opacity:.6}
38
+ .fs-input{width:100%;box-sizing:border-box;border:1px solid rgba(127,127,127,.28);background:transparent;color:inherit;border-radius:9px;padding:9px;font-size:14px}
39
+ .fs-btns{display:flex;gap:8px}
40
+ .fs-btn{flex:1;border:0;border-radius:9px;padding:10px;font-size:14px;background:#2f6fed;color:#fff}
41
+ .fs-ghost{background:rgba(127,127,127,.2);color:inherit}
42
+ .fs-account{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 12px}
43
+ .fs-avatar{width:72px;height:72px;border-radius:50%;background:rgba(127,127,127,.15);object-fit:cover}
44
+ .fs-name{font-size:16px;font-weight:600;margin-top:4px}
45
+ .fs-uin{font-size:12px;opacity:.55}
46
+ .fs-btns-col{flex-direction:column;width:100%;margin-top:14px}
47
+ .fs-primary{background:#12b7f5}
48
+ .fs-login{display:flex;flex-direction:column;gap:10px}
49
+ .fs-field{width:100%;box-sizing:border-box;border:0;border-bottom:1px solid rgba(127,127,127,.28);background:transparent;color:inherit;padding:10px 2px;font-size:15px;outline:none}
50
+ .fs-wide{padding:11px}
51
+ .fs-sep{text-align:center;font-size:12px;opacity:.5;margin-top:6px}`
52
+
53
+ function Panel() {
54
+ const [st, setSt] = react.useState(null)
55
+ const [busy, setBusy] = react.useState('')
56
+ const [err, setErr] = react.useState(false)
57
+
58
+ const load = async () => {
59
+ const d = await api('GET', '/status')
60
+ if (d?.ok) { setSt(d); setErr(false) } else setErr(true)
61
+ }
62
+ react.useEffect(() => { void load(); const t = setInterval(load, 5000); return () => clearInterval(t) }, [])
63
+
64
+ const post = async (path, body) => {
65
+ setBusy(path)
66
+ await api('POST', path, body)
67
+ setTimeout(() => { void load(); setBusy('') }, 8000)
68
+ }
69
+
70
+ const qrUrl = st?.qrAt ? `${API}/qr.png?t=${st.qrAt}` : `${API}/qr.png`
71
+ const logged = !!st?.loggedIn
72
+
73
+ return h('div', { className: 'fs-wrap' },
74
+ h('style', null, CSS),
75
+ err ? h('div', { className: 'fs-hint' }, '状态获取失败,稍后自动重试') : null,
76
+ h('div', { className: 'fs-card' },
77
+ row('QQ 进程', !!st?.qq, st?.qq ? '运行中' : '未运行'),
78
+ row('OneBot 端口', !!st?.qq, st?.qq ? '已监听' : '未监听'),
79
+ row('登录状态', logged, logged ? `已登录 ${st?.nickname || ''} ${st?.uin || ''}` : '未登录'),
80
+ ),
81
+ !logged ? h('div', { className: 'fs-card fs-center' },
82
+ st?.qrAt
83
+ ? h('img', { className: 'fs-qr', src: qrUrl, alt: '登录二维码' })
84
+ : h('div', { className: 'fs-hint' }, '正在生成二维码…'),
85
+ ) : null,
86
+ logged ? h('div', { className: 'fs-card fs-account' },
87
+ h('img', {
88
+ className: 'fs-avatar',
89
+ src: st?.uin ? `https://q1.qlogo.cn/g?b=sdk&nk=${st.uin}&s=640` : '',
90
+ alt: '',
91
+ }),
92
+ h('div', { className: 'fs-name' }, st?.nickname || '已登录'),
93
+ h('div', { className: 'fs-uin' }, st?.uin ? `QQ ${st.uin}` : ''),
94
+ h('div', { className: 'fs-btns fs-btns-col' },
95
+ h('button', { className: 'fs-btn fs-primary', onClick: () => post('/logout') },
96
+ busy === '/logout' ? '切换中…' : '切换账号'),
97
+ ),
98
+ ) : null,
99
+ )
100
+ }
101
+
102
+ function apply(ctx) {
103
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
104
+ name: 'settings.section',
105
+ id: 'qq-stack',
106
+ order: 61,
107
+ label: () => 'QQ 底座',
108
+ }, Panel))
109
+ }
110
+
111
+ exports.apply = apply;
112
+ exports.inject = inject;
113
+ return module.exports;
114
+ },
115
+ })
@@ -0,0 +1,4 @@
1
+ # dsh-qq-stack bundle patch: 挂载插件
2
+ - insert:
3
+ - id: dsh-qq-stack
4
+ name: dsh-qq-stack
package/index.js ADDED
@@ -0,0 +1,287 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs'
3
+ import { readFileSync as rf } from 'node:fs'
4
+
5
+ export const name = 'dsh-qq-stack'
6
+ export const inject = ['webServer']
7
+
8
+ const SELF = new URL('.', import.meta.url).pathname
9
+ const DEFAULTS = { root: '/root/qq-bot', uin: '', wsPort: 3001, token: '' }
10
+
11
+ function cfgDir(root) { return `${root}/opt/opt/QQ/resources/app/app_launcher/napcat/config` }
12
+
13
+ function ensureOnebot(root, port, token) {
14
+ let changed = false
15
+ let files = []
16
+ try { files = readdirSync(cfgDir(root)) } catch { return false }
17
+ for (const f of files) {
18
+ if (!/^onebot11_\d{5,12}\.json$/.test(f)) continue
19
+ const p = `${cfgDir(root)}/${f}`
20
+ let cfg
21
+ try { cfg = JSON.parse(readFileSync(p, 'utf8')) } catch { cfg = {} }
22
+ cfg.network = cfg.network || {}
23
+ const list = Array.isArray(cfg.network.websocketServers) ? cfg.network.websocketServers : []
24
+ if (list.some((x) => Number(x.port) === Number(port) && x.enable !== false)) continue
25
+ cfg.network.websocketServers = [{
26
+ name: 'dsh', enable: true, host: '127.0.0.1', port: Number(port),
27
+ messagePostFormat: 'array', reportSelfMessage: false, token,
28
+ debug: false, heartInterval: 30000, enableForcePushEvent: true,
29
+ }]
30
+ try { writeFileSync(p, JSON.stringify(cfg, null, 2)); changed = true } catch {}
31
+ }
32
+ return changed
33
+ }
34
+
35
+ function sessionLoginUin(root) {
36
+ let pidAt = 0
37
+ try { pidAt = statSync(`${root}/qq.pid`).mtimeMs } catch {}
38
+ let best = '', bestAt = 0
39
+ try {
40
+ for (const f of readdirSync(cfgDir(root))) {
41
+ const m = f.match(/^napcat_(\d{5,12})\.json$/)
42
+ if (!m) continue
43
+ const at = statSync(`${cfgDir(root)}/${f}`).mtimeMs
44
+ if (at >= pidAt && at > bestAt) { bestAt = at; best = m[1] }
45
+ }
46
+ } catch {}
47
+ return best
48
+ }
49
+
50
+ let lastHealAt = 0
51
+
52
+ function readEnvFile(root) {
53
+ const out = {}
54
+ try {
55
+ for (const line of readFileSync(`${root}/config.env`, 'utf8').split('\n')) {
56
+ const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/)
57
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '')
58
+ }
59
+ } catch {}
60
+ return out
61
+ }
62
+
63
+ function resolveConfig(config = {}) {
64
+ const root = config.root || process.env.DSH_QQ_ROOT || DEFAULTS.root
65
+ const file = readEnvFile(root)
66
+ const pick = (a, b, c) => (a !== undefined && a !== '' ? a : b !== undefined && b !== '' ? b : c)
67
+ return {
68
+ root,
69
+ uin: String(pick(config.uin, process.env.DSH_QQ_UIN, file.DSH_QQ_UIN ?? DEFAULTS.uin)),
70
+ wsPort: Number(pick(config.wsPort, process.env.DSH_QQ_WS_PORT, file.DSH_QQ_WS_PORT ?? DEFAULTS.wsPort)),
71
+ token: String(pick(config.token, process.env.DSH_QQ_TOKEN, file.DSH_QQ_TOKEN ?? DEFAULTS.token)),
72
+ autoInstall: config.autoInstall ?? true,
73
+ }
74
+ }
75
+
76
+ let probeCache = { at: 0, loggedIn: false, nickname: '', uin: '' }
77
+ async function probeLogin(port, token, force) {
78
+ if (!force && Date.now() - probeCache.at < 15000) return probeCache
79
+ const result = await new Promise((resolve) => {
80
+ let done = false
81
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
82
+ try {
83
+ const url = `ws://127.0.0.1:${port}/${token ? `?access_token=${encodeURIComponent(token)}` : ''}`
84
+ const ws = new WebSocket(url)
85
+ const timer = setTimeout(() => { try { ws.close() } catch {}; finish({ loggedIn: false, nickname: '' }) }, 3000)
86
+ ws.onopen = () => ws.send(JSON.stringify({ action: 'get_login_info', params: {}, echo: 'probe' }))
87
+ ws.onmessage = (e) => {
88
+ try {
89
+ const d = JSON.parse(e.data)
90
+ if (d.echo === 'probe') {
91
+ clearTimeout(timer)
92
+ const ok = d.retcode === 0 && d.data && d.data.user_id
93
+ try { ws.close() } catch {}
94
+ finish({ loggedIn: !!ok, nickname: ok ? String(d.data.nickname || '') : '', uin: ok ? String(d.data.user_id || '') : '' })
95
+ }
96
+ } catch {}
97
+ }
98
+ ws.onerror = () => { clearTimeout(timer); finish({ loggedIn: false, nickname: '' }) }
99
+ } catch { finish({ loggedIn: false, nickname: '' }) }
100
+ })
101
+ probeCache = { at: Date.now(), ...result }
102
+ return probeCache
103
+ }
104
+
105
+ function alive(pid) {
106
+ if (!pid) return false
107
+ try { process.kill(pid, 0); return true } catch { return false }
108
+ }
109
+
110
+ export function apply(ctx, pluginConfig = {}) {
111
+ try { return applyInner(ctx, pluginConfig) } catch (e) { try { ctx.logger?.warn?.(`[qq-stack] 激活失败:${e?.message ?? e}`) } catch {} }
112
+ }
113
+
114
+ function applyInner(ctx, pluginConfig = {}) {
115
+ const cfg = resolveConfig(pluginConfig)
116
+ const log = (msg) => { try { ctx.logger?.info?.(msg); if (process.env.DSH_QQ_DEBUG) console.log(msg) } catch {} }
117
+ log(`[qq-stack] 激活 root=${cfg.root} uin=${cfg.uin || '(空)'} autoInstall=${cfg.autoInstall}`)
118
+
119
+ const env = {
120
+ ...process.env,
121
+ DSH_QQ_ROOT: cfg.root,
122
+ DSH_QQ_UIN: cfg.uin,
123
+ DSH_QQ_WS_PORT: String(cfg.wsPort),
124
+ DSH_QQ_TOKEN: cfg.token,
125
+ }
126
+
127
+ const ensureStarted = () => {
128
+ try {
129
+ let pid = 0
130
+ try { pid = Number(readFileSync(`${cfg.root}/qq.pid`, 'utf8').trim()) || 0 } catch {}
131
+ if (alive(pid)) return
132
+ const script = `${cfg.root}/start-qq.sh`
133
+ if (!existsSync(script)) return
134
+ const child = spawn('bash', [script], { cwd: cfg.root, detached: true, stdio: 'ignore', env })
135
+ child.unref()
136
+ log('[qq-stack] 拉起 QQ')
137
+ } catch (e) { log(`[qq-stack] 启动失败:${e?.message ?? e}`) }
138
+ }
139
+
140
+ const ensureInstalled = () => {
141
+ if (existsSync(`${cfg.root}/opt/opt/QQ/qq`)) return ensureStarted()
142
+ if (!cfg.autoInstall) { log('[qq-stack] 未安装且 autoInstall=false,跳过'); return }
143
+ const installer = existsSync(`${cfg.root}/install.sh`) ? `${cfg.root}/install.sh` : `${SELF}install.sh`
144
+ if (!existsSync(installer)) { log('[qq-stack] 找不到 install.sh'); return }
145
+ log('[qq-stack] 首次安装开始(LinuxQQ + NapCat,约 250MB)')
146
+ try {
147
+ const child = spawn('bash', [installer], { cwd: cfg.root, detached: true, stdio: 'ignore', env })
148
+ child.unref()
149
+ } catch (e) { log(`[qq-stack] 安装启动失败:${e?.message ?? e}`) }
150
+ }
151
+
152
+ ctx.effect(() => {
153
+ const timer = setInterval(async () => {
154
+ try {
155
+ const envFile = readEnvFile(cfg.root)
156
+ const port = Number(envFile.DSH_QQ_WS_PORT || cfg.wsPort || 3001)
157
+ const token = envFile.DSH_QQ_TOKEN || cfg.token
158
+ const login = await probeLogin(port, token, true)
159
+ if (login.loggedIn && login.uin && envFile.DSH_QQ_UIN !== login.uin) {
160
+ const next = { ...envFile, DSH_QQ_UIN: login.uin }
161
+ writeFileSync(`${cfg.root}/config.env`, Object.entries(next).map(([k, x]) => `${k}=${x}`).join('\n') + '\n')
162
+ log(`[qq-stack] 记住快登号 ${login.uin}`)
163
+ }
164
+ const healed = ensureOnebot(cfg.root, port, token)
165
+ if (healed && Date.now() - lastHealAt > 90000) {
166
+ lastHealAt = Date.now()
167
+ log('[qq-stack] 补齐 OneBot 配置,重启 QQ')
168
+ try { spawn('bash', ['-c', 'bash stop-qq.sh; bash start-qq.sh'], { cwd: cfg.root, detached: true, stdio: 'ignore' }).unref() } catch {}
169
+ }
170
+ } catch (e) { log(`[qq-stack] 巡检失败:${e?.message ?? e}`) }
171
+ }, 60000)
172
+ return () => clearInterval(timer)
173
+ }, 'dsh-qq-stack: config-check')
174
+
175
+ ctx.effect(() => {
176
+ const t = setTimeout(() => { try { ensureInstalled() } catch (e) { log(`[qq-stack] ${e?.message ?? e}`) } }, 8000)
177
+ return () => clearTimeout(t)
178
+ }, 'dsh-qq-stack: autostart')
179
+
180
+ try {
181
+ const API = '/api/plugins/qq-stack'
182
+ const handler = async (req, res) => {
183
+ try {
184
+ const url = new URL(req.url, 'http://127.0.0.1')
185
+ const p = url.pathname.replace(API, '') || '/'
186
+ const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)) }
187
+ if (req.method === 'GET' && (p === '/status' || p === '/')) {
188
+ const envFile = readEnvFile(cfg.root)
189
+ let qq = false
190
+ try { qq = alive(Number(readFileSync(`${cfg.root}/qq.pid`, 'utf8').trim())) } catch {}
191
+ let detectedUin = ''
192
+ try {
193
+ const dir = `${cfg.root}/opt/opt/QQ/resources/app/app_launcher/napcat/config`
194
+ for (const f of readdirSync(dir)) {
195
+ const m = f.match(/^onebot11_(\d{5,12})\.json$/)
196
+ if (m) { detectedUin = m[1]; break }
197
+ }
198
+ } catch {}
199
+ const qr = `${cfg.root}/opt/opt/QQ/resources/app/app_launcher/napcat/cache/qrcode.png`
200
+ let qrAt = 0
201
+ try {
202
+ const qrM = statSync(qr).mtimeMs
203
+ const pidM = statSync(`${cfg.root}/qq.pid`).mtimeMs
204
+ if (qrM >= pidM) qrAt = Math.floor(qrM)
205
+ } catch {}
206
+ const login = qq ? await probeLogin(Number(envFile.DSH_QQ_WS_PORT || cfg.wsPort || 3001), envFile.DSH_QQ_TOKEN || cfg.token) : { loggedIn: false, nickname: '' }
207
+ if (login.loggedIn) {
208
+ const qqUin = detectedUin || envFile.DSH_QQ_UIN || ''
209
+ if (qqUin && envFile.DSH_QQ_UIN !== qqUin) {
210
+ const env2 = { ...envFile, DSH_QQ_UIN: qqUin }
211
+ writeFileSync(`${cfg.root}/config.env`, Object.entries(env2).map(([k, x]) => `${k}=${x}`).join('\n') + '\n')
212
+ }
213
+ }
214
+ const portNum = Number(envFile.DSH_QQ_WS_PORT || cfg.wsPort || 3001)
215
+ const tokenVal = envFile.DSH_QQ_TOKEN || cfg.token
216
+ const healed = ensureOnebot(cfg.root, portNum, tokenVal)
217
+ if (healed && !login.loggedIn && Date.now() - lastHealAt > 90000) {
218
+ lastHealAt = Date.now()
219
+ try { spawn('bash', ['-c', `bash stop-qq.sh; bash start-qq.sh`], { cwd: cfg.root, detached: true, stdio: 'ignore' }).unref() } catch {}
220
+ }
221
+ const probeFresh = Date.now() - (probeCache.at || 0) < 6000
222
+ if (login.loggedIn && login.uin && probeFresh && envFile.DSH_QQ_UIN !== login.uin) {
223
+ try {
224
+ const e2 = { ...envFile, DSH_QQ_UIN: login.uin }
225
+ writeFileSync(`${cfg.root}/config.env`, Object.entries(e2).map(([k, x]) => `${k}=${x}`).join('\n') + '\n')
226
+ } catch {}
227
+ }
228
+ return json({ ok: true, qq, loggedIn: !!login.loggedIn, nickname: login.nickname, uin: login.loggedIn ? login.uin : (envFile.DSH_QQ_UIN || detectedUin), port: envFile.DSH_QQ_WS_PORT || '3001', token: envFile.DSH_QQ_TOKEN || '', qrAt })
229
+ }
230
+ if (req.method === 'GET' && p === '/qr.png') {
231
+ let detectedUin = ''
232
+ try {
233
+ const dir = `${cfg.root}/opt/opt/QQ/resources/app/app_launcher/napcat/config`
234
+ for (const f of readdirSync(dir)) {
235
+ const m = f.match(/^onebot11_(\d{5,12})\.json$/)
236
+ if (m) { detectedUin = m[1]; break }
237
+ }
238
+ } catch {}
239
+ const qr = `${cfg.root}/opt/opt/QQ/resources/app/app_launcher/napcat/cache/qrcode.png`
240
+ if (!existsSync(qr)) { res.writeHead(404); return res.end() }
241
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' })
242
+ return res.end(rf(qr))
243
+ }
244
+ if (req.method === 'POST' && p === '/save') {
245
+ let body = ''
246
+ req.on('data', (c) => { body += c })
247
+ req.on('end', () => {
248
+ try {
249
+ const v = JSON.parse(body || '{}')
250
+ const allow = {}
251
+ for (const k of ['DSH_QQ_UIN', 'DSH_QQ_WS_PORT', 'DSH_QQ_TOKEN']) {
252
+ if (typeof v[k] === 'string') allow[k] = v[k].replace(/[\r\n]/g, '')
253
+ }
254
+ const cur = readEnvFile(cfg.root)
255
+ const next = { ...cur, ...allow }
256
+ writeFileSync(`${cfg.root}/config.env`, Object.entries(next).map(([k, x]) => `${k}=${x}`).join('\n') + '\n')
257
+ return json({ ok: true })
258
+ } catch (e) { return json({ ok: false, error: String(e?.message ?? e) }, 400) }
259
+ })
260
+ return
261
+ }
262
+ if (req.method === 'POST' && p === '/logout') {
263
+ try {
264
+ const cur = readEnvFile(cfg.root)
265
+ const next = { ...cur, DSH_QQ_UIN: '' }
266
+ delete next.DSH_QQ_PASSWORD_MD5
267
+ writeFileSync(`${cfg.root}/config.env`, Object.entries(next).map(([k, x]) => `${k}=${x}`).join('\n') + '\n')
268
+ } catch {}
269
+ try {
270
+ const auth = `${cfg.root}/data/qq-home/.config/QQ/auth/login.enc`
271
+ if (existsSync(auth)) unlinkSync(auth)
272
+ } catch {}
273
+ probeCache = { at: 0, loggedIn: false, nickname: '', uin: '' }
274
+ try { spawn('bash', ['-c', 'bash stop-qq.sh; bash start-qq.sh'], { cwd: cfg.root, detached: true, stdio: 'ignore' }).unref() } catch {}
275
+ return json({ ok: true })
276
+ }
277
+ if (req.method === 'POST' && p === '/restart') {
278
+ try { spawn('bash', ['-c', 'bash stop-qq.sh; bash start-qq.sh'], { cwd: cfg.root, detached: true, stdio: 'ignore' }).unref() } catch {}
279
+ return json({ ok: true })
280
+ }
281
+ json({ ok: false }, 404)
282
+ } catch (e) { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: String(e && e.message || e), stack: String(e && e.stack || '').split('\n').slice(0,3) })) } catch {} }
283
+ }
284
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: API, handler }), 'dsh-qq-stack: api')
285
+ } catch (e) { log(`[qq-stack] 接口注册失败:${e?.message ?? e}`) }
286
+
287
+ }
package/install.sh ADDED
@@ -0,0 +1,163 @@
1
+ #!/bin/bash
2
+ set -u
3
+ ROOT="${DSH_QQ_ROOT:-/root/qq-bot}"
4
+ mkdir -p "$ROOT"/{logs,opt,data/qq-home/.config,data/qq-home/.local/share,data/qq-home/.cache,data/runtime}
5
+ cd "$ROOT" || exit 1
6
+
7
+ if [ -f "$ROOT/config.env" ]; then
8
+ set -a; . "$ROOT/config.env"; set +a
9
+ fi
10
+ UIN="${DSH_QQ_UIN:-}"
11
+ WS_PORT="${DSH_QQ_WS_PORT:-3001}"
12
+ TOKEN="${DSH_QQ_TOKEN:-dshfish-3001}"
13
+
14
+ if [ ! -f "$ROOT/config.env" ]; then
15
+ cat > "$ROOT/config.env" <<EOF
16
+ DSH_QQ_UIN=$UIN
17
+ DSH_QQ_WS_PORT=$WS_PORT
18
+ DSH_QQ_TOKEN=$TOKEN
19
+ EOF
20
+ fi
21
+
22
+ say() { echo "[install $(date +%H:%M:%S)] $*"; }
23
+
24
+ ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m)"
25
+ case "$ARCH" in
26
+ arm64|aarch64) QQ_ARCH=arm64 ;;
27
+ amd64|x86_64) QQ_ARCH=amd64 ;;
28
+ *) say "不支持的架构:$ARCH"; exit 1 ;;
29
+ esac
30
+ QQ_VER="3.2.34-53644"
31
+ QQ_DEB_URL="https://qqdl.gtimg.cn/qqfile/QQNT/9.9.36/beta/9ee04bef/linuxqq_${QQ_VER}_${QQ_ARCH}.deb"
32
+ NAPCAT_ZIP="https://github.com/NapNeko/NapCatQQ/releases/latest/download/NapCat.Shell.zip"
33
+
34
+ missing=""
35
+ for p in xvfb libnss3 libgbm1 libasound2t64 libgtk-3-0 dbus-x11 unzip curl; do
36
+ dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"
37
+ done
38
+ if [ -n "$missing" ]; then
39
+ say "安装依赖:$missing"
40
+ export DEBIAN_FRONTEND=noninteractive
41
+ apt-get update -qq && apt-get install -y $missing || { say "依赖安装失败"; exit 1; }
42
+ fi
43
+
44
+ if [ ! -x "$ROOT/opt/opt/QQ/qq" ]; then
45
+ say "下载 LinuxQQ ${QQ_ARCH}(约 200MB)"
46
+ curl -fL --retry 3 -o /tmp/QQ.deb "$QQ_DEB_URL" || { say "LinuxQQ 下载失败"; exit 1; }
47
+ dpkg -x /tmp/QQ.deb "$ROOT/opt" || { say "解包失败"; exit 1; }
48
+ rm -f /tmp/QQ.deb
49
+ else
50
+ say "LinuxQQ 已存在,跳过"
51
+ fi
52
+
53
+ QQ_APP="$ROOT/opt/opt/QQ/resources/app"
54
+
55
+ if [ ! -f "$QQ_APP/app_launcher/napcat/napcat.mjs" ]; then
56
+ say "下载 NapCat"
57
+ if [ -f /tmp/NapCat.Shell.zip ] && unzip -t /tmp/NapCat.Shell.zip >/dev/null 2>&1; then
58
+ say "复用已下载的 NapCat 包"
59
+ else
60
+ curl -fL --retry 2 -o /tmp/NapCat.Shell.zip "$NAPCAT_ZIP" \
61
+ || curl -fL --retry 2 -o /tmp/NapCat.Shell.zip "https://gh-proxy.com/$NAPCAT_ZIP" \
62
+ || { say "NapCat 下载失败"; exit 1; }
63
+ fi
64
+ rm -rf /tmp/napcat-unzip && mkdir -p /tmp/napcat-unzip
65
+ unzip -q -o /tmp/NapCat.Shell.zip -d /tmp/napcat-unzip || { say "NapCat 解压失败"; exit 1; }
66
+ mkdir -p "$QQ_APP/app_launcher"
67
+ cp -a /tmp/napcat-unzip "$QQ_APP/app_launcher/napcat"
68
+ rm -rf /tmp/napcat-unzip /tmp/NapCat.Shell.zip
69
+ else
70
+ say "NapCat 已存在,跳过"
71
+ fi
72
+
73
+ cat > "$QQ_APP/loadNapCat.js" <<'EOF'
74
+ const path = require('node:path');
75
+ const { pathToFileURL } = require('node:url');
76
+ import(pathToFileURL(path.join(__dirname, 'app_launcher/napcat/napcat.mjs')).href);
77
+ EOF
78
+ node -e '
79
+ const fs=require("fs");const p=process.argv[1];
80
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
81
+ if(j.main!=="./loadNapCat.js"){j.main="./loadNapCat.js";fs.writeFileSync(p,JSON.stringify(j,null,2));}
82
+ ' "$QQ_APP/package.json" || { say "补丁失败"; exit 1; }
83
+
84
+ CFG_DIR="$QQ_APP/app_launcher/napcat/config"
85
+ mkdir -p "$CFG_DIR"
86
+ if ! ls "$CFG_DIR"/onebot11_*.json >/dev/null 2>&1; then
87
+ say "写入 OneBot 配置(ws ${WS_PORT})"
88
+ cat > "$CFG_DIR/onebot11_${UIN:-default}.json" <<EOF
89
+ {
90
+ "network": {
91
+ "httpServers": [],
92
+ "httpSseServers": [],
93
+ "httpClients": [],
94
+ "websocketServers": [
95
+ {
96
+ "name": "dsh",
97
+ "enable": true,
98
+ "host": "127.0.0.1",
99
+ "port": $WS_PORT,
100
+ "messagePostFormat": "array",
101
+ "reportSelfMessage": false,
102
+ "token": "$TOKEN",
103
+ "debug": false,
104
+ "heartInterval": 30000,
105
+ "enableForcePushEvent": true
106
+ }
107
+ ],
108
+ "websocketClients": [],
109
+ "plugins": []
110
+ },
111
+ "musicSignUrl": "",
112
+ "enableLocalFile2Url": false,
113
+ "parseMultMsg": false,
114
+ "imageDownloadProxy": ""
115
+ }
116
+ EOF
117
+ fi
118
+
119
+ if [ ! -f "$ROOT/start-qq.sh" ]; then
120
+ cat > "$ROOT/start-qq.sh" <<EOF
121
+ #!/bin/bash
122
+ cd "\$(dirname "\$0")" || exit 1
123
+ [ -f config.env ] && { set -a; . ./config.env; set +a; }
124
+ mkdir -p logs data/qq-home/.config data/qq-home/.local/share data/qq-home/.cache data/runtime
125
+ chmod 700 data/runtime
126
+ export HOME="$ROOT/data/qq-home"
127
+ export XDG_CONFIG_HOME="\$HOME/.config" XDG_DATA_HOME="\$HOME/.local/share" XDG_CACHE_HOME="\$HOME/.cache"
128
+ export XDG_RUNTIME_DIR="$ROOT/data/runtime"
129
+ export ELECTRON_DISABLE_SANDBOX=1
130
+ [ -n "\${DSH_QQ_UIN:-}" ] && export NAPCAT_QUICK_ACCOUNT="\$DSH_QQ_UIN"
131
+ if [ -f qq.pid ] && kill -0 "\$(cat qq.pid)" 2>/dev/null; then echo "QQ 已在运行"; exit 0; fi
132
+ cd opt/opt/QQ
133
+ setsid dbus-run-session -- xvfb-run -a -s "-screen 0 1280x800x24" ./qq \\
134
+ --no-sandbox --disable-gpu --disable-gpu-sandbox --in-process-gpu \\
135
+ --disable-dev-shm-usage --disable-gpu-compositing > ../../../logs/qq.log 2>&1 &
136
+ echo \$! > ../../../qq.pid
137
+ echo "QQ 启动中 (pid \$!)"
138
+ EOF
139
+ fi
140
+
141
+ if [ ! -f "$ROOT/stop-qq.sh" ]; then
142
+ cat > "$ROOT/stop-qq.sh" <<'EOF'
143
+ #!/bin/bash
144
+ cd "$(dirname "$0")" || exit 1
145
+ pid=$(cat qq.pid 2>/dev/null)
146
+ if [ -n "$pid" ]; then
147
+ pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
148
+ [ -n "$pgid" ] && kill -TERM -"$pgid" 2>/dev/null
149
+ for i in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
150
+ [ -n "$pgid" ] && kill -KILL -"$pgid" 2>/dev/null
151
+ kill -KILL "$pid" 2>/dev/null
152
+ fi
153
+ for p in $(ps -eo pid=,comm= 2>/dev/null | awk '$2=="qq"||$2=="Xvfb"{print $1}'); do
154
+ kill -KILL "$p" 2>/dev/null
155
+ done
156
+ rm -f qq.pid
157
+ EOF
158
+ fi
159
+
160
+ chmod +x "$ROOT"/*.sh
161
+ say "安装完成,启动 QQ"
162
+ bash "$ROOT/start-qq.sh"
163
+ say "完成(首次需要扫码,之后启动会走 ${UIN:-未设置账号} 的快速登录)"
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "dsh-qq-stack",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "description": "DeepSeek Harness 的 QQ 协议端底座:LinuxQQ + NapCat 随引擎自启,扫码一次后自动快登。",
7
+ "keywords": [
8
+ "dsh-plugin",
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "qq",
12
+ "napcat",
13
+ "onebot"
14
+ ],
15
+ "license": "MIT",
16
+ "engines": {
17
+ "node": ">=22.5.0"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "client.js",
22
+ "install.sh",
23
+ "cordis.patch.yml",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ ".": "./index.js",
29
+ "./client": "./client.js",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": [
40
+ "slots"
41
+ ]
42
+ }
43
+ },
44
+ "scripts": {
45
+ "test": "node --input-type=module -e \"await import('./index.js')\""
46
+ }
47
+ }