dsh-qq-digest 0.1.0 → 0.2.0
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 +39 -11
- package/install/cordis.patch.fragment.yml +4 -1
- package/lib/index.js +107 -19
- package/package.json +4 -2
- package/scripts/check-all.mjs +169 -0
- package/scripts/check-core-purity.mjs +70 -0
- package/scripts/final-check.mjs +197 -0
- package/scripts/host-smoke.mjs +235 -0
- package/scripts/install.mjs +128 -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/src/core/gate.js +16 -2
- package/src/mcp-server.js +15 -2
- package/src/run-lock.js +35 -3
- package/src/runtime.js +65 -9
- package/src/service.js +57 -7
- package/src/store-file.js +80 -13
package/README.md
CHANGED
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
|
|
50
50
|
- `llm`(默认):OpenAI 兼容接口,结构化 JSON。密钥取 `QQ_DIGEST_LLM_API_KEY` 或 `~/.dsh/.credentials.yaml` 的 `DEEPSEEK_API_KEY`。
|
|
51
51
|
失败**不静默降级**,直接抛 `LLM_*` 错误码。
|
|
52
|
+
⚠️ **知情提示**:选 `llm` 意味着被摘要的群/私聊**消息正文、昵称,以及被 @ 的 QQ 号**会发送到
|
|
53
|
+
模型服务商(默认 `api.deepseek.com`)。不能接受这一点就选 `heuristic`(纯本地规则,不出网)。
|
|
54
|
+
摘要是对**不可信输入**的加工结果:群成员可以往群名/正文里塞指令,摘要只能当数据看,不能当指令执行。
|
|
52
55
|
- `heuristic`:本地关键词规则,零网络零成本,质量有上限(会漏也会误抓)。
|
|
53
56
|
|
|
54
57
|
## 配置
|
|
@@ -69,7 +72,10 @@
|
|
|
69
72
|
|
|
70
73
|
- 不写 `groups` = 所有群都可读。
|
|
71
74
|
- `groups` 里只用于**关掉**某个群或给它起名。
|
|
72
|
-
-
|
|
75
|
+
- `private` 是**受支持的一等键**:`{ "88880001": { "enabled": true } }` 逐条打开私聊;
|
|
76
|
+
不允许一键全开(`private.defaultEnabled=true` 在策略解析阶段即判非法 → 全关)。
|
|
77
|
+
- 写出 `friend` / `send` / `reply` / `scope` / `write` 一类**危险键** → 整份配置作废 → **全关**(fail-closed);
|
|
78
|
+
与安全无关的未知键被忽略。
|
|
73
79
|
- 配置损坏同理:全关,不回退到「默认允许」。
|
|
74
80
|
|
|
75
81
|
## 落盘
|
|
@@ -82,9 +88,11 @@
|
|
|
82
88
|
## 验证
|
|
83
89
|
|
|
84
90
|
```bash
|
|
85
|
-
npm run verify
|
|
86
|
-
node scripts/smoke.mjs
|
|
87
|
-
node scripts/smoke.mjs bridge
|
|
91
|
+
npm run verify # core 纯净性检查 + 全部用例(当前 116)
|
|
92
|
+
node scripts/smoke.mjs # fixture 端到端(真实子进程走 MCP 协议)
|
|
93
|
+
node scripts/smoke.mjs bridge # 真桥接只读冒烟
|
|
94
|
+
node scripts/host-smoke.mjs # 宿主半:假 ctx 跑 apply(),真 req/res 打路由(含跨站/伪造 Host 用例)
|
|
95
|
+
node scripts/final-check.mjs # 发布前自检(脱敏词从 QQ_DIGEST_SENSITIVE_PATTERNS 读,不写死在仓库里)
|
|
88
96
|
```
|
|
89
97
|
|
|
90
98
|
core 纯净性检查锁定:`src/core/` 不许 import `node:fs` / 子进程 / 网络,不许出现 `fetch(`、`send_*`、`/api/send`、写文件符号。
|
|
@@ -148,9 +156,10 @@ core 纯净性检查锁定:`src/core/` 不许 import `node:fs` / 子进程 /
|
|
|
148
156
|
|
|
149
157
|
## 安装进 DSH
|
|
150
158
|
|
|
151
|
-
这一步分两块,互不冲突(row id 不同):
|
|
152
|
-
|
|
153
159
|
```bash
|
|
160
|
+
# 0) (可选)从 npm 拿包,或直接把本仓库放到任意目录
|
|
161
|
+
npm i dsh-qq-digest
|
|
162
|
+
|
|
154
163
|
# 1) MCP 行:给模型用的四个只读工具
|
|
155
164
|
node scripts/install.mjs --apply
|
|
156
165
|
|
|
@@ -158,6 +167,11 @@ node scripts/install.mjs --apply
|
|
|
158
167
|
node scripts/link-profile.mjs --apply
|
|
159
168
|
```
|
|
160
169
|
|
|
170
|
+
第 1 步还会把本机 qq-bridge 的位置写进 `<DSH_HOME>/qq-digest/bridge-root.txt`
|
|
171
|
+
(源码里不硬编码任何绝对路径)。如果没自动找到,用 `--bridge-root=D:/path/to/qq-bridge` 指定。
|
|
172
|
+
|
|
173
|
+
**包内不含任何作者的个人信息**:QQ 号、群号、用户目录都是脱敏后的假值或运行时解析。
|
|
174
|
+
|
|
161
175
|
第 2 步**不走 pnpm**:本机 profile 的 `node_modules` 里有文件被占用
|
|
162
176
|
(`@img/sharp-win32-x64` 会在 pnpm 重装时报 `ERR_PNPM_EPERM`),所以这里用
|
|
163
177
|
junction + 手改 profile `package.json`(会自动备份),和本机 `dsh-instruction-memory` 的装法一致。
|
|
@@ -201,9 +215,18 @@ node scripts/verify-dsh.mjs # 宿主半在线?客户端半上次启动有
|
|
|
201
215
|
|
|
202
216
|
## 已知限制(不美化)
|
|
203
217
|
|
|
204
|
-
- **设置页的开关不是安全边界**:`/qq-digest-api`
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
- **设置页的开关不是安全边界**:`/qq-digest-api` 允许**本机进程**(包括模型通过 bash 起一个 `curl`)直接 POST 改配置。
|
|
219
|
+
它挡住的是**浏览器与网络**,不是本机同一用户下的进程。
|
|
220
|
+
- 管理面已做的校验(`lib/index.js` 的 `denyUntrustedRequest`):
|
|
221
|
+
① 来源必须是回环(`remoteAddress`);② `Host` 必须是回环字面量(挡 DNS rebinding);
|
|
222
|
+
③ `Origin` 必须是回环来源;④ `Sec-Fetch-Site: cross-site` 直接拒;
|
|
223
|
+
⑤ 写操作必须是 `application/json` —— 这个类型跨源必预检,而本服务不返回任何 CORS 头,
|
|
224
|
+
于是 `<form>` / `text/plain` 的 CSRF 简单请求进不来。
|
|
225
|
+
- 反代 / 公网隧道仍会让 `remoteAddress` 看起来是回环:**不要把本插件挂在公网隧道后面**。
|
|
226
|
+
- 句柄密钥不再有源码默认值:`QQ_DIGEST_SECRET` → `<数据目录>/handle-secret`(首次启动随机生成并落盘)。
|
|
227
|
+
写不进去就拒绝启动,而不是退回弱密钥。`/state` 的 `secretSource` 会如实告诉你当前用哪一种。
|
|
228
|
+
- 读取摘要会**按当前策略现算授权**:关掉某个群或关掉总开关之后,该群历史摘要立即读不到(本轮修复)。
|
|
229
|
+
拒绝列表里返回的是 `auditRef`(独立 HMAC 域),它**不能**用来读任何摘要。
|
|
207
230
|
- 私聊只在 `bridge` 数据源下可用;`snowluma` 适配器只调群历史,没有私聊取数路径。
|
|
208
231
|
- `bridge` 源的群名是空的(桥接不存群名);可在 `config.json` 的 `groups.<id>.name` 里人工命名。
|
|
209
232
|
- `bridge` 源每轮最多回看 **100 条**(桥接 `/api/messages` 的硬上限)。两次运行之间新增超过 100 条时,
|
|
@@ -212,6 +235,11 @@ node scripts/verify-dsh.mjs # 宿主半在线?客户端半上次启动有
|
|
|
212
235
|
- `snowluma` 源是 `stateless`:每轮全量重算固定窗口(默认 60 条)。原因是 OneBot 的 `message_id`
|
|
213
236
|
不保证单调,拿它当水位线会静默漏读新消息。
|
|
214
237
|
- 句子级启发式摘要对「谁负责」判断保守:猜不准就回退成发消息的人,不做过度推断。
|
|
215
|
-
- `config.json`
|
|
216
|
-
|
|
238
|
+
- `config.json` 的 `schedule` 由**宿主半自己**执行:`lib/index.js` 每 30s tick 一次,到点跑一轮
|
|
239
|
+
(启动后 5s 还会补跑一次,避免刚好在到点后才启动而整天漏跑)。
|
|
240
|
+
跨进程互斥用 `run.lock`(带心跳续租,长轮次不会被判成陈旧锁抢占)。
|
|
241
|
+
- 手动「立即总结」**不会**吞掉当天的定时:去重只认 `lastTrigger === "scheduled"` 的那一次。
|
|
217
242
|
- 未评估 QQ 侧 ToS 与账号风险;本插件只读本地桥接/网关接口,不额外登录。
|
|
243
|
+
- **`/state` 会明文回显群号与私聊 QQ 号**:设置页要靠它们做开关和提交,不做掩码是刻意选择
|
|
244
|
+
(入口已经用来源校验收窄到回环同源)。因此别在截图、录屏、远程协助里展示设置页。
|
|
245
|
+
- 未做速率限制:`/run` 与 `run_summary` 可以被反复触发,会放大 LLM 调用费用。需要限流请在外层加。
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
config:
|
|
7
7
|
serverName: qq-digest
|
|
8
8
|
transport: stdio
|
|
9
|
-
|
|
9
|
+
# __NODE_EXE__ 由 install.mjs 用 process.execPath 替换。
|
|
10
|
+
# 模板里绝不能出现作者机器上的绝对路径:换台机器就会 spawn ENOENT,
|
|
11
|
+
# 表现为「MCP 工具全部消失但插件看起来装上了」。
|
|
12
|
+
command: '__NODE_EXE__'
|
|
10
13
|
args:
|
|
11
14
|
- '__PACKAGE_ROOT__/src/run-mcp.mjs'
|
|
12
15
|
toolCallTimeoutMs: 180000
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { canonicalize, KIND } from '../src/core/canonical.js';
|
|
19
19
|
import { roomHandle } from '../src/core/gate.js';
|
|
20
20
|
import { configPath, defaultConfig, loadConfig, saveConfig } from '../src/config.js';
|
|
21
|
-
import { resolveDataDir,
|
|
21
|
+
import { resolveDataDir, createRuntime } from '../src/runtime.js';
|
|
22
22
|
import { withRunLock } from '../src/run-lock.js';
|
|
23
23
|
|
|
24
24
|
export const name = 'dsh-qq-digest';
|
|
@@ -34,11 +34,12 @@ export function apply(ctx) {
|
|
|
34
34
|
mkdirSync(dataDir, { recursive: true });
|
|
35
35
|
|
|
36
36
|
const stateFile = join(dataDir, 'schedule-state.json');
|
|
37
|
-
const secret = resolveSecret();
|
|
38
37
|
|
|
39
38
|
let runtime = null;
|
|
40
39
|
const getRuntime = () => {
|
|
41
|
-
|
|
40
|
+
// createRuntime 自己解析密钥(env → <dataDir>/handle-secret → 首次生成),
|
|
41
|
+
// 与 MCP 半走同一份实现,保证两边算出的句柄一致。
|
|
42
|
+
if (runtime === null) runtime = createRuntime({ dataDir });
|
|
42
43
|
return runtime;
|
|
43
44
|
};
|
|
44
45
|
|
|
@@ -52,6 +53,7 @@ export function apply(ctx) {
|
|
|
52
53
|
// ── 状态 ────────────────────────────────────────────────────────────────
|
|
53
54
|
|
|
54
55
|
async function buildState() {
|
|
56
|
+
const secret = getRuntime().secret;
|
|
55
57
|
const loaded = loadConfig(configPath(dataDir));
|
|
56
58
|
const config = loaded.config;
|
|
57
59
|
const scheduleState = readScheduleState(stateFile);
|
|
@@ -113,6 +115,8 @@ export function apply(ctx) {
|
|
|
113
115
|
privateRooms,
|
|
114
116
|
configError: loaded.error,
|
|
115
117
|
policyDegraded: Boolean(loaded.policy?.degraded),
|
|
118
|
+
// 密钥来源如实上报(env / generated / file / explicit)。值本身永不下发。
|
|
119
|
+
secretSource: getRuntime().secretSource,
|
|
116
120
|
summarizer: config.summarizer,
|
|
117
121
|
schedule: config.schedule,
|
|
118
122
|
scheduleState,
|
|
@@ -129,7 +133,11 @@ export function apply(ctx) {
|
|
|
129
133
|
// ── 配置写入 ─────────────────────────────────────────────────────────────
|
|
130
134
|
|
|
131
135
|
function saveFromUi(body) {
|
|
132
|
-
const
|
|
136
|
+
const loaded = loadConfig(configPath(dataDir));
|
|
137
|
+
// 配置文件本身坏了时 loadConfig 会回退成默认值;拿这个默认值当 current 再保存,
|
|
138
|
+
// 会把用户原有的 groups 静默清空。宁可明确拒绝,让人先修好文件。
|
|
139
|
+
if (loaded.error) throw badRequest(`CONFIG_UNREADABLE:${loaded.error}`);
|
|
140
|
+
const current = loaded.config;
|
|
133
141
|
|
|
134
142
|
const groups = {};
|
|
135
143
|
const inputGroups = body?.groups ?? {};
|
|
@@ -205,7 +213,7 @@ export function apply(ctx) {
|
|
|
205
213
|
// ── 路由 ────────────────────────────────────────────────────────────────
|
|
206
214
|
|
|
207
215
|
function handler(req, res) {
|
|
208
|
-
if (
|
|
216
|
+
if (denyUntrustedRequest(req, res)) return;
|
|
209
217
|
|
|
210
218
|
let url;
|
|
211
219
|
try {
|
|
@@ -249,9 +257,19 @@ export function apply(ctx) {
|
|
|
249
257
|
if (!canonical.ok || canonical.kind !== KIND.GROUP) {
|
|
250
258
|
return sendJson(res, 400, { ok: false, error: 'ONLY_GROUPS' });
|
|
251
259
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
// 与 MCP 工具走同一条读取路径:读取也要按当前策略复核,
|
|
261
|
+
// 否则「关掉这个群」只影响下一轮取数,历史摘要照样读得到。
|
|
262
|
+
const handle = roomHandle(getRuntime().secret, canonical.canonicalId);
|
|
263
|
+
const out = await getRuntime().service.getDigest(handle);
|
|
264
|
+
const entry = out.status === 'OK' ? out.entry : null;
|
|
265
|
+
return sendJson(res, 200, {
|
|
266
|
+
ok: true,
|
|
267
|
+
groupId: canonical.groupId,
|
|
268
|
+
status: out.status,
|
|
269
|
+
...(out.reason ? { reason: out.reason } : {}),
|
|
270
|
+
digest: entry?.digest ?? null,
|
|
271
|
+
createdAt: entry?.createdAt ?? null,
|
|
272
|
+
});
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
return sendJson(res, 404, { ok: false, error: 'NOT_FOUND' });
|
|
@@ -279,10 +297,7 @@ export function apply(ctx) {
|
|
|
279
297
|
if (!isDue(schedule, now)) return;
|
|
280
298
|
|
|
281
299
|
const state = readScheduleState(stateFile);
|
|
282
|
-
if (state
|
|
283
|
-
const last = nowIn(schedule.timeZone, new Date(state.lastRunAt));
|
|
284
|
-
if (last.dateKey === now.dateKey) return; // 今天已经跑过
|
|
285
|
-
}
|
|
300
|
+
if (alreadyRanToday(state, schedule, now)) return;
|
|
286
301
|
|
|
287
302
|
const result = await runOnce('scheduled');
|
|
288
303
|
ctx.logger?.info?.(`[qq-digest] 定时总结 ${now.dateKey} ${now.hhmm} → ${result.status}`);
|
|
@@ -381,7 +396,8 @@ function nowIn(timeZone, at = new Date()) {
|
|
|
381
396
|
return {
|
|
382
397
|
dateKey: `${year}-${month}-${day}`,
|
|
383
398
|
hhmm: `${hour}:${minute}`,
|
|
384
|
-
|
|
399
|
+
// 解析不出来就是 null,不再默默当成周一:fail-open 会让「每周一」的任务在错误的星期跑。
|
|
400
|
+
weekday: index >= 0 ? WEEKDAY_CODES[index] : null,
|
|
385
401
|
};
|
|
386
402
|
}
|
|
387
403
|
|
|
@@ -401,9 +417,22 @@ function normalizeSchedule(raw) {
|
|
|
401
417
|
};
|
|
402
418
|
}
|
|
403
419
|
|
|
420
|
+
/**
|
|
421
|
+
* 今天是否已经「由定时任务」跑过。
|
|
422
|
+
*
|
|
423
|
+
* 只认 lastTrigger === 'scheduled':手动跑也会写 lastRunAt,若把它当成定时已完成,
|
|
424
|
+
* 用户在 08:59 手点一次「立即总结」,09:00 的定时就会被静默吞掉 —— 而且没有任何提示。
|
|
425
|
+
*/
|
|
426
|
+
function alreadyRanToday(state, schedule, now) {
|
|
427
|
+
if (state?.lastTrigger !== 'scheduled' || !state.lastRunAt) return false;
|
|
428
|
+
const last = nowIn(schedule.timeZone, new Date(state.lastRunAt));
|
|
429
|
+
return last.dateKey === now.dateKey;
|
|
430
|
+
}
|
|
431
|
+
|
|
404
432
|
function isDue(schedule, now) {
|
|
405
433
|
if (now.hhmm !== schedule.time) return false;
|
|
406
434
|
if (schedule.kind === 'daily') return true;
|
|
435
|
+
if (!now.weekday) return false; // 周几不可判定 → 不跑,而不是猜一个
|
|
407
436
|
return Array.isArray(schedule.weekdays) && schedule.weekdays.includes(now.weekday);
|
|
408
437
|
}
|
|
409
438
|
|
|
@@ -412,10 +441,11 @@ function readScheduleState(file) {
|
|
|
412
441
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
413
442
|
return {
|
|
414
443
|
lastRunAt: typeof parsed?.lastRunAt === 'string' ? parsed.lastRunAt : null,
|
|
444
|
+
lastTrigger: typeof parsed?.lastTrigger === 'string' ? parsed.lastTrigger : null,
|
|
415
445
|
lastStatus: typeof parsed?.lastStatus === 'string' ? parsed.lastStatus : null,
|
|
416
446
|
};
|
|
417
447
|
} catch {
|
|
418
|
-
return { lastRunAt: null, lastStatus: null };
|
|
448
|
+
return { lastRunAt: null, lastTrigger: null, lastStatus: null };
|
|
419
449
|
}
|
|
420
450
|
}
|
|
421
451
|
|
|
@@ -429,14 +459,64 @@ function writeScheduleState(file, { lastRunAt, trigger, status }) {
|
|
|
429
459
|
}
|
|
430
460
|
}
|
|
431
461
|
|
|
432
|
-
|
|
462
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 管理面入口校验。
|
|
466
|
+
*
|
|
467
|
+
* 只判 remoteAddress 是不够的,有三种真实场景会绕过去:
|
|
468
|
+
* 1) DNS rebinding:攻击者的域名解析到 127.0.0.1,浏览器会把请求发到本机,remoteAddress 就是回环;
|
|
469
|
+
* 2) 任意网页的跨站请求:浏览器发出的简单请求(form / text-plain)不需要预检,服务端不校验就照做;
|
|
470
|
+
* 3) 反向代理 / 公网隧道:转进来的请求 remoteAddress 通常也是 127.0.0.1。
|
|
471
|
+
*
|
|
472
|
+
* 因此这里同时校验 Host、Origin、Sec-Fetch-Site,并要求写操作必须是 application/json
|
|
473
|
+
* (该类型跨源必预检,而本服务不返回任何 CORS 头,预检不可能通过)。
|
|
474
|
+
*/
|
|
475
|
+
function denyUntrustedRequest(req, res) {
|
|
433
476
|
const address = String(req.socket?.remoteAddress ?? '');
|
|
434
477
|
const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
435
|
-
if (loopback) return
|
|
436
|
-
|
|
478
|
+
if (!loopback) return reject(res, 403, 'LOCAL_ONLY');
|
|
479
|
+
|
|
480
|
+
if (!hostIsLoopback(req.headers?.host)) return reject(res, 403, 'BAD_HOST');
|
|
481
|
+
if (!originIsLoopback(req.headers?.origin)) return reject(res, 403, 'BAD_ORIGIN');
|
|
482
|
+
|
|
483
|
+
const site = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
484
|
+
if (site === 'cross-site') return reject(res, 403, 'CROSS_SITE');
|
|
485
|
+
|
|
486
|
+
const method = String(req.method ?? 'GET').toUpperCase();
|
|
487
|
+
if (method === 'POST') {
|
|
488
|
+
const type = String(req.headers?.['content-type'] ?? '').toLowerCase();
|
|
489
|
+
if (!type.startsWith('application/json')) return reject(res, 415, 'JSON_REQUIRED');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function reject(res, status, error) {
|
|
496
|
+
sendJson(res, status, { ok: false, error });
|
|
437
497
|
return true;
|
|
438
498
|
}
|
|
439
499
|
|
|
500
|
+
function hostIsLoopback(header) {
|
|
501
|
+
if (typeof header !== 'string' || header.trim() === '') return false;
|
|
502
|
+
let value = header.trim().toLowerCase();
|
|
503
|
+
// IPv6 形如 [::1]:1875,先剥掉端口
|
|
504
|
+
if (value.startsWith('[')) value = value.slice(0, value.indexOf(']') + 1);
|
|
505
|
+
else value = value.split(':')[0];
|
|
506
|
+
return LOOPBACK_HOSTS.has(value);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function originIsLoopback(header) {
|
|
510
|
+
// 没有 Origin 说明不是跨源浏览器请求(同源导航、curl、脚本),交给 Host 校验兜住。
|
|
511
|
+
if (typeof header !== 'string' || header.trim() === '') return true;
|
|
512
|
+
try {
|
|
513
|
+
const url = new URL(header.trim());
|
|
514
|
+
return LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
440
520
|
function readJson(req) {
|
|
441
521
|
return new Promise((resolve, reject) => {
|
|
442
522
|
let size = 0;
|
|
@@ -475,5 +555,13 @@ function sendJson(res, status, payload) {
|
|
|
475
555
|
res.end(body);
|
|
476
556
|
}
|
|
477
557
|
|
|
478
|
-
/**
|
|
479
|
-
export const __test = {
|
|
558
|
+
/** 仅供单元测试:时区换算、到期判定与管理面来源校验是最容易出错的三块。 */
|
|
559
|
+
export const __test = {
|
|
560
|
+
nowIn,
|
|
561
|
+
isDue,
|
|
562
|
+
alreadyRanToday,
|
|
563
|
+
normalizeSchedule,
|
|
564
|
+
readScheduleState,
|
|
565
|
+
writeScheduleState,
|
|
566
|
+
denyUntrustedRequest,
|
|
567
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qq-digest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "只读的 DSH QQ 摘要插件:群聊默认全开、私聊默认全关且只能逐条打开;MCP 四个只读工具 + 设置页(选群/起名/定时/立即总结),零发送能力",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"mcp"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=20"
|
|
29
|
+
"node": ">=20",
|
|
30
|
+
"dsh": ">=0.2.0-0 <0.3.0"
|
|
30
31
|
},
|
|
31
32
|
"main": "lib/index.js",
|
|
32
33
|
"exports": {
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"files": [
|
|
52
53
|
"lib",
|
|
53
54
|
"src",
|
|
55
|
+
"scripts",
|
|
54
56
|
"install",
|
|
55
57
|
"cordis.patch.yml",
|
|
56
58
|
"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 违规。`);
|