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.
@@ -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);
package/src/core/gate.js CHANGED
@@ -26,10 +26,23 @@ export const REASON = Object.freeze({
26
26
  GROUP_DISABLED: 'GROUP_DISABLED',
27
27
  });
28
28
 
29
+ /** 读取句柄:拿到它意味着「当前策略放行这个房间」。 */
29
30
  export function roomHandle(secret, canonicalId) {
30
31
  return createHmac('sha256', secret).update(`room:${canonicalId}`).digest('hex').slice(0, 16);
31
32
  }
32
33
 
34
+ /**
35
+ * 审计引用:与读取句柄**必须不同域**。
36
+ *
37
+ * 拒绝分支也要能回一个稳定的、可对账但不泄漏标识的引用;但如果直接复用 roomHandle,
38
+ * 拒绝就等于把「读取凭据」发给了被拒绝的一方 —— 模型可以从 denied[] 里取到被拒绝房间的
39
+ * 句柄,再去读该房间的历史摘要,撤销语义直接失效。
40
+ * 因此审计引用走 `audit:` 域,它**不能**用来读任何东西。
41
+ */
42
+ export function auditRefFor(secret, canonicalId) {
43
+ return createHmac('sha256', secret).update(`audit:${canonicalId}`).digest('hex').slice(0, 16);
44
+ }
45
+
33
46
  export function createGate({ secret }) {
34
47
  if (typeof secret !== 'string' || secret.length < 16) {
35
48
  throw new Error('GATE_SECRET_TOO_SHORT');
@@ -47,7 +60,8 @@ export function createGate({ secret }) {
47
60
  }
48
61
 
49
62
  const { canonicalId, kind } = canon;
50
- const auditRef = roomHandle(secret, canonicalId);
63
+ // 审计引用与读取句柄分属两个 HMAC 域:拒绝时只发放 auditRef,它读不了任何东西。
64
+ const auditRef = auditRefFor(secret, canonicalId);
51
65
 
52
66
  // 交叉校验:声明形态必须与数据源给出的权威类型一致,不一致即拒。
53
67
  if (authoritativeKind !== undefined && authoritativeKind !== null && authoritativeKind !== kind) {
@@ -68,7 +82,7 @@ export function createGate({ secret }) {
68
82
  return Object.freeze({
69
83
  verdict: VERDICT.ALLOW,
70
84
  kind,
71
- roomHandle: auditRef,
85
+ roomHandle: roomHandle(secret, canonicalId),
72
86
  auditRef,
73
87
  });
74
88
  };
package/src/mcp-server.js CHANGED
@@ -17,15 +17,28 @@
17
17
  */
18
18
 
19
19
  import { createInterface } from 'node:readline';
20
+ import { readFileSync } from 'node:fs';
20
21
 
21
22
  import { createRuntime } from './runtime.js';
22
23
  import { withRunLock } from './run-lock.js';
23
24
  import { createToolHandlers } from './tools.js';
24
25
  import { TOOLS } from './core/toolSurface.js';
25
26
 
27
+ /** 版本只有一个真相源:package.json。硬编码会漂(serverInfo 与包版本打对不上)。 */
28
+ function readPackageVersion() {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
31
+ return typeof parsed?.version === 'string' ? parsed.version : '0.0.0';
32
+ } catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+
37
+ const VERSION = readPackageVersion();
38
+
26
39
  const runtime = createRuntime();
27
40
  process.stderr.write(
28
- `[qq-digest] source=${runtime.source} data=${runtime.dataDir} llmKey=${runtime.llm.apiKeySource}\n`,
41
+ `[qq-digest] source=${runtime.source} data=${runtime.dataDir} llmKey=${runtime.llm.apiKeySource} secretSource=${runtime.secretSource}\n`,
29
42
  );
30
43
 
31
44
  // run_summary 走跨进程锁;拿不到锁如实返回 ALREADY_RUNNING,而不是重复跑一轮。
@@ -63,7 +76,7 @@ rl.on('line', async (line) => {
63
76
  result: {
64
77
  protocolVersion: '2024-11-05',
65
78
  capabilities: { tools: {} },
66
- serverInfo: { name: 'qq-digest', version: '0.2.0' },
79
+ serverInfo: { name: 'qq-digest', version: VERSION },
67
80
  },
68
81
  });
69
82
  return;
package/src/run-lock.js CHANGED
@@ -8,10 +8,17 @@
8
8
  * 超过 lease 时限视为陈旧锁并抢占,避免进程崩溃后永久卡死。
9
9
  */
10
10
 
11
- import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
 
14
14
  const DEFAULT_LEASE_MS = 10 * 60_000;
15
+ /**
16
+ * 心跳间隔。租期只有 10 分钟,而一轮最坏可能跑几个小时
17
+ * (200 个房间上限 × LLM 单次 60s 超时 + 桥接 15s),所以必须续租:
18
+ * 否则「还在跑」的锁会被下一个触发者判成陈旧锁抢占,两轮并发写水位线,
19
+ * 表现为重复摘要与水位线互相覆盖 —— 也就是锁存在的唯一理由失效。
20
+ */
21
+ const DEFAULT_HEARTBEAT_MS = 60_000;
15
22
 
16
23
  export function lockPath(dir) {
17
24
  return join(dir, 'run.lock');
@@ -22,18 +29,43 @@ export function lockPath(dir) {
22
29
  * @param {{ leaseMs?: number, now?: () => number }} [options]
23
30
  */
24
31
  export function acquireRunLock(dir, options = {}) {
25
- const { leaseMs = DEFAULT_LEASE_MS, now = () => Date.now() } = options;
32
+ const {
33
+ leaseMs = DEFAULT_LEASE_MS,
34
+ heartbeatMs = DEFAULT_HEARTBEAT_MS,
35
+ now = () => Date.now(),
36
+ } = options;
26
37
  const path = lockPath(dir);
27
38
  const owner = { pid: process.pid, at: now() };
28
39
 
29
40
  for (let attempt = 0; attempt < 2; attempt += 1) {
30
41
  try {
31
42
  mkdirSync(path);
32
- writeFileSync(join(path, 'owner.json'), `${JSON.stringify(owner)}\n`, 'utf8');
43
+ const ownerFile = join(path, 'owner.json');
44
+ writeFileSync(ownerFile, `${JSON.stringify(owner)}\n`, 'utf8');
45
+
46
+ // 续租:age 判定看的是**目录** mtime,所以心跳除了刷新 owner.json 还要 touch 目录本身。
47
+ let timer = null;
48
+ if (heartbeatMs > 0) {
49
+ const period = Math.max(1_000, Math.min(heartbeatMs, Math.floor(leaseMs / 2)));
50
+ timer = setInterval(() => {
51
+ try {
52
+ const beat = { pid: process.pid, at: now() };
53
+ writeFileSync(ownerFile, `${JSON.stringify(beat)}\n`, 'utf8');
54
+ const at = new Date();
55
+ utimesSync(path, at, at);
56
+ } catch {
57
+ // 心跳失败不该让整轮失败:下一轮最多被判成陈旧锁,行为与没有心跳时相同。
58
+ }
59
+ }, period);
60
+ timer.unref?.();
61
+ }
62
+
33
63
  return {
34
64
  ok: true,
35
65
  path,
66
+ owner,
36
67
  release() {
68
+ if (timer) clearInterval(timer);
37
69
  try {
38
70
  rmSync(path, { recursive: true, force: true });
39
71
  } catch {
package/src/runtime.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * DSH_HOME 空白视为未设置,绝不退回 cwd(否则换个目录启动就多一份空数据)。
9
9
  */
10
10
 
11
- import { readFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { randomBytes } from 'node:crypto';
12
13
  import { homedir } from 'node:os';
13
14
  import { join, resolve } from 'node:path';
14
15
 
@@ -20,11 +21,11 @@ import { configPath, loadConfig } from './config.js';
20
21
  import { createSummarizerFromConfig } from './summarizer-factory.js';
21
22
  import { createService } from './service.js';
22
23
  import { resolveLlmConfig } from './llm-config.mjs';
23
-
24
- export const DEFAULT_SECRET = 'qq-digest-local-dev-secret-0123456789';
25
-
26
24
  import { resolveBridgePaths } from './bridge-paths.js';
27
25
 
26
+ export const HANDLE_SECRET_FILE = 'handle-secret';
27
+ export const MIN_SECRET_LENGTH = 16;
28
+
28
29
  export function resolveDshHome(env = process.env) {
29
30
  const fromEnv = env.DSH_HOME;
30
31
  if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
@@ -37,9 +38,48 @@ export function resolveDataDir(env = process.env) {
37
38
  return join(resolveDshHome(env), 'qq-digest');
38
39
  }
39
40
 
40
- export function resolveSecret(env = process.env) {
41
- const value = env.QQ_DIGEST_SECRET;
42
- return typeof value === 'string' && value.length >= 16 ? value : DEFAULT_SECRET;
41
+ export function secretPath(dataDir) {
42
+ return join(dataDir, HANDLE_SECRET_FILE);
43
+ }
44
+
45
+ /**
46
+ * 解析句柄 HMAC 密钥,并如实说明来源。
47
+ *
48
+ * 为什么不再有「默认常量」:常量写死在源码里,等于句柄可离线计算 ——
49
+ * 知道群号/QQ 号的人就能算出 handle 去定向读摘要,不透明句柄这层保护归零。
50
+ *
51
+ * 优先级:QQ_DIGEST_SECRET(>=16 字符)→ <dataDir>/handle-secret → 首次启动随机生成并落盘。
52
+ * 必须落盘:只存内存会让每次重启都换密钥,水位线(按句柄作键)全部作废,等于每次重启全量重跑。
53
+ */
54
+ export function resolveSecretInfo(env = process.env, dataDir = resolveDataDir(env)) {
55
+ const fromEnv = env.QQ_DIGEST_SECRET;
56
+ if (typeof fromEnv === 'string' && fromEnv.trim().length >= MIN_SECRET_LENGTH) {
57
+ return { secret: fromEnv.trim(), source: 'env' };
58
+ }
59
+
60
+ const file = secretPath(dataDir);
61
+ try {
62
+ const existing = readFileSync(file, 'utf8').trim();
63
+ if (existing.length >= MIN_SECRET_LENGTH) return { secret: existing, source: 'file' };
64
+ } catch {
65
+ // 文件不存在 → 下面生成
66
+ }
67
+
68
+ const generated = randomBytes(32).toString('hex');
69
+ try {
70
+ mkdirSync(dataDir, { recursive: true });
71
+ writeFileSync(file, `${generated}\n`, { encoding: 'utf8', mode: 0o600 });
72
+ } catch (err) {
73
+ // 写不进去就拒绝启动:宁可工具不可用,也不要用一个「一看就知道」的密钥继续跑。
74
+ const failure = new Error(`HANDLE_SECRET_UNWRITABLE:${file}:${err?.message ?? err}`);
75
+ failure.code = 'HANDLE_SECRET_UNWRITABLE';
76
+ throw failure;
77
+ }
78
+ return { secret: generated, source: 'generated' };
79
+ }
80
+
81
+ export function resolveSecret(env = process.env, dataDir = resolveDataDir(env)) {
82
+ return resolveSecretInfo(env, dataDir).secret;
43
83
  }
44
84
 
45
85
  export function readBridgeToken(tokenFile) {
@@ -93,10 +133,14 @@ export function createRuntime(options = {}) {
93
133
 
94
134
  const store = options.store ?? createFileStore({ dir: dataDir });
95
135
  const watermark = options.watermark ?? createFileWatermark({ dir: dataDir });
136
+ const secretInfo = options.secret
137
+ ? { secret: options.secret, source: 'explicit' }
138
+ : resolveSecretInfo(env, dataDir);
96
139
 
97
140
  const service = createService({
98
141
  adapter,
99
- secret: options.secret ?? resolveSecret(env),
142
+ secret: secretInfo.secret,
143
+ secretSource: secretInfo.source,
100
144
  store,
101
145
  watermark,
102
146
  getPolicy: () => readConfig().policy,
@@ -112,5 +156,17 @@ export function createRuntime(options = {}) {
112
156
  summarize: (input) => createSummarizerFromConfig(readConfig().config, { llm })(input),
113
157
  });
114
158
 
115
- return { dataDir, source, adapter, llm, store, watermark, readConfig, configPath: configPath(dataDir), service };
159
+ return {
160
+ dataDir,
161
+ source,
162
+ adapter,
163
+ llm,
164
+ store,
165
+ watermark,
166
+ readConfig,
167
+ configPath: configPath(dataDir),
168
+ service,
169
+ secret: secretInfo.secret,
170
+ secretSource: secretInfo.source,
171
+ };
116
172
  }
package/src/service.js CHANGED
@@ -40,6 +40,8 @@ export function createService(options = {}) {
40
40
  getPolicy: externalPolicyProvider,
41
41
  // 桥接不提供群名,允许外部按 rawId 补一个显示名(只用于展示,不参与裁决)。
42
42
  titleProvider = null,
43
+ // 密钥来源:env | generated | file | unknown。只用于如实上报,绝不外传值。
44
+ secretSource = 'unknown',
43
45
  } = options;
44
46
 
45
47
  if (!adapter) throw new Error('SERVICE_MISSING_ADAPTER');
@@ -142,16 +144,62 @@ export function createService(options = {}) {
142
144
  }
143
145
 
144
146
  function getDigest(handle) {
145
- if (handle !== undefined && handle !== null && handle !== '') {
146
- if (typeof handle !== 'string' || !/^[0-9a-f]{16}$/.test(handle)) {
147
- return { status: 'REJECTED', reason: 'INVALID_HANDLE' };
148
- }
147
+ return readDigest(handle);
148
+ }
149
+
150
+ /**
151
+ * 读取已落盘摘要。
152
+ *
153
+ * 关键约束:**读取也必须过闸门**。
154
+ * 句柄是不透明令牌,但它只证明「签发时策略放行过这个房间」,不证明「现在仍然放行」。
155
+ * 因此每次读取都先按当前策略对数据源现算一遍允许集,再与存储求交 ——
156
+ * 关掉某个群 / 关掉总开关之后,历史摘要立即读不到(撤销语义成立)。
157
+ *
158
+ * 数据源不可用时不允许「先读再说」:无法确认当前允许谁,就一律拒绝。
159
+ */
160
+ async function readDigest(handle) {
161
+ const hasHandle = handle !== undefined && handle !== null && handle !== '';
162
+ if (hasHandle && (typeof handle !== 'string' || !/^[0-9a-f]{16}$/.test(handle))) {
163
+ return { status: 'REJECTED', reason: 'INVALID_HANDLE' };
164
+ }
165
+
166
+ const allowed = await currentAllowedHandles();
167
+ if (!allowed.ok) {
168
+ return {
169
+ status: 'DENIED',
170
+ reason: 'SOURCE_UNAVAILABLE',
171
+ detail: allowed.error?.code ?? null,
172
+ };
173
+ }
174
+
175
+ if (hasHandle) {
176
+ // 不在允许集里,与「没有摘要」返回同一个结果:不告诉调用方它是否存在。
177
+ if (!allowed.handles.has(handle)) return { status: 'NOT_FOUND' };
149
178
  const entry = store.latest(handle);
150
179
  return entry ? { status: 'OK', entry } : { status: 'NOT_FOUND' };
151
180
  }
152
181
 
153
182
  const rows = typeof store.latestPerRoom === 'function' ? store.latestPerRoom() : dedupeLatest(store.all());
154
- return { status: 'OK', count: rows.length, items: rows };
183
+ const visible = rows.filter((row) => allowed.handles.has(row.roomHandle));
184
+ return { status: 'OK', count: visible.length, items: visible };
185
+ }
186
+
187
+ /** 按当前策略现算「此刻允许读」的句柄集合。数据源失败时返回 ok:false。 */
188
+ async function currentAllowedHandles() {
189
+ const policy = resolvePolicy();
190
+ let rooms;
191
+ try {
192
+ rooms = await adapter.listRooms();
193
+ } catch (err) {
194
+ return { ok: false, error: { code: err?.code ?? 'ADAPTER_ERROR', message: String(err?.message ?? err) } };
195
+ }
196
+
197
+ const handles = new Set();
198
+ for (const room of rooms) {
199
+ const decision = gate({ policy, rawId: room.rawId, authoritativeKind: room.authoritativeKind });
200
+ if (decision.verdict === 'ALLOW') handles.add(decision.roomHandle);
201
+ }
202
+ return { ok: true, handles, policy };
155
203
  }
156
204
 
157
205
  async function status() {
@@ -173,8 +221,10 @@ export function createService(options = {}) {
173
221
  adapterError,
174
222
  digestCount: store.all().length,
175
223
  lastRun,
176
- // 句柄密钥只报「是否设置」,绝不报值。
177
- secretConfigured: typeof secret === 'string' && secret.length >= 16,
224
+ // 句柄密钥只报「是否设置」与「来源」,绝不报值。
225
+ // 注意:源码里不再有默认常量,因此 secretConfigured 不会再出现「恒真」的假保证。
226
+ secretConfigured: typeof secret === 'string' && secret.length >= 16 && secretSource !== 'default',
227
+ secretSource,
178
228
  };
179
229
  }
180
230