dsh-qq-digest 0.1.0 → 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/README.md +8 -2
- package/package.json +2 -1
- package/scripts/check-all.mjs +169 -0
- package/scripts/check-core-purity.mjs +70 -0
- package/scripts/host-smoke.mjs +186 -0
- package/scripts/install.mjs +123 -0
- package/scripts/link-profile.mjs +96 -0
- package/scripts/smoke.mjs +115 -0
- package/scripts/trigger.mjs +67 -0
- package/scripts/verify-dsh.mjs +110 -0
package/README.md
CHANGED
|
@@ -148,9 +148,10 @@ core 纯净性检查锁定:`src/core/` 不许 import `node:fs` / 子进程 /
|
|
|
148
148
|
|
|
149
149
|
## 安装进 DSH
|
|
150
150
|
|
|
151
|
-
这一步分两块,互不冲突(row id 不同):
|
|
152
|
-
|
|
153
151
|
```bash
|
|
152
|
+
# 0) (可选)从 npm 拿包,或直接把本仓库放到任意目录
|
|
153
|
+
npm i dsh-qq-digest
|
|
154
|
+
|
|
154
155
|
# 1) MCP 行:给模型用的四个只读工具
|
|
155
156
|
node scripts/install.mjs --apply
|
|
156
157
|
|
|
@@ -158,6 +159,11 @@ node scripts/install.mjs --apply
|
|
|
158
159
|
node scripts/link-profile.mjs --apply
|
|
159
160
|
```
|
|
160
161
|
|
|
162
|
+
第 1 步还会把本机 qq-bridge 的位置写进 `<DSH_HOME>/qq-digest/bridge-root.txt`
|
|
163
|
+
(源码里不硬编码任何绝对路径)。如果没自动找到,用 `--bridge-root=D:/path/to/qq-bridge` 指定。
|
|
164
|
+
|
|
165
|
+
**包内不含任何作者的个人信息**:QQ 号、群号、用户目录都是脱敏后的假值或运行时解析。
|
|
166
|
+
|
|
161
167
|
第 2 步**不走 pnpm**:本机 profile 的 `node_modules` 里有文件被占用
|
|
162
168
|
(`@img/sharp-win32-x64` 会在 pnpm 重装时报 `ERR_PNPM_EPERM`),所以这里用
|
|
163
169
|
junction + 手改 profile `package.json`(会自动备份),和本机 `dsh-instruction-memory` 的装法一致。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qq-digest",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "只读的 DSH QQ 摘要插件:群聊默认全开、私聊默认全关且只能逐条打开;MCP 四个只读工具 + 设置页(选群/起名/定时/立即总结),零发送能力",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"files": [
|
|
52
52
|
"lib",
|
|
53
53
|
"src",
|
|
54
|
+
"scripts",
|
|
54
55
|
"install",
|
|
55
56
|
"cordis.patch.yml",
|
|
56
57
|
"README.md",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 全量自检:把「插件在 DSH 里到底通不通」一次跑完。
|
|
4
|
+
*
|
|
5
|
+
* 只做只读探测 + 三个必然失败的写入(用来验证拒绝路径),不改动你的真实配置。
|
|
6
|
+
*
|
|
7
|
+
* 用法:node scripts/check-all.mjs [端口]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
|
+
const PORTS = process.argv[2] ? [Number(process.argv[2])] : [4845, 8784, 1402, 14802];
|
|
17
|
+
const DATA_DIR = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'qq-digest');
|
|
18
|
+
|
|
19
|
+
let failures = 0;
|
|
20
|
+
const ok = (label, detail) => console.log(`PASS ${label}${detail ? ` ${detail}` : ''}`);
|
|
21
|
+
const bad = (label, detail) => { console.log(`FAIL ${label}${detail ? ` ${detail}` : ''}`); failures += 1; };
|
|
22
|
+
const info = (label, detail) => console.log(`INFO ${label}${detail ? ` ${detail}` : ''}`);
|
|
23
|
+
|
|
24
|
+
// ── 1. 静态:源码完整性 ───────────────────────────────────────────────────
|
|
25
|
+
for (const file of ['lib/index.js', 'lib/client.js', 'src/config.js', 'src/runtime.js', 'src/service.js', 'package.json', 'cordis.patch.yml']) {
|
|
26
|
+
if (existsSync(join(root, file))) ok(`文件存在 ${file}`);
|
|
27
|
+
else bad(`缺文件 ${file}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
31
|
+
if (manifest.dsh?.bundle?.patch) ok('manifest: dsh.bundle.patch', manifest.dsh.bundle.patch);
|
|
32
|
+
else bad('manifest 缺 dsh.bundle.patch');
|
|
33
|
+
if (manifest.dsh?.client?.platform === 'web') ok('manifest: dsh.client.platform', 'web');
|
|
34
|
+
else bad('manifest 缺 dsh.client.platform');
|
|
35
|
+
if (Array.isArray(manifest.dsh?.client?.inject) && manifest.dsh.client.inject.length > 0) {
|
|
36
|
+
ok('manifest: dsh.client.inject', manifest.dsh.client.inject.length + ' 个依赖');
|
|
37
|
+
} else bad('manifest 缺 dsh.client.inject');
|
|
38
|
+
|
|
39
|
+
const patch = readFileSync(join(root, 'cordis.patch.yml'), 'utf8');
|
|
40
|
+
if (/-\s*insert:/.test(patch) && /name:\s*dsh-qq-digest/.test(patch)) ok('bundle patch 形态正确');
|
|
41
|
+
else bad('bundle patch 形态不对');
|
|
42
|
+
|
|
43
|
+
// ── 2. profile 接线 ──────────────────────────────────────────────────────
|
|
44
|
+
const profileDir = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'profiles', 'desktop');
|
|
45
|
+
try {
|
|
46
|
+
const profilePkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
|
|
47
|
+
if (profilePkg.dsh?.profile?.bundles?.includes('dsh-qq-digest')) ok('profile 已登记 bundle');
|
|
48
|
+
else bad('profile 未登记 bundle');
|
|
49
|
+
if (profilePkg.dependencies?.['dsh-qq-digest']) ok('profile 已登记依赖', profilePkg.dependencies['dsh-qq-digest']);
|
|
50
|
+
else bad('profile 未登记依赖');
|
|
51
|
+
|
|
52
|
+
const linkPath = join(profileDir, 'node_modules', 'dsh-qq-digest');
|
|
53
|
+
if (existsSync(linkPath)) ok('junction 存在');
|
|
54
|
+
else bad('junction 不存在');
|
|
55
|
+
} catch (err) {
|
|
56
|
+
bad('读不到 profile', String(err.message));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 3. 宿主半在线与状态形状 ──────────────────────────────────────────────
|
|
60
|
+
let base = null;
|
|
61
|
+
for (const port of PORTS) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`http://127.0.0.1:${port}/qq-digest-api/state`, {
|
|
64
|
+
headers: { accept: 'application/json' },
|
|
65
|
+
signal: AbortSignal.timeout(6000),
|
|
66
|
+
});
|
|
67
|
+
if (res.status === 200) { base = `http://127.0.0.1:${port}`; break; }
|
|
68
|
+
} catch { /* 下一个端口 */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (base === null) {
|
|
72
|
+
bad('宿主半不在线(DSH 没跑,或 bundle 没加载)');
|
|
73
|
+
} else {
|
|
74
|
+
ok('宿主半在线', base);
|
|
75
|
+
const state = await (await fetch(`${base}/qq-digest-api/state`, { headers: { accept: 'application/json' } })).json();
|
|
76
|
+
const newHost = typeof state.groupsEnabled === 'boolean';
|
|
77
|
+
|
|
78
|
+
if (typeof state.groupsEnabled === 'boolean') ok('状态含群聊总开关', String(state.groupsEnabled));
|
|
79
|
+
else bad('状态缺 groupsEnabled —— 宿主半还是旧版本,需要重启');
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(state.privateRooms)) {
|
|
82
|
+
ok('状态含私聊列表', `${state.privateRooms.length} 条,打开 ${state.privateRooms.filter((r) => r.enabled).length} 条`);
|
|
83
|
+
const named = state.privateRooms.filter((r) => r.named);
|
|
84
|
+
info('已命名的私聊', named.length ? named.map((r) => `${r.qq}→${r.name}`).join(', ') : '(无)');
|
|
85
|
+
} else bad('状态缺 privateRooms —— 宿主半还是旧版本');
|
|
86
|
+
|
|
87
|
+
info('群列表', (state.rooms ?? []).map((r) => `${r.title ?? r.groupId}${r.enabled ? '' : '(关)'}`).join(' / ') || '(空)');
|
|
88
|
+
info('摘要器', state.summarizer);
|
|
89
|
+
info('定时', JSON.stringify(state.schedule));
|
|
90
|
+
info('客户端心跳', JSON.stringify(state.clientBoot ?? null));
|
|
91
|
+
|
|
92
|
+
if (state.clientBoot === undefined) {
|
|
93
|
+
info('clientBoot', '旧版宿主没有这个字段(重启后才有),不代表客户端有问题');
|
|
94
|
+
} else if (state.clientBoot?.mountedAt) {
|
|
95
|
+
ok('客户端半已注册设置页');
|
|
96
|
+
} else if (state.clientBoot?.factoryAt) {
|
|
97
|
+
bad('客户端半执行了但没注册上', state.clientBoot.lastError ?? state.clientBoot.lastPhase);
|
|
98
|
+
} else {
|
|
99
|
+
bad('客户端半没有任何心跳(bundle 没执行)');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (state.policyDegraded) bad('策略降级(配置损坏 → 全关)', state.configError);
|
|
103
|
+
else ok('策略正常(未降级)');
|
|
104
|
+
|
|
105
|
+
if (state.adapterError) bad('数据源报错', state.adapterError.message);
|
|
106
|
+
else ok('数据源无报错');
|
|
107
|
+
|
|
108
|
+
// 拒绝路径:这三个都必须在落盘前被拒
|
|
109
|
+
const post = async (path, body) => {
|
|
110
|
+
const res = await fetch(`${base}${path}`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const badGroup = await post('/qq-digest-api/config', { groups: { 'private:88880001': { enabled: true } } });
|
|
119
|
+
if (badGroup.status === 400) ok('群列表里塞私聊 id → 400');
|
|
120
|
+
else bad('群列表里塞私聊 id 没被拒', badGroup.status);
|
|
121
|
+
|
|
122
|
+
const badTime = await post('/qq-digest-api/config', { schedule: { enabled: true, kind: 'daily', time: '25:99', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } });
|
|
123
|
+
if (badTime.status === 400) ok('非法定时时间 → 400');
|
|
124
|
+
else bad('非法定时时间没被拒', badTime.status);
|
|
125
|
+
|
|
126
|
+
const badPrivate = newHost
|
|
127
|
+
? await post('/qq-digest-api/config', { private: { abc: { enabled: true } } })
|
|
128
|
+
: null;
|
|
129
|
+
if (badPrivate === null) info('跳过私聊键校验', '宿主半是旧版本,还没有 private 字段');
|
|
130
|
+
else if (badPrivate.status === 400) ok('私聊键不是 QQ 号 → 400');
|
|
131
|
+
else bad('非法私聊键没被拒', badPrivate.status);
|
|
132
|
+
|
|
133
|
+
const badDigest = await fetch(`${base}/qq-digest-api/digest?group=private:1`);
|
|
134
|
+
if (badDigest.status === 400) ok('按私聊取摘要 → 400');
|
|
135
|
+
else bad('按私聊取摘要没被拒', badDigest.status);
|
|
136
|
+
|
|
137
|
+
const notFound = await fetch(`${base}/qq-digest-api/nope`);
|
|
138
|
+
if (notFound.status === 404) ok('未知路径 → 404');
|
|
139
|
+
else bad('未知路径返回异常', notFound.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 4. 落盘 ──────────────────────────────────────────────────────────────
|
|
143
|
+
for (const [name, label] of [['config.json', '配置'], ['digests.jsonl', '摘要'], ['watermarks.json', '水位线'], ['client-boot.json', '客户端心跳']]) {
|
|
144
|
+
const file = join(DATA_DIR, name);
|
|
145
|
+
if (!existsSync(file)) { info(`${label}文件`, `${name} 不存在`); continue; }
|
|
146
|
+
const size = statSync(file).size;
|
|
147
|
+
if (name.endsWith('.jsonl')) {
|
|
148
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean).length;
|
|
149
|
+
info(`${label}文件`, `${name} ${lines} 行 / ${size}B`);
|
|
150
|
+
} else {
|
|
151
|
+
info(`${label}文件`, `${name} ${size}B`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (existsSync(join(DATA_DIR, 'config.json'))) {
|
|
156
|
+
const cfg = JSON.parse(readFileSync(join(DATA_DIR, 'config.json'), 'utf8'));
|
|
157
|
+
info('配置内容', JSON.stringify({
|
|
158
|
+
groupsEnabled: cfg.groupsEnabled,
|
|
159
|
+
groups: Object.keys(cfg.groups ?? {}).length,
|
|
160
|
+
private: Object.entries(cfg.private ?? {}).filter(([, v]) => v.enabled).length + ' 条打开',
|
|
161
|
+
summarizer: cfg.summarizer,
|
|
162
|
+
}));
|
|
163
|
+
if (cfg.private && Object.values(cfg.private).some((v) => v.enabled === true)) {
|
|
164
|
+
info('注意', '有私聊处于打开状态(这是你显式设的,不是默认)');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
|
169
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* core 层静态检查:纯逻辑层不许碰网络、子进程、文件系统写入,
|
|
4
|
+
* 也不许出现任何「发送」符号。
|
|
5
|
+
*
|
|
6
|
+
* 这不是风格检查,是安全边界检查:
|
|
7
|
+
* core 是唯一做裁决的地方,它如果能触网或写盘,
|
|
8
|
+
* 「只读」就从机制退化成一句承诺。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { validateToolSurface, TOOLS } from '../src/core/toolSurface.js';
|
|
16
|
+
|
|
17
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
const coreDir = join(root, 'src', 'core');
|
|
19
|
+
|
|
20
|
+
const FORBIDDEN_IMPORTS = [
|
|
21
|
+
"node:fs",
|
|
22
|
+
"node:child_process",
|
|
23
|
+
"node:net",
|
|
24
|
+
"node:http",
|
|
25
|
+
"node:https",
|
|
26
|
+
"node:dns",
|
|
27
|
+
"node:tls",
|
|
28
|
+
"node:worker_threads",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const FORBIDDEN_TOKENS = [
|
|
32
|
+
/\bfetch\s*\(/,
|
|
33
|
+
/\bsend_message\b/,
|
|
34
|
+
/\bsend_group_msg\b/,
|
|
35
|
+
/\bsend_private_msg\b/,
|
|
36
|
+
/\bsendGroup\s*\(/,
|
|
37
|
+
/\bsendPrivate\s*\(/,
|
|
38
|
+
/\bmark_read\b/,
|
|
39
|
+
/\/api\/send/,
|
|
40
|
+
/\bwriteFileSync\b/,
|
|
41
|
+
/\bappendFileSync\b/,
|
|
42
|
+
/\bexecSync\b/,
|
|
43
|
+
/\bspawnSync\b/,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const violations = [];
|
|
47
|
+
|
|
48
|
+
const files = readdirSync(coreDir).filter((name) => name.endsWith('.js'));
|
|
49
|
+
for (const name of files) {
|
|
50
|
+
const text = readFileSync(join(coreDir, name), 'utf8');
|
|
51
|
+
for (const specifier of FORBIDDEN_IMPORTS) {
|
|
52
|
+
if (text.includes(`'${specifier}'`) || text.includes(`"${specifier}"`)) {
|
|
53
|
+
violations.push(`${name}: 不许 import ${specifier}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const pattern of FORBIDDEN_TOKENS) {
|
|
57
|
+
if (pattern.test(text)) violations.push(`${name}: 命中禁止符号 ${pattern}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const surface = validateToolSurface(TOOLS);
|
|
62
|
+
for (const violation of surface.violations) violations.push(`toolSurface: ${violation}`);
|
|
63
|
+
|
|
64
|
+
if (violations.length > 0) {
|
|
65
|
+
console.error('core 纯净性检查失败:');
|
|
66
|
+
for (const violation of violations) console.error(` - ${violation}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`core 纯净性检查通过:${files.length} 个文件,0 违规;工具面 ${TOOLS.length} 个工具,0 违规。`);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 宿主半冒烟:不启动 DSH,直接用一个假 ctx 把 apply() 跑起来,
|
|
4
|
+
* 再对着它注册的 HTTP 路由发真实的 req/res 对象。
|
|
5
|
+
*
|
|
6
|
+
* 这能提前抓到「路由没注册 / 路径写错 / JSON 形状不对 / 私聊没被拒」,
|
|
7
|
+
* 不必等到重启 DSH 之后才在设置页里发现。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
|
|
15
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'qq-digest-host-'));
|
|
16
|
+
process.env.QQ_DIGEST_DATA_DIR = dataDir;
|
|
17
|
+
process.env.QQ_DIGEST_SOURCE = process.env.QQ_DIGEST_SOURCE || 'fixture';
|
|
18
|
+
|
|
19
|
+
const { apply } = await import('../lib/index.js');
|
|
20
|
+
|
|
21
|
+
let captured = null;
|
|
22
|
+
const ctx = {
|
|
23
|
+
webServer: {
|
|
24
|
+
register(options) {
|
|
25
|
+
captured = options;
|
|
26
|
+
return () => {};
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
effect(fn) {
|
|
30
|
+
const disposer = fn();
|
|
31
|
+
return () => {};
|
|
32
|
+
},
|
|
33
|
+
get() {
|
|
34
|
+
return undefined;
|
|
35
|
+
},
|
|
36
|
+
logger: { info() {}, warn() {} },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
apply(ctx);
|
|
40
|
+
|
|
41
|
+
if (captured === null) {
|
|
42
|
+
console.error('FAIL: 没有注册任何路由');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (captured.kind !== 'prefix' || captured.path !== '/qq-digest-api') {
|
|
46
|
+
console.error(`FAIL: 路由形状不对 ${JSON.stringify({ kind: captured.kind, path: captured.path })}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function request(handler, { method = 'GET', url, body }) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const req = new Readable({ read() {} });
|
|
53
|
+
req.method = method;
|
|
54
|
+
req.url = url;
|
|
55
|
+
req.socket = { remoteAddress: '127.0.0.1' };
|
|
56
|
+
if (body !== undefined) req.push(JSON.stringify(body));
|
|
57
|
+
req.push(null);
|
|
58
|
+
|
|
59
|
+
const chunks = [];
|
|
60
|
+
const res = {
|
|
61
|
+
statusCode: 200,
|
|
62
|
+
headers: {},
|
|
63
|
+
writeHead(status, headers) {
|
|
64
|
+
this.statusCode = status;
|
|
65
|
+
this.headers = headers ?? {};
|
|
66
|
+
},
|
|
67
|
+
end(chunk) {
|
|
68
|
+
if (chunk) chunks.push(Buffer.from(chunk));
|
|
69
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
70
|
+
try {
|
|
71
|
+
resolve({ status: this.statusCode, json: JSON.parse(text) });
|
|
72
|
+
} catch {
|
|
73
|
+
reject(new Error(`非 JSON 响应:${text.slice(0, 200)}`));
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
handler(req, res);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const results = [];
|
|
83
|
+
function check(name, condition, detail) {
|
|
84
|
+
results.push({ name, ok: Boolean(condition), detail });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const state = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
88
|
+
check('GET /state 返回 200', state.status === 200, state.status);
|
|
89
|
+
check('状态里有 groups 数组', Array.isArray(state.json.rooms), typeof state.json.rooms);
|
|
90
|
+
check('只暴露群', (state.json.rooms ?? []).every((room) => /^\d+$/.test(String(room.groupId))), JSON.stringify(state.json.rooms?.slice(0, 2)));
|
|
91
|
+
check('fixture 下应看到 2 个群', (state.json.rooms ?? []).length === 2, (state.json.rooms ?? []).length);
|
|
92
|
+
check('默认群聊总开关是开的', state.json.groupsEnabled === true, state.json.groupsEnabled);
|
|
93
|
+
check('私聊单独成列且默认全关', Array.isArray(state.json.privateRooms) && state.json.privateRooms.length === 1 && state.json.privateRooms[0].enabled === false, JSON.stringify(state.json.privateRooms));
|
|
94
|
+
|
|
95
|
+
const saved = await request(captured.handler, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
url: '/qq-digest-api/config',
|
|
98
|
+
body: { groups: { '100000': { enabled: false } }, summarizer: 'heuristic', schedule: { enabled: true, kind: 'daily', time: '09:30', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
99
|
+
});
|
|
100
|
+
check('POST /config 保存成功', saved.status === 200 && saved.json.ok === true, saved.status);
|
|
101
|
+
check('关掉的群出现在配置里', saved.json.config?.groups?.['group:100000']?.enabled === false, JSON.stringify(saved.json.config?.groups));
|
|
102
|
+
check('定时时间被保存', saved.json.config?.schedule?.time === '09:30', saved.json.config?.schedule?.time);
|
|
103
|
+
|
|
104
|
+
const afterSave = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
105
|
+
const closed = (afterSave.json.rooms ?? []).find((room) => room.groupId === '100000');
|
|
106
|
+
check('关掉的群在列表里标记为关闭', closed?.enabled === false, JSON.stringify(closed));
|
|
107
|
+
|
|
108
|
+
const privateAttempt = await request(captured.handler, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
url: '/qq-digest-api/config',
|
|
111
|
+
body: { groups: { '88880001-private': { enabled: true }, 'private:88880001': { enabled: true } } },
|
|
112
|
+
});
|
|
113
|
+
check('私聊 id 被拒(400)', privateAttempt.status === 400, `${privateAttempt.status} ${JSON.stringify(privateAttempt.json)}`);
|
|
114
|
+
|
|
115
|
+
const badTime = await request(captured.handler, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: '/qq-digest-api/config',
|
|
118
|
+
body: { groups: {}, summarizer: 'heuristic', schedule: { enabled: true, kind: 'daily', time: '9:3', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
119
|
+
});
|
|
120
|
+
check('非法时间被拒(400)', badTime.status === 400, `${badTime.status} ${JSON.stringify(badTime.json)}`);
|
|
121
|
+
|
|
122
|
+
const privSave = await request(captured.handler, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: '/qq-digest-api/config',
|
|
125
|
+
body: { groupsEnabled: false, groups: {}, private: { '88880001': { enabled: true, name: '测试私聊' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
126
|
+
});
|
|
127
|
+
check('可以逐条打开私聊', privSave.status === 200, privSave.status);
|
|
128
|
+
check('总开关可以关掉', privSave.json.state?.groupsEnabled === false, privSave.json.state?.groupsEnabled);
|
|
129
|
+
check('私聊开关落盘', privSave.json.state?.privateRooms?.[0]?.enabled === true, JSON.stringify(privSave.json.state?.privateRooms));
|
|
130
|
+
check('私聊名字落盘', privSave.json.state?.privateRooms?.[0]?.name === '测试私聊', privSave.json.state?.privateRooms?.[0]?.name);
|
|
131
|
+
|
|
132
|
+
const blockedRun = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/run', body: {} });
|
|
133
|
+
const blockedDenied = (blockedRun.json?.result?.denied ?? []).filter((item) => item.reason === 'GROUP_DISABLED');
|
|
134
|
+
check('总开关关掉后所有群都被拒(单群覆盖也失效)', blockedDenied.length === 2, blockedDenied.length);
|
|
135
|
+
|
|
136
|
+
const restore = await request(captured.handler, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
url: '/qq-digest-api/config',
|
|
139
|
+
body: { groupsEnabled: true, groups: {}, private: { '88880001': { enabled: false } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
140
|
+
});
|
|
141
|
+
check('可以再恢复默认', restore.status === 200 && restore.json.state?.groupsEnabled === true, restore.status);
|
|
142
|
+
|
|
143
|
+
const run = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/run', body: {} });
|
|
144
|
+
check('POST /run 返回结果', run.status === 200 && typeof run.json.result?.status === 'string', JSON.stringify(run.json.result?.status));
|
|
145
|
+
|
|
146
|
+
const digest = await request(captured.handler, { url: '/qq-digest-api/digest?group=70001111' });
|
|
147
|
+
check('GET /digest 只认群号', digest.status === 200 || digest.json.digest === null, digest.status);
|
|
148
|
+
|
|
149
|
+
const privateDigest = await request(captured.handler, { url: '/qq-digest-api/digest?group=private%3A1' });
|
|
150
|
+
check('私聊句柄读不到摘要(400)', privateDigest.status === 400, privateDigest.status);
|
|
151
|
+
|
|
152
|
+
// 客户端半心跳:证明「设置页看不见」时能分清是哪一层的问题
|
|
153
|
+
const bootFactory = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/client-boot', body: { phase: 'factory' } });
|
|
154
|
+
check('POST /client-boot 接受 factory 心跳', bootFactory.status === 200, bootFactory.status);
|
|
155
|
+
const bootMounted = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/client-boot', body: { phase: 'mounted', sectionId: 'qq-digest' } });
|
|
156
|
+
check('POST /client-boot 接受 mounted 心跳', bootMounted.status === 200, bootMounted.status);
|
|
157
|
+
const withBoot = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
158
|
+
check('状态里回显客户端心跳', Boolean(withBoot.json.clientBoot?.factoryAt && withBoot.json.clientBoot?.mountedAt), JSON.stringify(withBoot.json.clientBoot));
|
|
159
|
+
|
|
160
|
+
const notFound = await request(captured.handler, { url: '/qq-digest-api/nope' });
|
|
161
|
+
check('未知路径 404', notFound.status === 404, notFound.status);
|
|
162
|
+
|
|
163
|
+
// 重名:群和私聊同名必须被拒(放在最后,避免改动状态影响前面的用例)
|
|
164
|
+
const dupSave = await request(captured.handler, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
url: '/qq-digest-api/config',
|
|
167
|
+
body: { groupsEnabled: true, groups: { '100000': { enabled: true, name: '重名' } }, private: { '88880001': { enabled: false, name: '重名' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
168
|
+
});
|
|
169
|
+
check('群与私聊同名 → 400', dupSave.status === 400 && String(dupSave.json.error ?? '').includes('DUPLICATE_NAME'), `${dupSave.status} ${JSON.stringify(dupSave.json)}`);
|
|
170
|
+
|
|
171
|
+
const uniqueSave = await request(captured.handler, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
url: '/qq-digest-api/config',
|
|
174
|
+
body: { groupsEnabled: true, groups: { '100000': { enabled: true, name: '甲' } }, private: { '88880001': { enabled: false, name: '乙' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
175
|
+
});
|
|
176
|
+
check('名字互不相同 → 200', uniqueSave.status === 200, uniqueSave.status);
|
|
177
|
+
|
|
178
|
+
for (const result of results) {
|
|
179
|
+
console.log(`${result.ok ? 'PASS' : 'FAIL'} ${result.name}${result.detail === undefined ? '' : ` (${result.detail})`}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
183
|
+
|
|
184
|
+
const failed = results.filter((result) => !result.ok).length;
|
|
185
|
+
console.log(`\n${results.length - failed}/${results.length} 通过`);
|
|
186
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 把 qq-digest 接进 DSH profile —— 只做**本机接线**,不改任何源码。
|
|
4
|
+
*
|
|
5
|
+
* 做三件事:
|
|
6
|
+
* 1) 在 profile 的 cordis.patch.yml 里维护一段带标记的区块,插入 MCP 行;
|
|
7
|
+
* 2) 把本仓库的真实路径替换进安装片段(模板里是 __PACKAGE_ROOT__ 占位符,
|
|
8
|
+
* 这样仓库本身不携带作者机器的绝对路径);
|
|
9
|
+
* 3) 把本机 qq-bridge 的位置写进 <DSH_HOME>/qq-digest/bridge-root.txt,
|
|
10
|
+
* 插件运行时靠它找到桥接(源码里不硬编码任何绝对路径)。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node scripts/install.mjs # 预览
|
|
14
|
+
* node scripts/install.mjs --apply # 写入(自动备份)
|
|
15
|
+
* node scripts/install.mjs --apply --profile desktop
|
|
16
|
+
* node scripts/install.mjs --apply --remove # 移除 MCP 行
|
|
17
|
+
* node scripts/install.mjs --apply --bridge-root=D:/path/to/qq-bridge
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const apply = args.includes('--apply');
|
|
29
|
+
const remove = args.includes('--remove');
|
|
30
|
+
const profileArg = args.find((arg) => arg.startsWith('--profile='));
|
|
31
|
+
const bridgeArg = args.find((arg) => arg.startsWith('--bridge-root='));
|
|
32
|
+
const profile = profileArg ? profileArg.split('=')[1] : process.env.DSH_PROFILE || 'desktop';
|
|
33
|
+
|
|
34
|
+
const BEGIN = '# === qq-digest MCP BEGIN ===';
|
|
35
|
+
const END = '# === qq-digest MCP END ===';
|
|
36
|
+
const BRIDGE_ROOT_FILE = 'bridge-root.txt';
|
|
37
|
+
|
|
38
|
+
function resolveDshHome(env = process.env) {
|
|
39
|
+
const fromEnv = env.DSH_HOME;
|
|
40
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
|
|
41
|
+
return join(homedir(), '.dsh');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 找一个真实存在的 qq-bridge 目录(有 state/console-token 才算)。 */
|
|
45
|
+
function detectBridgeRoot(explicit) {
|
|
46
|
+
const candidates = [
|
|
47
|
+
explicit,
|
|
48
|
+
process.env.QQ_DIGEST_BRIDGE_ROOT,
|
|
49
|
+
join(homedir(), 'AI', 'qq-bridge'),
|
|
50
|
+
process.env.USERNAME ? `D:/Users/${process.env.USERNAME}/AI/qq-bridge` : null,
|
|
51
|
+
process.env.USERNAME ? `C:/Users/${process.env.USERNAME}/AI/qq-bridge` : null,
|
|
52
|
+
].filter((value) => typeof value === 'string' && value.trim() !== '');
|
|
53
|
+
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (existsSync(join(candidate, 'state', 'console-token'))) return resolve(candidate);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dshHome = resolveDshHome();
|
|
61
|
+
const dataDir = join(dshHome, 'qq-digest');
|
|
62
|
+
const patchFile = join(dshHome, 'profiles', profile, 'cordis.patch.yml');
|
|
63
|
+
const bridgeRoot = detectBridgeRoot(bridgeArg ? bridgeArg.split('=').slice(1).join('=') : null);
|
|
64
|
+
|
|
65
|
+
if (!existsSync(patchFile)) {
|
|
66
|
+
console.error(`找不到 profile 配置:${patchFile}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fragment = readFileSync(join(root, 'install', 'cordis.patch.fragment.yml'), 'utf8');
|
|
71
|
+
const block = fragment.replace(/__PACKAGE_ROOT__/g, root.replace(/\\/g, '/')).trimEnd();
|
|
72
|
+
|
|
73
|
+
const current = readFileSync(patchFile, 'utf8');
|
|
74
|
+
|
|
75
|
+
function stripBlock(text) {
|
|
76
|
+
const begin = text.indexOf(BEGIN);
|
|
77
|
+
if (begin === -1) return text;
|
|
78
|
+
const end = text.indexOf(END);
|
|
79
|
+
if (end === -1) return text;
|
|
80
|
+
return `${text.slice(0, begin)}${text.slice(end + END.length)}`.replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const withoutBlock = stripBlock(current);
|
|
84
|
+
const next = remove ? `${withoutBlock}\n` : `${withoutBlock}\n\n${block}\n`;
|
|
85
|
+
const changed = next !== current;
|
|
86
|
+
|
|
87
|
+
console.log(`profile = ${profile}`);
|
|
88
|
+
console.log(`patch = ${patchFile}`);
|
|
89
|
+
console.log(`packageRoot = ${root.replace(/\\/g, '/')}`);
|
|
90
|
+
console.log(`bridgeRoot = ${bridgeRoot ?? '(没找到;插件会退回 ~/AI/qq-bridge)'}`);
|
|
91
|
+
console.log(`action = ${remove ? '移除 qq-digest 行' : '写入/更新 qq-digest 行'}`);
|
|
92
|
+
console.log(`patch 变化 = ${changed ? 'yes' : 'no(已是目标状态)'}`);
|
|
93
|
+
|
|
94
|
+
if (!apply) {
|
|
95
|
+
console.log('(预览模式:没有写入。加 --apply 才真正落盘。)');
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (changed) {
|
|
100
|
+
const backup = `${patchFile}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
101
|
+
copyFileSync(patchFile, backup);
|
|
102
|
+
writeFileSync(patchFile, next, 'utf8');
|
|
103
|
+
console.log(`已写入。备份:${backup}`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log('profile patch 无需改动。');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 本机桥接位置:只写一次,不覆盖用户已有设置
|
|
109
|
+
if (!remove) {
|
|
110
|
+
const bridgeFile = join(dataDir, BRIDGE_ROOT_FILE);
|
|
111
|
+
if (bridgeRoot === null) {
|
|
112
|
+
console.log(`跳过 ${BRIDGE_ROOT_FILE}:没检测到 qq-bridge(可用 --bridge-root=... 指定)`);
|
|
113
|
+
} else if (existsSync(bridgeFile)) {
|
|
114
|
+
console.log(`${BRIDGE_ROOT_FILE} 已存在,保持不动:${bridgeFile}`);
|
|
115
|
+
} else {
|
|
116
|
+
const { mkdirSync } = await import('node:fs');
|
|
117
|
+
mkdirSync(dataDir, { recursive: true });
|
|
118
|
+
writeFileSync(bridgeFile, `${bridgeRoot.replace(/\\/g, '/')}\n`, 'utf8');
|
|
119
|
+
console.log(`已记录桥接位置:${bridgeFile}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('重启 DSH 后生效(MCP 行通常可热加载,bundle 行需要重启)。');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 把本包挂进 DSH profile —— 不经过 pnpm。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用 plugin_manager 的 install_bundle:本机 profile 的 node_modules 里有
|
|
6
|
+
* 文件被占用(@img/sharp-win32-x64 EPERM),pnpm 一跑就整批重装并中途失败。
|
|
7
|
+
* 这里只做两件不依赖网络的事:
|
|
8
|
+
* 1) 在 profile 的 node_modules 里建一个 junction 指向本仓库;
|
|
9
|
+
* 2) 在 profile 的 package.json 里登记依赖与 bundle 名单。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/link-profile.mjs # 预览
|
|
13
|
+
* node scripts/link-profile.mjs --apply # 写入(自动备份 package.json)
|
|
14
|
+
* node scripts/link-profile.mjs --apply --profile desktop
|
|
15
|
+
* node scripts/link-profile.mjs --apply --remove
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
19
|
+
import { writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
|
|
24
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
const PACKAGE_NAME = 'dsh-qq-digest';
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const apply = args.includes('--apply');
|
|
29
|
+
const remove = args.includes('--remove');
|
|
30
|
+
const profileArg = args.find((arg) => arg.startsWith('--profile='));
|
|
31
|
+
const profile = profileArg ? profileArg.split('=')[1] : process.env.DSH_PROFILE || 'desktop';
|
|
32
|
+
|
|
33
|
+
function resolveDshHome() {
|
|
34
|
+
const fromEnv = process.env.DSH_HOME;
|
|
35
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
|
|
36
|
+
return join(homedir(), '.dsh');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const profileDir = join(resolveDshHome(), 'profiles', profile);
|
|
40
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
41
|
+
const linkPath = join(profileDir, 'node_modules', PACKAGE_NAME);
|
|
42
|
+
|
|
43
|
+
if (!existsSync(manifestPath)) {
|
|
44
|
+
console.error(`找不到 profile:${manifestPath}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
49
|
+
const deps = manifest.dependencies ?? {};
|
|
50
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
51
|
+
|
|
52
|
+
const linkSpec = `link:${root.replace(/\\/g, '/')}`;
|
|
53
|
+
const linked = existsSync(linkPath) && lstatSync(linkPath).isSymbolicLink();
|
|
54
|
+
|
|
55
|
+
console.log(`profile = ${profile}`);
|
|
56
|
+
console.log(`package = ${root}`);
|
|
57
|
+
console.log(`junction = ${linkPath}${linked ? '(已存在)' : '(待创建)'}`);
|
|
58
|
+
console.log(`dep = ${deps[PACKAGE_NAME] ?? '(未登记)'} → ${remove ? '(移除)' : linkSpec}`);
|
|
59
|
+
console.log(`bundles = ${bundles.includes(PACKAGE_NAME) ? '已登记' : '未登记'}`);
|
|
60
|
+
|
|
61
|
+
if (!apply) {
|
|
62
|
+
console.log('(预览模式:没有写入。加 --apply 才真正落盘。)');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (remove) {
|
|
67
|
+
if (linked) rmSync(linkPath, { recursive: false, force: true });
|
|
68
|
+
delete deps[PACKAGE_NAME];
|
|
69
|
+
manifest.dependencies = deps;
|
|
70
|
+
if (manifest.dsh?.profile) {
|
|
71
|
+
manifest.dsh.profile.bundles = bundles.filter((name) => name !== PACKAGE_NAME);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
if (!linked) {
|
|
75
|
+
if (existsSync(linkPath)) {
|
|
76
|
+
console.error(`目标已存在且不是 junction:${linkPath}(先手动确认再处理)`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
symlinkSync(root, linkPath, 'junction');
|
|
80
|
+
console.log('已创建 junction');
|
|
81
|
+
}
|
|
82
|
+
deps[PACKAGE_NAME] = linkSpec;
|
|
83
|
+
manifest.dependencies = deps;
|
|
84
|
+
manifest.dsh = manifest.dsh ?? {};
|
|
85
|
+
manifest.dsh.profile = manifest.dsh.profile ?? {};
|
|
86
|
+
const nextBundles = bundles.includes(PACKAGE_NAME) ? bundles : [...bundles, PACKAGE_NAME];
|
|
87
|
+
manifest.dsh.profile.bundles = nextBundles;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const backup = `${manifestPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
91
|
+
copyFileSync(manifestPath, backup);
|
|
92
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
93
|
+
|
|
94
|
+
console.log(`已写入 ${manifestPath}`);
|
|
95
|
+
console.log(`备份:${backup}`);
|
|
96
|
+
console.log('重启 DSH 后生效(bundle 的客户端半在启动时随页加载)。');
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 冒烟测试:用真实子进程跑一遍 MCP 协议,验证「装进去能用」而不只是「单元测试绿」。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/smoke.mjs # fixture 源(零副作用)
|
|
7
|
+
* node scripts/smoke.mjs bridge # 真桥接(只读;不会发消息)
|
|
8
|
+
* node scripts/smoke.mjs snowluma # 直连 SnowLuma(需网关在线)
|
|
9
|
+
*
|
|
10
|
+
* 它只发 tools/list 与三个只读工具的调用;run_summary 在 fixture 源下用本地启发式摘要,
|
|
11
|
+
* 不消耗模型额度。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir, tmpdir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
const source = process.argv[2] || 'fixture';
|
|
22
|
+
const summarizer = process.argv[3] || 'heuristic';
|
|
23
|
+
|
|
24
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'qq-digest-smoke-'));
|
|
25
|
+
// 把本机记录的 qq-bridge 位置带进临时数据目录,否则 bridge 源会退回 ~/AI/qq-bridge 而找不到令牌
|
|
26
|
+
const realBridgeRoot = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'qq-digest', 'bridge-root.txt');
|
|
27
|
+
if (existsSync(realBridgeRoot)) writeFileSync(join(dataDir, 'bridge-root.txt'), readFileSync(realBridgeRoot, 'utf8'), 'utf8');
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(dataDir, 'config.json'),
|
|
30
|
+
`${JSON.stringify(
|
|
31
|
+
{
|
|
32
|
+
version: 1,
|
|
33
|
+
groups: {},
|
|
34
|
+
summarizer,
|
|
35
|
+
schedule: { enabled: false, kind: 'daily', weekdays: ['MO'], time: '09:00', timeZone: 'Asia/Shanghai' },
|
|
36
|
+
},
|
|
37
|
+
null,
|
|
38
|
+
2,
|
|
39
|
+
)}\n`,
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const child = spawn(process.execPath, [join(root, 'src', 'run-mcp.mjs')], {
|
|
44
|
+
env: { ...process.env, QQ_DIGEST_SOURCE: source, QQ_DIGEST_DATA_DIR: dataDir },
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stdout = '';
|
|
49
|
+
let stderr = '';
|
|
50
|
+
child.stdout.setEncoding('utf8');
|
|
51
|
+
child.stderr.setEncoding('utf8');
|
|
52
|
+
child.stdout.on('data', (chunk) => {
|
|
53
|
+
stdout += chunk;
|
|
54
|
+
});
|
|
55
|
+
child.stderr.on('data', (chunk) => {
|
|
56
|
+
stderr += chunk;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const requests = [
|
|
60
|
+
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
61
|
+
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
62
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'qq_digest_status', arguments: {} } },
|
|
63
|
+
{ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'qq_digest_list_rooms', arguments: {} } },
|
|
64
|
+
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'qq_digest_run_summary', arguments: {} } },
|
|
65
|
+
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'qq_digest_get_digest', arguments: {} } },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
for (const request of requests) {
|
|
69
|
+
child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
71
|
+
}
|
|
72
|
+
child.stdin.end();
|
|
73
|
+
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
child.kill();
|
|
77
|
+
resolve();
|
|
78
|
+
}, 25_000);
|
|
79
|
+
child.on('exit', () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const responses = new Map();
|
|
86
|
+
for (const line of stdout.split('\n')) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (trimmed === '') continue;
|
|
89
|
+
try {
|
|
90
|
+
const message = JSON.parse(trimmed);
|
|
91
|
+
if (message.id !== undefined) responses.set(message.id, message);
|
|
92
|
+
} catch {
|
|
93
|
+
// 忽略非 JSON 行
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function text(id) {
|
|
98
|
+
const message = responses.get(id);
|
|
99
|
+
if (!message) return '(无响应)';
|
|
100
|
+
if (message.error) return `ERROR ${message.error.message}`;
|
|
101
|
+
if (message.result?.tools) return message.result.tools.map((tool) => tool.name).join(', ');
|
|
102
|
+
return String(message.result?.content?.[0]?.text ?? '').replace(/\s+/g, ' ').slice(0, 700);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(`source = ${source} / summarizer = ${summarizer}`);
|
|
106
|
+
console.log(`stderr = ${stderr.trim().split('\n').slice(0, 2).join(' | ')}`);
|
|
107
|
+
console.log(`tools = ${text(2)}`);
|
|
108
|
+
console.log(`status = ${text(3)}`);
|
|
109
|
+
console.log(`rooms = ${text(4)}`);
|
|
110
|
+
console.log(`run = ${text(5)}`);
|
|
111
|
+
console.log(`digest = ${text(6)}`);
|
|
112
|
+
|
|
113
|
+
const all = [...responses.values()].map((message) => JSON.stringify(message)).join('\n');
|
|
114
|
+
const leaks = ['PRIVATE_SENTINEL', 'private:', '男人', 'EXCLUDED_SENTINEL'].filter((needle) => all.includes(needle));
|
|
115
|
+
console.log(`private 泄漏检查 = ${leaks.length === 0 ? '通过(无 private 痕迹)' : `失败:${leaks.join(',')}`}`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 命令行手动触发一次总结(走设置页同一套 API,不是另一条路径)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:node scripts/trigger.mjs [端口]
|
|
6
|
+
*
|
|
7
|
+
* 它会真的调用模型(如果 config.summarizer === 'llm'),因此会产生少量费用。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PORTS = process.argv[2] ? [Number(process.argv[2])] : [8784, 1402, 14802];
|
|
11
|
+
|
|
12
|
+
let base = null;
|
|
13
|
+
for (const port of PORTS) {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`http://127.0.0.1:${port}/qq-digest-api/state`, {
|
|
16
|
+
headers: { accept: 'application/json' },
|
|
17
|
+
signal: AbortSignal.timeout(5000),
|
|
18
|
+
});
|
|
19
|
+
if (res.status === 200) {
|
|
20
|
+
base = `http://127.0.0.1:${port}`;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// 换下一个端口
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (base === null) {
|
|
29
|
+
console.error('找不到在跑的 DSH(8784/1402/14802 都没响应 /qq-digest-api/state)');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(`目标 ${base}`);
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
const res = await fetch(`${base}/qq-digest-api/run`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ trigger: 'manual' }),
|
|
39
|
+
signal: AbortSignal.timeout(180_000),
|
|
40
|
+
});
|
|
41
|
+
const payload = await res.json();
|
|
42
|
+
console.log(`HTTP ${res.status},耗时 ${Math.round((Date.now() - started) / 1000)}s`);
|
|
43
|
+
|
|
44
|
+
const result = payload.result ?? {};
|
|
45
|
+
console.log(`状态 ${result.status}`);
|
|
46
|
+
console.log(`产出 ${(result.summarized ?? []).length} 个群 / 跳过 ${(result.skipped ?? []).length} / 拒绝 ${(result.denied ?? []).length} / 失败 ${(result.errors ?? []).length}`);
|
|
47
|
+
|
|
48
|
+
for (const item of result.summarized ?? []) {
|
|
49
|
+
console.log(` ✓ ${item.roomTitle ?? item.roomHandle}:${item.messageCount} 条消息 → ${item.highlights} 条重点 / ${item.tasks} 项待办`);
|
|
50
|
+
}
|
|
51
|
+
for (const item of result.skipped ?? []) {
|
|
52
|
+
console.log(` - ${item.roomHandle}:${item.reason}`);
|
|
53
|
+
}
|
|
54
|
+
for (const item of result.errors ?? []) {
|
|
55
|
+
console.log(` ✗ ${item.roomHandle}:${item.code} ${item.message}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 打印每个群最新一篇摘要的要点数量,正文留给设置页看
|
|
59
|
+
const state = await (await fetch(`${base}/qq-digest-api/state`, { headers: { accept: 'application/json' } })).json();
|
|
60
|
+
for (const room of state.rooms ?? []) {
|
|
61
|
+
if (!room.hasDigest) continue;
|
|
62
|
+
const digest = await (await fetch(`${base}/qq-digest-api/digest?group=${room.groupId}`, { headers: { accept: 'application/json' } })).json();
|
|
63
|
+
const d = digest.digest ?? {};
|
|
64
|
+
console.log(`\n[${room.title ?? room.groupId}] ${room.lastDigestAt}`);
|
|
65
|
+
console.log(` ${d.overview ?? '(无概述)'}`);
|
|
66
|
+
(d.tasks ?? []).slice(0, 3).forEach((task) => console.log(` · 待办:${task.what}${task.owner && task.owner !== '未指定' ? `(${task.owner})` : ''}`));
|
|
67
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DSH 适配性自检:一次跑完「插件在宿主里到底活没活」的全部可观测证据。
|
|
4
|
+
*
|
|
5
|
+
* 检查项:
|
|
6
|
+
* 1) 宿主半:设置页 API 是否可达、返回什么
|
|
7
|
+
* 2) 客户端半:最近一次 web boot 有没有因为它失败(看 desktop 的 crash log)
|
|
8
|
+
* 3) MCP 行:run-mcp 进程是否在跑
|
|
9
|
+
* 4) 落盘:配置/摘要目录状态
|
|
10
|
+
*
|
|
11
|
+
* 用法:node scripts/verify-dsh.mjs [端口]
|
|
12
|
+
* 端口省略时按 8784 → 1402 → 14802 依次试。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const PORTS = process.argv[2] ? [Number(process.argv[2])] : [8784, 1402, 14802];
|
|
20
|
+
const LOG_DIR = join(homedir(), 'AppData', 'Roaming', '@deepseek-ai', 'dsh-desktop', 'logs');
|
|
21
|
+
const DATA_DIR = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'qq-digest');
|
|
22
|
+
|
|
23
|
+
function ok(label, detail) {
|
|
24
|
+
console.log(`PASS ${label}${detail ? ` ${detail}` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
function bad(label, detail) {
|
|
27
|
+
console.log(`FAIL ${label}${detail ? ` ${detail}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let failures = 0;
|
|
31
|
+
|
|
32
|
+
// 1) 宿主半
|
|
33
|
+
let state = null;
|
|
34
|
+
for (const port of PORTS) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`http://127.0.0.1:${port}/qq-digest-api/state`, {
|
|
37
|
+
headers: { accept: 'application/json' },
|
|
38
|
+
signal: AbortSignal.timeout(6000),
|
|
39
|
+
});
|
|
40
|
+
if (res.status === 200) {
|
|
41
|
+
state = await res.json();
|
|
42
|
+
ok('宿主半在线', `:${port} source=${state.source} 群=${state.rooms?.length ?? 0}`);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
console.log(` :${port} HTTP ${res.status}`);
|
|
46
|
+
} catch {
|
|
47
|
+
console.log(` :${port} 不可达`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (state === null) {
|
|
51
|
+
bad('宿主半未响应(DSH 没在跑,或行没挂上)');
|
|
52
|
+
failures += 1;
|
|
53
|
+
} else {
|
|
54
|
+
if (Array.isArray(state.rooms) && state.rooms.length > 0) ok('群列表非空');
|
|
55
|
+
else bad('群列表为空(数据源不可达?)'), (failures += 1);
|
|
56
|
+
if (state.policyDegraded) bad('策略降级(配置损坏 → 全关)', state.configError), (failures += 1);
|
|
57
|
+
else ok('策略正常');
|
|
58
|
+
if (state.adapterError) bad('数据源报错', state.adapterError.message), (failures += 1);
|
|
59
|
+
else ok('数据源无报错');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2) 客户端半:看最近一次 web boot 是否因本插件失败
|
|
63
|
+
try {
|
|
64
|
+
const clientFile = join(import.meta.dirname, '..', 'lib', 'client.js');
|
|
65
|
+
const fixTime = statSync(clientFile).mtimeMs;
|
|
66
|
+
|
|
67
|
+
const files = readdirSync(LOG_DIR)
|
|
68
|
+
.filter((name) => name.endsWith('-web-boot.log'))
|
|
69
|
+
.map((name) => ({ name, path: join(LOG_DIR, name), mtime: statSync(join(LOG_DIR, name)).mtimeMs }))
|
|
70
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
71
|
+
|
|
72
|
+
const newest = files[0];
|
|
73
|
+
if (!newest) {
|
|
74
|
+
console.log('SKIP 客户端半:没有 web-boot 崩溃日志可查(通常就是好事)');
|
|
75
|
+
} else if (newest.mtime < fixTime) {
|
|
76
|
+
// 修复之后还没重新加载过:既不能算通过,也不能把旧的失败算到新代码头上。
|
|
77
|
+
console.log(`SKIP 客户端半:修复后还没重新加载过(最近一次 boot ${new Date(newest.mtime).toISOString()} 早于 client.js ${new Date(fixTime).toISOString()})`);
|
|
78
|
+
} else if (readFileSync(newest.path, 'utf8').includes('dsh-qq-digest: import failed')) {
|
|
79
|
+
bad('客户端半加载失败', `${newest.name} @ ${new Date(newest.mtime).toISOString()}`);
|
|
80
|
+
failures += 1;
|
|
81
|
+
} else {
|
|
82
|
+
ok('客户端半最近一次启动未报错', new Date(newest.mtime).toISOString());
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.log(`SKIP 客户端半:读不到 desktop 日志目录(${err.code ?? err.message})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3) MCP 行
|
|
89
|
+
try {
|
|
90
|
+
const { execSync } = await import('node:child_process');
|
|
91
|
+
const out = execSync('tasklist /FI "IMAGENAME eq node.exe" /FO CSV', { encoding: 'utf8' });
|
|
92
|
+
ok('node 进程可枚举', `${out.split('\n').length - 2} 个(具体 run-mcp 进程见任务管理器)`);
|
|
93
|
+
} catch {
|
|
94
|
+
console.log('SKIP MCP 进程枚举失败');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4) 落盘
|
|
98
|
+
const configFile = join(DATA_DIR, 'config.json');
|
|
99
|
+
const digests = join(DATA_DIR, 'digests.jsonl');
|
|
100
|
+
console.log(` 数据目录 ${DATA_DIR}`);
|
|
101
|
+
console.log(` config.json ${existsSync(configFile) ? '存在' : '不存在'}`);
|
|
102
|
+
if (existsSync(digests)) {
|
|
103
|
+
const lines = readFileSync(digests, 'utf8').split('\n').filter(Boolean).length;
|
|
104
|
+
console.log(` digests.jsonl ${lines} 条摘要`);
|
|
105
|
+
} else {
|
|
106
|
+
console.log(' digests.jsonl 还没有(还没跑过总结)');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
|
110
|
+
process.exit(failures === 0 ? 0 : 1);
|