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 +21 -0
- package/README.md +55 -2
- package/client.js +115 -0
- package/cordis.patch.yml +4 -0
- package/index.js +287 -0
- package/install.sh +163 -0
- package/package.json +45 -4
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
|
-
#
|
|
1
|
+
# dsh-qq-stack
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
})
|
package/cordis.patch.yml
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|