dsh-qqbot-guard 1.0.0 → 1.1.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 CHANGED
@@ -50,8 +50,9 @@ mentions 后从正文摘掉。
50
50
  2. 装本插件:市场安装,或 `dsh plugin --profile web add dsh-qqbot-guard`;
51
51
  3. 本包自带 `dsh.bundle.patch`(`cordis.patch.yml`,用 `- insert:` 插入 `qqbot-guard` 行),
52
52
  被列入 `dsh.profile.bundles` 后会自动生效——**不要**在 profile 的 `cordis.patch.yml` 里再写一条同 id 的新行;
53
- 4. 需要改参数时,二选一:
54
- - **JSON 后门(手机端推荐)**:编辑 `$DSH_HOME/qqbot-guard.json`
53
+ 4. 需要改参数时,三选一(最终都落到同一个 `$DSH_HOME/qqbot-guard.json`):
54
+ - **设置页(推荐,手机端也能用)**:DSH 里打开「设置 → QQ 守护」,改完点保存,**立即生效、不用重启**;
55
+ - **JSON 后门**:编辑 `$DSH_HOME/qqbot-guard.json`
55
56
  (App 里是 `/root/.dsh/qqbot-guard.json`,首次启动由 `PluginSeed` 生成);
56
57
  - **profile patch 覆盖**(能写 YAML 时):
57
58
 
@@ -120,6 +121,20 @@ mentions 后从正文摘掉。
120
121
  配置优先级:`DEFAULT_CONFIG` < 外部 JSON 文件 < cordis 里的 `config`。JSON 解析失败只会打一条
121
122
  warn 然后忽略,不会让插件挂掉——手机上手改坏了不会变砖。
122
123
 
124
+ ## 设置页(可视化配置)
125
+
126
+ 包导出 `./client`(`dsh.client.platform = web`),在 DSH 里注册一个设置板块「QQ 守护」:
127
+
128
+ - 顶部状态行:qqbot 是否定位到、装在哪、SDK 版本、history 补丁是否生效——`找不到 qqbot` 这类
129
+ 故障不用再翻 App 日志;
130
+ - 关键词用每行一个的 textarea 编辑(空行忽略、自动去重),开关直接点;
131
+ - 保存写的还是 `$DSH_HOME/qqbot-guard.json`(原子写:临时文件 + rename,不会写坏);
132
+ - guard 每收一条消息前检查文件指纹(mtime + size),变了就重新读——**保存即生效**,
133
+ 在设置页和手改 JSON 之间来回切换也不会打架。
134
+
135
+ 接口是同源的 `GET/PUT /qqbot-guard/config`:只接受回环地址的 Host、拒绝跨站 Origin,
136
+ 请求体上限 64 KB,未知字段直接 400(防止手滑写进错字段还静默忽略)。
137
+
123
138
  ## 自检
124
139
 
125
140
  ```bash
@@ -0,0 +1,482 @@
1
+ /**
2
+ * qqbot-guard 的客户端半侧:在 DSH「设置」里加一个「QQ 守护」板块。
3
+ *
4
+ * 手写、不打包:DSH 的模块加载器按约定加载 `client.js`(package.json 的 `exports["./client"]`),
5
+ * 内容就是 `window.__ModuleLoader__.load({ id, factory })` 包一层 CJS 工厂;
6
+ * React 由宿主通过 `require('react')` 提供,所以这里不需要 node_modules。
7
+ *
8
+ * 数据全部走宿主同源的 `/qqbot-guard/config`(服务端在 index.js 的 installConfigApi 里注册),
9
+ * 保存后 guard 端会按文件 mtime 热重载,下一条群消息就生效;只有「总开关」与附件下载相关
10
+ * 的项需要重启插件才生效,UI 上会单独提示。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-qqbot-guard',
14
+ factory: (require) => {
15
+ const React = require('react');
16
+ const h = React.createElement;
17
+ const { useCallback, useEffect, useMemo, useState } = React;
18
+
19
+ const NS = 'qqbot-guard';
20
+ const API = '/qqbot-guard/config';
21
+
22
+ const zh = {
23
+ nav: 'QQ 守护',
24
+ title: 'QQ 守护',
25
+ intro: '群消息不 @ 也能触发 QQ 机器人:正文命中关键词时自动补一条「被 @」标记,并顺带带上群历史。',
26
+ keywords: '关键词',
27
+ keywordsHint: '一行一个;正文包含任一词就当作在跟你说话。留空表示关闭关键词放行。',
28
+ switches: '开关',
29
+ enabled: '启用守护',
30
+ enabledHint: '关掉后 qqbot 恢复原样。修改后需要重启插件才生效。',
31
+ faces: '表情转文字',
32
+ facesHint: '把新版表情标签换成 emoji,历史里不再是一串标签。',
33
+ historyAttachments: '历史附件摘要',
34
+ historyAttachmentsHint: '群历史只存正文,开启后补一句附件说明。',
35
+ historyMedia: '预下载历史附件',
36
+ historyMediaHint: '没触发关键词的群消息也把图片/文件落盘;重启后生效。',
37
+ ignoreCase: '忽略大小写',
38
+ verbose: '诊断日志',
39
+ advanced: '高级',
40
+ qqbotPackage: 'qqbot 安装目录',
41
+ qqbotPackageHint: '留空自动探测;一般不用填。',
42
+ maxMB: '预下载上限(MB)',
43
+ save: '保存',
44
+ saving: '保存中…',
45
+ saved: '已保存,下一条消息生效',
46
+ reload: '重新加载',
47
+ loading: '读取配置中…',
48
+ failed: '读取失败',
49
+ status: '运行状态',
50
+ location: 'qqbot 位置',
51
+ notFound: '没找到',
52
+ installed: '补丁已挂载',
53
+ notInstalled: '补丁未挂载',
54
+ restartNeeded: '总开关/预下载的改动需要重启插件才生效。',
55
+ overridden: '这些字段被 cordis 配置覆盖,改这里不会生效:',
56
+ file: '配置文件',
57
+ };
58
+
59
+ const en = {
60
+ nav: 'QQ Guard',
61
+ title: 'QQ Guard',
62
+ intro: 'Let group messages reach the QQ bot without an @ mention: keyword hits get a synthetic mention and pull in group history.',
63
+ keywords: 'Keywords',
64
+ keywordsHint: 'One per line. Any hit counts as talking to you. Leave empty to disable keyword gating.',
65
+ switches: 'Switches',
66
+ enabled: 'Enable guard',
67
+ enabledHint: 'When off, qqbot behaves as stock. Takes effect after the plugin restarts.',
68
+ faces: 'Emoji tags to text',
69
+ facesHint: 'Rewrite <faceType=…> tags into emoji in history.',
70
+ historyAttachments: 'History attachment notes',
71
+ historyAttachmentsHint: 'Append a short attachment note when reading group history.',
72
+ historyMedia: 'Prefetch history media',
73
+ historyMediaHint: 'Download media for messages that never triggered a keyword.',
74
+ ignoreCase: 'Ignore case',
75
+ verbose: 'Diagnostic logs',
76
+ advanced: 'Advanced',
77
+ qqbotPackage: 'qqbot install dir',
78
+ qqbotPackageHint: 'Leave empty to auto-detect.',
79
+ maxMB: 'Prefetch limit (MB)',
80
+ save: 'Save',
81
+ saving: 'Saving…',
82
+ saved: 'Saved — applies to the next message',
83
+ reload: 'Reload',
84
+ loading: 'Loading…',
85
+ failed: 'Failed to load',
86
+ status: 'Status',
87
+ location: 'qqbot location',
88
+ notFound: 'not found',
89
+ installed: 'Patch mounted',
90
+ notInstalled: 'Patch not mounted',
91
+ restartNeeded: 'Changes to the master switch or prefetch need a plugin restart.',
92
+ overridden: 'These fields are set by the cordis config and ignore this page:',
93
+ file: 'Config file',
94
+ };
95
+
96
+ const S = {
97
+ root: { display: 'flex', flexDirection: 'column', gap: 18, padding: '4px 2px 28px' },
98
+ intro: { margin: 0, fontSize: 13, lineHeight: 1.7, opacity: 0.86 },
99
+ card: {
100
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.28))',
101
+ borderRadius: 12,
102
+ padding: '14px 16px',
103
+ display: 'flex',
104
+ flexDirection: 'column',
105
+ gap: 12,
106
+ },
107
+ cardTitle: {
108
+ fontSize: 12,
109
+ fontWeight: 600,
110
+ letterSpacing: '.08em',
111
+ textTransform: 'uppercase',
112
+ opacity: 0.62,
113
+ margin: 0,
114
+ },
115
+ field: { display: 'flex', flexDirection: 'column', gap: 6 },
116
+ label: { fontSize: 13, fontWeight: 600 },
117
+ hint: { fontSize: 12, lineHeight: 1.6, opacity: 0.68, margin: 0 },
118
+ textarea: {
119
+ width: '100%',
120
+ minHeight: 96,
121
+ boxSizing: 'border-box',
122
+ padding: '10px 12px',
123
+ borderRadius: 10,
124
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.32))',
125
+ background: 'var(--dsh-surface, transparent)',
126
+ color: 'inherit',
127
+ font: 'inherit',
128
+ fontSize: 13,
129
+ lineHeight: 1.6,
130
+ resize: 'vertical',
131
+ },
132
+ input: {
133
+ width: '100%',
134
+ boxSizing: 'border-box',
135
+ padding: '8px 12px',
136
+ borderRadius: 10,
137
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.32))',
138
+ background: 'var(--dsh-surface, transparent)',
139
+ color: 'inherit',
140
+ font: 'inherit',
141
+ fontSize: 13,
142
+ },
143
+ row: {
144
+ display: 'flex',
145
+ alignItems: 'center',
146
+ justifyContent: 'space-between',
147
+ gap: 12,
148
+ padding: '6px 0',
149
+ borderTop: '1px solid var(--dsh-border, rgba(127,127,127,.16))',
150
+ },
151
+ rowText: { display: 'flex', flexDirection: 'column', gap: 2 },
152
+ switchWrap: { display: 'flex', alignItems: 'center', gap: 8, flex: '0 0 auto' },
153
+ footer: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' },
154
+ button: {
155
+ padding: '8px 18px',
156
+ borderRadius: 10,
157
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.32))',
158
+ background: 'var(--dsh-accent, #4c6ef5)',
159
+ color: '#fff',
160
+ font: 'inherit',
161
+ fontSize: 13,
162
+ fontWeight: 600,
163
+ cursor: 'pointer',
164
+ },
165
+ buttonGhost: {
166
+ padding: '8px 14px',
167
+ borderRadius: 10,
168
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.32))',
169
+ background: 'transparent',
170
+ color: 'inherit',
171
+ font: 'inherit',
172
+ fontSize: 13,
173
+ cursor: 'pointer',
174
+ },
175
+ badge: {
176
+ display: 'inline-flex',
177
+ alignItems: 'center',
178
+ gap: 6,
179
+ padding: '2px 9px',
180
+ borderRadius: 999,
181
+ fontSize: 12,
182
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.32))',
183
+ opacity: 0.9,
184
+ },
185
+ notice: { fontSize: 12.5, lineHeight: 1.6, margin: 0 },
186
+ mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12, wordBreak: 'break-all' },
187
+ };
188
+
189
+ const TOGGLE_ROWS = [
190
+ ['faces', 'faces', 'facesHint', false],
191
+ ['historyAttachments', 'historyAttachments', 'historyAttachmentsHint', false],
192
+ ['historyMedia', 'historyMedia', 'historyMediaHint', true],
193
+ ['ignoreCase', 'ignoreCase', null, false],
194
+ ['verbose', 'verbose', null, false],
195
+ ];
196
+
197
+ function Toggle({ id, checked, onChange, disabled }) {
198
+ return h('input', {
199
+ id,
200
+ type: 'checkbox',
201
+ checked,
202
+ disabled,
203
+ onChange: (event) => onChange(event.target.checked),
204
+ style: { width: 16, height: 16, cursor: disabled ? 'default' : 'pointer' },
205
+ });
206
+ }
207
+
208
+ function Section({ t }) {
209
+ const [loading, setLoading] = useState(true);
210
+ const [saving, setSaving] = useState(false);
211
+ const [error, setError] = useState('');
212
+ const [notice, setNotice] = useState('');
213
+ const [file, setFile] = useState('');
214
+ const [status, setStatus] = useState({});
215
+ const [config, setConfig] = useState(null);
216
+ const [keywords, setKeywords] = useState('');
217
+ const [overridden, setOverridden] = useState([]);
218
+
219
+ const applyPayload = useCallback((payload) => {
220
+ const cfg = payload?.config ?? {};
221
+ setConfig(cfg);
222
+ setFile(payload?.file ?? '');
223
+ setStatus(payload?.status ?? {});
224
+ setOverridden(Array.isArray(payload?.overridden) ? payload.overridden : []);
225
+ setKeywords(Array.isArray(cfg.triggers) ? cfg.triggers.join('\n') : '');
226
+ }, []);
227
+
228
+ const load = useCallback(async () => {
229
+ setError('');
230
+ try {
231
+ const response = await fetch(API, { headers: { accept: 'application/json' } });
232
+ const payload = await response.json().catch(() => ({}));
233
+ if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
234
+ applyPayload(payload);
235
+ } catch (err) {
236
+ setError(String(err?.message ?? err));
237
+ } finally {
238
+ setLoading(false);
239
+ }
240
+ }, [applyPayload]);
241
+
242
+ useEffect(() => {
243
+ void load();
244
+ }, [load]);
245
+
246
+ const patch = useCallback((key, value) => {
247
+ setConfig((prev) => ({ ...(prev ?? {}), [key]: value }));
248
+ setNotice('');
249
+ }, []);
250
+
251
+ const save = useCallback(async () => {
252
+ if (!config) return;
253
+ setSaving(true);
254
+ setError('');
255
+ setNotice('');
256
+ try {
257
+ const body = {
258
+ triggers: keywords
259
+ .split(/\r?\n|,|,/)
260
+ .map((s) => s.trim())
261
+ .filter(Boolean),
262
+ };
263
+ for (const key of [
264
+ 'enabled',
265
+ 'ignoreCase',
266
+ 'faces',
267
+ 'historyAttachments',
268
+ 'historyMedia',
269
+ 'verbose',
270
+ 'strictVersion',
271
+ ]) {
272
+ if (typeof config[key] === 'boolean') body[key] = config[key];
273
+ }
274
+ if (config.historyMediaMaxMB !== undefined) body.historyMediaMaxMB = config.historyMediaMaxMB;
275
+ if (typeof config.qqbotPackage === 'string') body.qqbotPackage = config.qqbotPackage;
276
+ const response = await fetch(API, {
277
+ method: 'PUT',
278
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
279
+ body: JSON.stringify(body),
280
+ });
281
+ const payload = await response.json().catch(() => ({}));
282
+ if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
283
+ applyPayload(payload);
284
+ setNotice(t('saved'));
285
+ } catch (err) {
286
+ setError(String(err?.message ?? err));
287
+ } finally {
288
+ setSaving(false);
289
+ }
290
+ }, [applyPayload, config, keywords, t]);
291
+
292
+ const statusBits = useMemo(() => {
293
+ if (!status) return [];
294
+ const bits = [];
295
+ if (status.installed) bits.push({ key: 'installed', text: t('installed'), tone: 'ok' });
296
+ else if (status.reason) bits.push({ key: 'idle', text: status.reason, tone: 'warn' });
297
+ return bits;
298
+ }, [status, t]);
299
+
300
+ if (loading) {
301
+ return h('div', { style: S.root }, h('p', { style: S.hint }, t('loading')));
302
+ }
303
+
304
+ if (!config) {
305
+ return h(
306
+ 'div',
307
+ { style: S.root },
308
+ h('p', { style: S.notice, role: 'alert' }, `${t('failed')}: ${error || 'unknown'}`),
309
+ h('button', { type: 'button', style: S.buttonGhost, onClick: () => void load() }, t('reload')),
310
+ );
311
+ }
312
+
313
+ return h(
314
+ 'div',
315
+ { style: S.root },
316
+ h('p', { style: S.intro }, t('intro')),
317
+
318
+ h(
319
+ 'section',
320
+ { style: S.card },
321
+ h('h3', { style: S.cardTitle }, t('status')),
322
+ h(
323
+ 'div',
324
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
325
+ statusBits.map((bit) =>
326
+ h('span', {
327
+ key: bit.key,
328
+ style: { ...S.badge, borderColor: bit.tone === 'ok' ? 'rgba(64,160,96,.55)' : 'rgba(214,140,60,.55)' },
329
+ }, bit.text),
330
+ ),
331
+ ),
332
+ h(
333
+ 'p',
334
+ { style: S.hint },
335
+ h('span', null, `${t('location')}: `),
336
+ h('span', { style: S.mono }, status?.qqbot || t('notFound')),
337
+ ),
338
+ file
339
+ ? h(
340
+ 'p',
341
+ { style: S.hint },
342
+ h('span', null, `${t('file')}: `),
343
+ h('span', { style: S.mono }, file),
344
+ )
345
+ : null,
346
+ ),
347
+
348
+ h(
349
+ 'section',
350
+ { style: S.card },
351
+ h('h3', { style: S.cardTitle }, t('keywords')),
352
+ h('textarea', {
353
+ style: S.textarea,
354
+ value: keywords,
355
+ spellCheck: false,
356
+ placeholder: '大肥鱼\n肥鱼\n鱼鱼',
357
+ onChange: (event) => {
358
+ setKeywords(event.target.value);
359
+ setNotice('');
360
+ },
361
+ }),
362
+ h('p', { style: S.hint }, t('keywordsHint')),
363
+ ),
364
+
365
+ h(
366
+ 'section',
367
+ { style: S.card },
368
+ h('h3', { style: S.cardTitle }, t('switches')),
369
+ h(
370
+ 'div',
371
+ { style: { ...S.row, borderTop: 'none' } },
372
+ h(
373
+ 'label',
374
+ { htmlFor: 'qqbot-guard-enabled', style: S.rowText },
375
+ h('span', { style: S.label }, t('enabled')),
376
+ h('span', { style: S.hint }, t('enabledHint')),
377
+ ),
378
+ h(Toggle, {
379
+ id: 'qqbot-guard-enabled',
380
+ checked: config.enabled !== false,
381
+ onChange: (value) => patch('enabled', value),
382
+ }),
383
+ ),
384
+ TOGGLE_ROWS.map(([key, labelKey, hintKey]) =>
385
+ h(
386
+ 'div',
387
+ { key, style: S.row },
388
+ h(
389
+ 'label',
390
+ { htmlFor: `qqbot-guard-${key}`, style: S.rowText },
391
+ h('span', { style: S.label }, t(labelKey)),
392
+ hintKey ? h('span', { style: S.hint }, t(hintKey)) : null,
393
+ ),
394
+ h(Toggle, {
395
+ id: `qqbot-guard-${key}`,
396
+ checked: config[key] !== false,
397
+ onChange: (value) => patch(key, value),
398
+ }),
399
+ ),
400
+ ),
401
+ ),
402
+
403
+ h(
404
+ 'section',
405
+ { style: S.card },
406
+ h('h3', { style: S.cardTitle }, t('advanced')),
407
+ h(
408
+ 'div',
409
+ { style: S.field },
410
+ h('label', { style: S.label, htmlFor: 'qqbot-guard-pkg' }, t('qqbotPackage')),
411
+ h('input', {
412
+ id: 'qqbot-guard-pkg',
413
+ style: S.input,
414
+ value: config.qqbotPackage ?? '',
415
+ placeholder: 'auto',
416
+ onChange: (event) => patch('qqbotPackage', event.target.value),
417
+ }),
418
+ h('p', { style: S.hint }, t('qqbotPackageHint')),
419
+ ),
420
+ h(
421
+ 'div',
422
+ { style: S.field },
423
+ h('label', { style: S.label, htmlFor: 'qqbot-guard-maxmb' }, t('maxMB')),
424
+ h('input', {
425
+ id: 'qqbot-guard-maxmb',
426
+ type: 'number',
427
+ min: 1,
428
+ max: 1024,
429
+ style: S.input,
430
+ value: config.historyMediaMaxMB ?? 20,
431
+ onChange: (event) => patch('historyMediaMaxMB', Number(event.target.value)),
432
+ }),
433
+ ),
434
+ ),
435
+
436
+ h(
437
+ 'div',
438
+ { style: S.footer },
439
+ h(
440
+ 'button',
441
+ { type: 'button', style: { ...S.button, opacity: saving ? 0.6 : 1 }, disabled: saving, onClick: () => void save() },
442
+ saving ? t('saving') : t('save'),
443
+ ),
444
+ h('button', { type: 'button', style: S.buttonGhost, onClick: () => void load() }, t('reload')),
445
+ notice ? h('span', { style: { ...S.notice, color: 'rgba(64,160,96,.95)' } }, notice) : null,
446
+ error ? h('span', { style: { ...S.notice, color: 'rgba(214,80,80,.95)' }, role: 'alert' }, error) : null,
447
+ ),
448
+ h('p', { style: S.hint }, t('restartNeeded')),
449
+ overridden.length > 0
450
+ ? h(
451
+ 'p',
452
+ { style: { ...S.notice, color: 'rgba(214,140,60,.95)' } },
453
+ `${t('overridden')} ${overridden.join(', ')}`,
454
+ )
455
+ : null,
456
+ );
457
+ }
458
+
459
+ const name = 'dsh-qqbot-guard';
460
+ const inject = ['slots', 'locale'];
461
+
462
+ function apply(ctx) {
463
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), `${NS}: dictionaries`);
464
+ const t = ctx.locale.bind(NS);
465
+ ctx.slots.inject('settings.section', () =>
466
+ ctx.slots.register(
467
+ {
468
+ name: 'settings.section',
469
+ id: NS,
470
+ order: 65,
471
+ label: () => t('nav'),
472
+ locale: NS,
473
+ inject: () => ({ t, locale: ctx.locale }),
474
+ },
475
+ () => h(Section, { t }),
476
+ ),
477
+ );
478
+ }
479
+
480
+ return { name, inject, apply };
481
+ },
482
+ });
package/cordis.patch.yml CHANGED
@@ -1,8 +1,11 @@
1
1
  # qqbot-guard bundle patch
2
2
  #
3
3
  # 本包被列入 profile 的 dsh.profile.bundles 后,dsh 启动时自动应用此 patch,
4
- # 把下面这一行插进插件树。真正的门禁参数请在 profile 的 cordis.patch.yml 里覆盖
5
- # (同 id 的普通 patch 会覆盖这里的默认值)。
4
+ # 把下面这一行插进插件树。
5
+ #
6
+ # 故意不带 config:所有参数都由 $DSH_HOME/qqbot-guard.json(设置页写的就是它)
7
+ # 或 DEFAULT_CONFIG 决定。cordis 的 config 优先级高于 JSON 文件——这里写一个键,
8
+ # 设置页里对应的开关就再也改不动了,所以默认一个都不写。
6
9
  #
7
10
  # 注意:profile 的 cordis.patch.yml 只能改「已存在的 id」,新增插件行必须写在
8
11
  # bundle patch(本文件)里,或者用 `- insert:` 形式手动插入。
@@ -10,13 +13,3 @@
10
13
  - insert:
11
14
  - id: qqbot-guard
12
15
  name: 'dsh-qqbot-guard'
13
- config:
14
- # triggers 故意不写:由 $DSH_HOME/qqbot-guard.json 或 DEFAULT_CONFIG(大肥鱼/肥鱼/鱼鱼)决定,
15
- # 手机端改关键词不用碰 YAML。要在这里写死也行,会盖过 JSON 文件。
16
- ignoreCase: true
17
- # 留空则自动探测:先看本插件自己所在的 node_modules(也就是 profile 布局
18
- # <home>/profiles/<name>/node_modules),再看 <home>/profiles/*、<home>/plugins/*,
19
- # 最后从 cwd 按 Node 规则向上找。只有自动探测落空时才需要在这里写死路径。
20
- qqbotPackage: ''
21
- strictVersion: false
22
- verbose: true
package/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * (含本地路径——落盘名带随机后缀,只能读的时候按前缀去 media 目录里找)。
22
22
  */
23
23
  import { pathToFileURL, fileURLToPath } from 'node:url';
24
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { dirname, join } from 'node:path';
27
27
 
@@ -142,6 +142,244 @@ export function mergeConfig(config = {}, logger = console) {
142
142
  return { ...DEFAULT_CONFIG, ...file, ...explicit };
143
143
  }
144
144
 
145
+ /** 设置页可以改的布尔开关。`configFile` 不在此列:让 UI 改自己的配置路径容易把用户锁死。 */
146
+ const BOOLEAN_KEYS = [
147
+ 'enabled',
148
+ 'ignoreCase',
149
+ 'faces',
150
+ 'historyAttachments',
151
+ 'historyMedia',
152
+ 'verbose',
153
+ 'strictVersion',
154
+ ];
155
+
156
+ /**
157
+ * 归一化设置页提交的配置片段:只接受已知字段,字段值不合法时抛出带说明的错误。
158
+ * @param {unknown} input 请求体里的 JSON
159
+ * @returns {object} 只含已知字段的配置片段
160
+ */
161
+ export function sanitizeConfigPatch(input) {
162
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
163
+ throw new Error('配置必须是一个 JSON 对象');
164
+ }
165
+ const out = {};
166
+ for (const key of BOOLEAN_KEYS) {
167
+ if (Object.hasOwn(input, key)) out[key] = Boolean(input[key]);
168
+ }
169
+ if (Object.hasOwn(input, 'triggers')) {
170
+ if (!Array.isArray(input.triggers)) throw new Error('triggers 必须是字符串数组');
171
+ const list = input.triggers.map((t) => String(t ?? '').trim()).filter(Boolean);
172
+ out.triggers = [...new Set(list)];
173
+ }
174
+ if (Object.hasOwn(input, 'historyMediaMaxMB')) {
175
+ const mb = Number(input.historyMediaMaxMB);
176
+ if (!Number.isFinite(mb) || mb <= 0 || mb > 1024) {
177
+ throw new Error('historyMediaMaxMB 需要 0~1024 之间的数字');
178
+ }
179
+ out.historyMediaMaxMB = mb;
180
+ }
181
+ if (Object.hasOwn(input, 'qqbotPackage')) {
182
+ out.qqbotPackage = String(input.qqbotPackage ?? '').trim();
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** 原子写 JSON:先写临时文件再 rename,写一半断电也不会留下半个配置文件。 */
188
+ export function writeConfigFile(file, data) {
189
+ if (!file) throw new Error('没有可写的配置文件路径');
190
+ mkdirSync(dirname(file), { recursive: true });
191
+ const tmp = `${file}.${process.pid}.tmp`;
192
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
193
+ renameSync(tmp, file);
194
+ }
195
+
196
+ /** 读一个纯 JSON 文件;不存在或损坏返回 null。 */
197
+ function readJsonFile(file) {
198
+ if (!file || !existsSync(file)) return null;
199
+ try {
200
+ const data = JSON.parse(readFileSync(file, 'utf8'));
201
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /** 文件指纹:mtime + 大小。只改内容不改大小的情况极少,够用。 */
208
+ function configStamp(file) {
209
+ if (!file) return 'missing';
210
+ try {
211
+ const s = statSync(file);
212
+ return `${s.mtimeMs}:${s.size}`;
213
+ } catch {
214
+ return 'missing';
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 配置来源:cordis 之后还能被 JSON 文件覆盖,文件改了要能重读(设置页保存后即时生效)。
220
+ * @param {object} config cordis 里的 config
221
+ * @param {object} logger
222
+ */
223
+ export function createConfigSource(config = {}, logger = console) {
224
+ const file = resolveConfigFile(config);
225
+ let stamp = configStamp(file);
226
+ let merged = mergeConfig(config, logger);
227
+ return {
228
+ /** 写回到哪个文件;没有可写路径时为 null */
229
+ file,
230
+ /** 当前生效的配置;文件 mtime/大小变了就重新合并 */
231
+ get() {
232
+ const next = configStamp(file);
233
+ if (next !== stamp) {
234
+ stamp = next;
235
+ merged = mergeConfig(config, logger);
236
+ logger.info?.(`[qqbot-guard] 配置已重新加载:${file}`);
237
+ }
238
+ return merged;
239
+ },
240
+ /** 强制重读(设置页保存后调用,省得等下一次轮询) */
241
+ reload() {
242
+ stamp = configStamp(file);
243
+ merged = mergeConfig(config, logger);
244
+ return merged;
245
+ },
246
+ };
247
+ }
248
+
249
+ /** 本机回环地址的「主机名:端口」;不认识的写法则返回 null。 */
250
+ function loopbackAuthority(authority) {
251
+ if (typeof authority !== 'string' || authority === '') return null;
252
+ let url;
253
+ try {
254
+ url = new URL(`http://${authority}`);
255
+ } catch {
256
+ return null;
257
+ }
258
+ const host = url.hostname.toLowerCase();
259
+ if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]' && host !== '::1') return null;
260
+ return `${host}:${url.port}`;
261
+ }
262
+
263
+ /**
264
+ * 只允许本机回环来源改配置:Host 必须是回环(挡 DNS rebinding),
265
+ * 带 Origin 时还要求同源(挡别的网页用表单 POST 改配置)。
266
+ * 桌面端页面跑在 `dsh-app://` 下,必须显式放行。
267
+ * @param {object} request Node 的 IncomingMessage
268
+ */
269
+ export function isSameOrigin(request) {
270
+ const hostKey = loopbackAuthority(request?.headers?.host);
271
+ if (hostKey === null) return false;
272
+ const origin = request?.headers?.origin;
273
+ if (origin === undefined || origin === '') return true;
274
+ try {
275
+ const url = new URL(origin);
276
+ if (url.protocol === 'dsh-app:') return true;
277
+ return loopbackAuthority(url.host) === hostKey;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ const CONFIG_PATH = '/qqbot-guard/config';
284
+ const BODY_LIMIT_BYTES = 64 * 1024;
285
+
286
+ function sendJson(response, status, value) {
287
+ response.writeHead(status, {
288
+ 'cache-control': 'no-store',
289
+ 'content-type': 'application/json; charset=utf-8',
290
+ });
291
+ response.end(JSON.stringify(value));
292
+ }
293
+
294
+ /** 读请求体并按上限保护;超过上限直接断开连接。 */
295
+ function readBody(request, limit = BODY_LIMIT_BYTES) {
296
+ return new Promise((resolve, reject) => {
297
+ let size = 0;
298
+ const chunks = [];
299
+ request.on('data', (chunk) => {
300
+ size += chunk.length;
301
+ if (size > limit) {
302
+ reject(new Error('请求体过大'));
303
+ request.destroy();
304
+ return;
305
+ }
306
+ chunks.push(chunk);
307
+ });
308
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
309
+ request.on('error', reject);
310
+ });
311
+ }
312
+
313
+ /** 设置页要的全部状态:配置、默认值、qqbot 定位结果。 */
314
+ function configSnapshot(source, status, explicit = {}) {
315
+ return {
316
+ file: source.file ?? '',
317
+ config: source.get(),
318
+ defaults: DEFAULT_CONFIG,
319
+ status: { ...status },
320
+ // cordis 里的 config 优先级高于 JSON 文件;这些字段改设置页不会生效,得让 UI 说清楚
321
+ overridden: Object.keys(explicit ?? {}),
322
+ };
323
+ }
324
+
325
+ async function handleConfigRequest(request, response, source, log, status, explicit = {}) {
326
+ if (!isSameOrigin(request)) {
327
+ return sendJson(response, 403, { error: '来源不被信任(只允许本机回环地址)' });
328
+ }
329
+ const method = String(request?.method ?? 'GET').toUpperCase();
330
+ try {
331
+ if (method === 'GET') return sendJson(response, 200, configSnapshot(source, status, explicit));
332
+ if (method === 'PUT' || method === 'POST') {
333
+ if (!source.file) return sendJson(response, 400, { error: '没有可写的配置文件(缺少 DSH_HOME)' });
334
+ const patch = sanitizeConfigPatch(JSON.parse(await readBody(request)));
335
+ const next = { ...(readJsonFile(source.file) ?? {}), ...patch };
336
+ writeConfigFile(source.file, next);
337
+ source.reload();
338
+ log.info?.(`[qqbot-guard] 设置页已保存:${source.file}(${Object.keys(patch).join(', ') || '无改动'})`);
339
+ return sendJson(response, 200, configSnapshot(source, status, explicit));
340
+ }
341
+ return sendJson(response, 405, { error: '不支持的方法' });
342
+ } catch (err) {
343
+ const message = String(err?.message ?? err);
344
+ log.warn?.(`[qqbot-guard] 配置请求失败:${message}`);
345
+ return sendJson(response, 400, { error: message });
346
+ }
347
+ }
348
+
349
+ /**
350
+ * 注册设置页用到的 HTTP 接口。
351
+ * qqbot 没装也要能打开设置页,所以调用点在 `findQqbotPackage` 之前。
352
+ * @returns {boolean} 宿主是否提供了 `inject`(有 webServer 时再异步挂载)
353
+ */
354
+ export function installConfigApi(ctx, source, log, status = {}, explicit = {}) {
355
+ if (typeof ctx?.inject !== 'function') return false;
356
+ ctx.inject(['webServer'], (host) => {
357
+ const server = host?.webServer;
358
+ if (!server || typeof server.register !== 'function') {
359
+ log.warn?.('[qqbot-guard] webServer 不可用,设置页接口未注册');
360
+ return;
361
+ }
362
+ const mount = () => {
363
+ const dispose = server.register({
364
+ kind: 'exact',
365
+ path: CONFIG_PATH,
366
+ handler: (request, response) =>
367
+ handleConfigRequest(request, response, source, log, status, explicit),
368
+ });
369
+ return () => {
370
+ try {
371
+ dispose?.();
372
+ } catch {
373
+ /* 宿主已经在卸载路由了 */
374
+ }
375
+ };
376
+ };
377
+ if (typeof host.effect === 'function') host.effect(mount, 'qqbot-guard: config api');
378
+ else mount();
379
+ });
380
+ return true;
381
+ }
382
+
145
383
  function parseVersion(v) { const m = String(v ?? '').trim().match(/^(\d+)\.(\d+)\.(\d+)/);
146
384
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
147
385
  }
@@ -561,12 +799,15 @@ export function createGuard(
561
799
  notes = new Map(),
562
800
  mediaDownloader = null,
563
801
  ) {
564
- const triggers = (config.triggers ?? []).filter((t) => typeof t === 'string' && t.length > 0);
565
- const ignoreCase = config.ignoreCase !== false;
566
- const faces = config.faces !== false;
567
- const historyAttachments = config.historyAttachments !== false;
802
+ // 设置页保存后要即时生效:每处理一条消息都读一次最新配置(数组很小,开销可以忽略)。
803
+ const read = typeof config === 'function' ? config : () => config;
568
804
 
569
805
  return async function qqbotGuard(ctx, next) {
806
+ const current = read() ?? DEFAULT_CONFIG;
807
+ const triggers = (current.triggers ?? []).filter((t) => typeof t === 'string' && t.length > 0);
808
+ const ignoreCase = current.ignoreCase !== false;
809
+ const faces = current.faces !== false;
810
+ const historyAttachments = current.historyAttachments !== false;
570
811
  try {
571
812
  const msg = ctx?.message;
572
813
  if (faces && typeof msg?.content === 'string') {
@@ -778,15 +1019,34 @@ export function installGuard(QQBot, guard, logger = console) {
778
1019
 
779
1020
  export async function apply(ctx, config) {
780
1021
  const log = pickLogger(ctx);
781
- const cfg = mergeConfig(config, log);
1022
+ const source = createConfigSource(config, log);
1023
+ const cfg = source.get();
1024
+
1025
+ /** 设置页显示的运行状态:qqbot 装在哪、补丁有没有生效。 */
1026
+ const status = {
1027
+ qqbot: '',
1028
+ qqbotVersion: '',
1029
+ sdk: '',
1030
+ installed: false,
1031
+ historyAttachments: false,
1032
+ reason: '',
1033
+ };
1034
+ // 先挂配置接口:qqbot 没装、甚至 enabled=false 时,用户仍然要能打开设置页改配置。
1035
+ installConfigApi(ctx, source, log, status, config);
782
1036
 
783
1037
  if (cfg.enabled === false) {
1038
+ status.reason = 'enabled=false';
784
1039
  log.info('[qqbot-guard] enabled=false,跳过(qqbot 行为保持原样)');
785
1040
  return;
786
1041
  }
787
1042
 
788
1043
  const qqbot = findQqbotPackage(cfg, log);
789
- if (!qqbot) return;
1044
+ if (!qqbot) {
1045
+ status.reason = '没找到 @tencent-connect/dsh-qqbot';
1046
+ return;
1047
+ }
1048
+ status.qqbot = qqbot.dir;
1049
+ status.qqbotVersion = qqbot.pkg?.version ?? '';
790
1050
 
791
1051
  let sdkInfo;
792
1052
  try {
@@ -824,12 +1084,15 @@ export async function apply(ctx, config) {
824
1084
 
825
1085
  const notes = new Map();
826
1086
  const mediaDownloader = makeMediaDownloader(qqbot.dir, cfg, log);
827
- const guard = createGuard(cfg, log, notes, mediaDownloader);
1087
+ const guard = createGuard(() => source.get(), log, notes, mediaDownloader);
828
1088
  const result = installGuard(sdk.QQBot, guard, log);
829
1089
  if (!result.installed) {
1090
+ status.reason = `补丁未安装:${result.reason}`;
830
1091
  log.error(`[qqbot-guard] 补丁未安装:${result.reason}`);
831
1092
  return;
832
1093
  }
1094
+ status.installed = true;
1095
+ status.sdk = sdkInfo.version ?? '';
833
1096
 
834
1097
  let historyPatched = false;
835
1098
  let mediaRoot = '';
@@ -854,6 +1117,7 @@ export async function apply(ctx, config) {
854
1117
  log.warn(`[qqbot-guard] 历史附件摘要未启用: ${err?.message ?? err}`);
855
1118
  }
856
1119
  }
1120
+ status.historyAttachments = historyPatched;
857
1121
 
858
1122
  if (cfg.verbose) {
859
1123
  const cfgFile = resolveConfigFile(cfg);
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "dsh-qqbot-guard",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
- "description": "qqbot 增强层:群聊关键词放行 + 历史上下文 + 进程内补丁(不改 qqbot 文件)",
5
+ "description": "qqbot 增强层:群聊关键词放行 + 历史上下文 + 设置页可视化配置(进程内补丁,不改 qqbot 文件)",
6
6
  "license": "MIT",
7
7
  "main": "./index.js",
8
8
  "exports": {
9
- ".": "./index.js"
9
+ ".": "./index.js",
10
+ "./client": "./client/client.js",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./package.json": "./package.json"
10
13
  },
11
14
  "files": [
12
15
  "index.js",
16
+ "client",
13
17
  "cordis.patch.yml",
14
18
  "README.md"
15
19
  ],
@@ -39,6 +43,13 @@
39
43
  "manifestVersion": 1,
40
44
  "bundle": {
41
45
  "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-locale",
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
+ ]
42
53
  }
43
54
  },
44
55
  "peerDependencies": {