@zaofan/dsh-qqbot 1.10.3 → 1.10.5
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/dist/features/ext-capabilities.d.ts +15 -1
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-inbound-context.d.ts.map +1 -1
- package/dist/features/ext-inbound-context.js +20 -2
- package/dist/features/ext-inbound-context.js.map +1 -1
- package/dist/features/ext-inbound-state.d.ts +1 -1
- package/dist/features/ext-inbound-state.d.ts.map +1 -1
- package/docs/USER-GUIDE.md +1107 -1081
- package/examples/ext-commands//344/274/232/350/257/235/345/210/227/350/241/250.mjs +163 -24
- package/examples/ext-inbound/README.md +28 -0
- package/examples/ext-inbound/route-isolate.mjs +132 -0
- package/examples/ext-tools/lib/session_watch_lib.mjs +126 -6
- package/package.json +1 -1
|
@@ -22,9 +22,33 @@
|
|
|
22
22
|
* 3. 命令侧 `ctx.caps` 有值、**按钮回调的 ctx 是把能力平铺在自身**(没有 caps 键)
|
|
23
23
|
* ⇒ 一律写 `ctx?.caps ?? ctx`。
|
|
24
24
|
* 4. `keyboardOf` / `regCardSelf` 里的**工具名必须与本文件一致**,否则插件查表查不到。
|
|
25
|
+
* 5. ★★ **别信"handler 与 onInteraction 是同一个模块实例"**(2026-10-14 真机踩中,见文件头
|
|
26
|
+
* `SLOT` 注释):插件对 `ext-commands` 是**两条独立加载路径**,`tools_reload` 只热刷其中一条
|
|
27
|
+
* ⇒ 同一个文件被实例化成**两份模块**、各持自己的 `W`。
|
|
28
|
+
* 现象:发卡正常(命令侧 `表=2/3`),一点按钮就回「这个向导已经过期了」(回调侧 `keys=[]`)。
|
|
29
|
+
* 对策:回调侧要用的一切(kernel / sender / replyTarget / 父会话 id / 向导状态)
|
|
30
|
+
* 一律走 **`globalThis` 符号槽(`publishShared`/`adoptShared`)+ 磁盘(`saveState`/`loadState`)**,
|
|
31
|
+
* 模块变量只当本地缓存。
|
|
25
32
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
/**
|
|
34
|
+
* ★★ 一页几个选项 = 由 **QQ 内联键盘的硬上限**倒推出来的,不是随便定的(2026-10-14 真机踩中)
|
|
35
|
+
*
|
|
36
|
+
* QQ 官方对内联键盘的限制:**总行数 ≤ 5**、每行 ≤ 5 个按钮。
|
|
37
|
+
* 超了会被直接拒绝,原话见诊断日志:
|
|
38
|
+
* `HTTP 400: {"message":"请求错误,内联键盘行/列超限","code":40034029}`
|
|
39
|
+
* 更坑的是:tier① 被拒后会**静默退化成纯文本兜底** —— 现象就是主人反馈的
|
|
40
|
+
* 「文字发出来了,按钮选项消失」。
|
|
41
|
+
*
|
|
42
|
+
* 所以布局固定为 **5 行**:
|
|
43
|
+
* 第 1~3 行 = 选项(**一页最多 15 个**)
|
|
44
|
+
* 第 4 行 = 翻页(◀ 上一页 / 下一页 ▶),没得翻时放一个占位按钮
|
|
45
|
+
* 第 5 行 = 取消
|
|
46
|
+
* (2026-10-14 之前是 PAGE=20 ⇒ 4 行选项 + 2 行尾巴 = **6 行** ⇒ 必然被拒。)
|
|
47
|
+
*/
|
|
48
|
+
const PAGE = 15;
|
|
49
|
+
const OPT_ROWS = 3; // 选项最多 3 行(3×5 = 15)
|
|
50
|
+
const MAX_KB_ROWS = 5; // QQ 内联键盘总行数硬上限
|
|
51
|
+
const LETTERS = 'ABCDEFGHIJKLMNO'; // 15 个
|
|
28
52
|
const CARD_EXPIRE_MS = 10 * 60_000;
|
|
29
53
|
const MAX_PAGE_MS = 30 * 60_000;
|
|
30
54
|
const TOOL = '会话列表'; // ★ 必须与文件名/注册名一致
|
|
@@ -66,6 +90,81 @@ function dataRootOf(ctx) {
|
|
|
66
90
|
return 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot';
|
|
67
91
|
}
|
|
68
92
|
|
|
93
|
+
/**
|
|
94
|
+
* ★★ 跨模块实例的共享位 —— 2026-10-14 真机 bug 修复(「选了工作区就报向导已过期」)
|
|
95
|
+
*
|
|
96
|
+
* 现象:`/会话列表` 正常发卡、点按钮却回「这个向导已经过期了」,
|
|
97
|
+
* 诊断日志里 `[onInteraction] card=… 命中=false keys=[]`(状态表是**空**的),
|
|
98
|
+
* 而同一条日志里 `[paint] … 表=2 / 表=3`(命令侧写进去过)⇒ **两边的 W 不是同一个 Map**。
|
|
99
|
+
*
|
|
100
|
+
* 真因:插件对 `ext-commands` 是**两条独立加载路径**(斜杠命令注册 / 卡片回调查表),
|
|
101
|
+
* `tools_reload` 只热刷其中一条 ⇒ **同一个文件被实例化成两份模块**,
|
|
102
|
+
* 各自持有自己的 `W` / `KERNEL` / `CTX_CACHE`:
|
|
103
|
+
* · 命令侧实例:paint 写自己的 W(seq 正常递增)—— 看起来一切正常;
|
|
104
|
+
* · 回调侧实例:W 永远空 ⇒ 一点就"过期"。
|
|
105
|
+
*
|
|
106
|
+
* ⇒ 凡是**回调侧要用**的东西(kernel / sender / replyTarget / 父会话 id / 向导状态)
|
|
107
|
+
* **都不能只放模块变量**,必须落到 ① `globalThis` 符号槽(同进程跨实例可见)
|
|
108
|
+
* 或 ② 磁盘(跨实例、甚至跨重启都可见)。
|
|
109
|
+
*/
|
|
110
|
+
const SLOT = Symbol.for('dsh-qqbot.session-list.shared');
|
|
111
|
+
function shared() {
|
|
112
|
+
try { return globalThis[SLOT] ?? (globalThis[SLOT] = {}); } catch { return {}; }
|
|
113
|
+
}
|
|
114
|
+
function publishShared(patch) { try { Object.assign(shared(), patch ?? {}); } catch { /* ignore */ } }
|
|
115
|
+
|
|
116
|
+
/** 向导状态的磁盘副本(回调侧实例用来"认出"命令侧实例发的卡) */
|
|
117
|
+
function stateFileOf(ctx) {
|
|
118
|
+
const root = cn(shared().dataRoot) || dataRootOf(ctx);
|
|
119
|
+
return `${root}/.qqbot/session-list-wizard.json`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function saveState(ctx, cardId, st) {
|
|
123
|
+
try {
|
|
124
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
125
|
+
const { dirname } = await import('node:path');
|
|
126
|
+
const f = stateFileOf(ctx);
|
|
127
|
+
let all = {};
|
|
128
|
+
try { all = JSON.parse(readFileSync(f, 'utf8')) ?? {}; } catch { /* 首次或坏文件 */ }
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
for (const k of Object.keys(all)) if (Number(all[k]?.expireAt ?? 0) < now) delete all[k];
|
|
131
|
+
all[cardId] = { ...st, expireAt: now + CARD_EXPIRE_MS };
|
|
132
|
+
const keys = Object.keys(all);
|
|
133
|
+
if (keys.length > 20) for (const k of keys.slice(0, keys.length - 20)) delete all[k];
|
|
134
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
135
|
+
writeFileSync(f, JSON.stringify(all));
|
|
136
|
+
return true;
|
|
137
|
+
} catch { return false; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function loadState(ctx, cardId) {
|
|
141
|
+
try {
|
|
142
|
+
const { readFileSync } = await import('node:fs');
|
|
143
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
144
|
+
const st = all[cardId];
|
|
145
|
+
if (!st || Number(st.expireAt ?? 0) < Date.now()) return null;
|
|
146
|
+
return st;
|
|
147
|
+
} catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function dropState(ctx, cardId) {
|
|
151
|
+
try {
|
|
152
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
153
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
154
|
+
if (all[cardId]) { delete all[cardId]; writeFileSync(stateFileOf(ctx), JSON.stringify(all)); }
|
|
155
|
+
} catch { /* ignore */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 回调侧补齐"命令侧才知道"的上下文(同进程共享位优先,其次回调 ctx 自带的 kernel) */
|
|
159
|
+
function adoptShared(ctx) {
|
|
160
|
+
const s = shared();
|
|
161
|
+
KERNEL = KERNEL ?? s.kernel ?? ctx?.kernel ?? ctx?.caps?.kernel ?? null;
|
|
162
|
+
CTX_CACHE.sender = CTX_CACHE.sender ?? s.sender ?? null;
|
|
163
|
+
CTX_CACHE.replyTarget = CTX_CACHE.replyTarget ?? s.replyTarget ?? null;
|
|
164
|
+
CTX_CACHE.agentId = CTX_CACHE.agentId || cn(s.parentId);
|
|
165
|
+
CTX_CACHE.sessionId = CTX_CACHE.sessionId || cn(s.parentId);
|
|
166
|
+
}
|
|
167
|
+
|
|
69
168
|
/** 命令侧自写卡表(不依赖插件是否注入 registerInteractionCard) */
|
|
70
169
|
async function regCardSelf(ctx, cardId, buttonIds) {
|
|
71
170
|
const root = dataRootOf(ctx);
|
|
@@ -92,7 +191,11 @@ async function regCardSelf(ctx, cardId, buttonIds) {
|
|
|
92
191
|
} catch { return false; }
|
|
93
192
|
}
|
|
94
193
|
|
|
95
|
-
/** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话
|
|
194
|
+
/** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话 同款。
|
|
195
|
+
* ★ 返回值要说实话(2026-10-14 修):`'kb'`=带按钮发出去了 / `'text'`=**退化成纯文本**(按钮没了)
|
|
196
|
+
* / `'fail'`=什么都没发出去。之前只会返回 true/false,**退化被当成成功**,
|
|
197
|
+
* 于是"文字出来了、按钮消失"这种事故在日志里显示 `发出=ok`,白排查半天。 */
|
|
198
|
+
let LAST_SEND_ERR = '';
|
|
96
199
|
async function sendCard(ctx, text, keyboard) {
|
|
97
200
|
const caps = ctx?.caps ?? ctx ?? {};
|
|
98
201
|
const mc = ctx?.message ?? {};
|
|
@@ -107,24 +210,25 @@ async function sendCard(ctx, text, keyboard) {
|
|
|
107
210
|
const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
|
|
108
211
|
try {
|
|
109
212
|
const r = await api(path, { method: 'POST', body });
|
|
110
|
-
void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0,
|
|
111
|
-
if (r && r.ok !== false) return
|
|
112
|
-
|
|
213
|
+
void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0, 200)}`);
|
|
214
|
+
if (r && r.ok !== false) { LAST_SEND_ERR = ''; return 'kb'; }
|
|
215
|
+
LAST_SEND_ERR = cn(r?.err?.human ?? r?.err?.message ?? r?.err?.code ?? 'ok=false');
|
|
216
|
+
} catch (e) { LAST_SEND_ERR = cn(e?.message ?? e); void diag(`[sendCard] ①抛错=${short(e?.message ?? e, 200)}`); }
|
|
113
217
|
}
|
|
114
218
|
try {
|
|
115
219
|
if (keyboard && typeof caps.markdownCard === 'function') {
|
|
116
220
|
const r2 = await caps.markdownCard(text, keyboard);
|
|
117
|
-
if (r2) return
|
|
221
|
+
if (r2) return 'kb';
|
|
118
222
|
}
|
|
119
223
|
} catch { /* ignore */ }
|
|
120
224
|
try {
|
|
121
225
|
if (keyboard && typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function') {
|
|
122
226
|
await ctx.sender.sendMarkdownWithKeyboard(ctx.replyTarget, text, keyboard);
|
|
123
|
-
return
|
|
227
|
+
return 'kb';
|
|
124
228
|
}
|
|
125
229
|
} catch { /* ignore */ }
|
|
126
|
-
try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return
|
|
127
|
-
return
|
|
230
|
+
try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return 'text'; } catch { /* ignore */ }
|
|
231
|
+
return 'fail';
|
|
128
232
|
}
|
|
129
233
|
|
|
130
234
|
/** 按钮(id 不能带冒号) */
|
|
@@ -432,7 +536,7 @@ function cardKeyboard(st) {
|
|
|
432
536
|
const opts = st.options;
|
|
433
537
|
const start = st.page * PAGE;
|
|
434
538
|
const cur = opts.slice(start, start + PAGE);
|
|
435
|
-
for (let r = 0; r * 5 < cur.length && r <
|
|
539
|
+
for (let r = 0; r * 5 < cur.length && r < OPT_ROWS; r++) {
|
|
436
540
|
const row = [];
|
|
437
541
|
for (let c = 0; c < 5; c++) {
|
|
438
542
|
const i = r * 5 + c;
|
|
@@ -446,6 +550,8 @@ function cardKeyboard(st) {
|
|
|
446
550
|
if (start + PAGE < opts.length) nav.push(btn('next', '下一页 ▶', 1));
|
|
447
551
|
rows.push(nav.length ? nav : [btn('noop', '·', 1)]);
|
|
448
552
|
rows.push([btn('cancel', '取消', 4)]);
|
|
553
|
+
// ★ 保底:QQ 只收 ≤5 行;真超了就砍尾巴(正常布局下正好 5 行,走不到这里)
|
|
554
|
+
while (rows.length > MAX_KB_ROWS) rows.splice(rows.length - 2, 1);
|
|
449
555
|
return keyboardOf(rows, st.cardId);
|
|
450
556
|
}
|
|
451
557
|
|
|
@@ -453,9 +559,11 @@ function cardKeyboard(st) {
|
|
|
453
559
|
async function paint(ctx, st) {
|
|
454
560
|
st.options = await optionsOf(ctx, st.step, st);
|
|
455
561
|
if (st.page * PAGE >= st.options.length) st.page = Math.max(0, Math.ceil(st.options.length / PAGE) - 1);
|
|
562
|
+
st.error = '';
|
|
456
563
|
st.cardId = `${TOOL}-${++seq}-${Date.now().toString(36)}`;
|
|
457
564
|
const kb = cardKeyboard(st);
|
|
458
|
-
const
|
|
565
|
+
const mode = await sendCard(ctx, cardText(st), kb);
|
|
566
|
+
const ok = mode === 'kb';
|
|
459
567
|
const caps = ctx?.caps ?? ctx ?? {};
|
|
460
568
|
const allIds = [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'cancel', 'noop'];
|
|
461
569
|
try {
|
|
@@ -468,8 +576,14 @@ async function paint(ctx, st) {
|
|
|
468
576
|
if (W.size > 30) {
|
|
469
577
|
for (const k of [...W.keys()].slice(0, W.size - 30)) if (k !== st.cardId) W.delete(k);
|
|
470
578
|
}
|
|
471
|
-
|
|
472
|
-
|
|
579
|
+
publishShared({ dataRoot: dataRootOf(ctx) });
|
|
580
|
+
try { await saveState(ctx, st.cardId, st); } catch { /* ignore */ } // ★ 落盘:回调侧可能是**另一个模块实例**
|
|
581
|
+
void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${mode}${LAST_SEND_ERR ? ' err=' + short(LAST_SEND_ERR, 80) : ''} 表=${W.size}`);
|
|
582
|
+
if (mode !== 'kb') {
|
|
583
|
+
st.error = mode === 'text'
|
|
584
|
+
? `按钮没发出去(QQ 拒了键盘:${short(LAST_SEND_ERR, 50) || '未知'}),这一步只能纯文本显示`
|
|
585
|
+
: '卡片完全没发出去(当前环境可能不支持富文本卡片)';
|
|
586
|
+
}
|
|
473
587
|
return ok;
|
|
474
588
|
}
|
|
475
589
|
|
|
@@ -478,13 +592,18 @@ async function paint(ctx, st) {
|
|
|
478
592
|
* 「绑定任何已有会话」这件事本身不需要新能力:`register()` 的 childSessionId 就是"任意会话 id"。
|
|
479
593
|
*/
|
|
480
594
|
async function bindNow(ctx, st) {
|
|
595
|
+
adoptShared(ctx);
|
|
481
596
|
const lib = await loadLib();
|
|
482
|
-
const root =
|
|
597
|
+
const root = kernelOf(ctx);
|
|
483
598
|
const sess = st.picked.session ?? {};
|
|
484
599
|
const ws = st.picked.workspace?.value ?? '';
|
|
485
600
|
const mode = Number(st.picked.mode?.value ?? 1);
|
|
486
|
-
|
|
487
|
-
|
|
601
|
+
// ★ 父会话 id 优先取**命令侧存进向导状态**的那份:回调侧可能是另一个模块实例,
|
|
602
|
+
// 它自己的 CTX_CACHE 是空的(详见文件头 SLOT 注释)。
|
|
603
|
+
const parentId = cn(st.parentId) || CTX_CACHE.agentId || CTX_CACHE.sessionId || '';
|
|
604
|
+
const sender = CTX_CACHE.sender ?? shared().sender ?? null;
|
|
605
|
+
const replyTarget = CTX_CACHE.replyTarget ?? shared().replyTarget ?? null;
|
|
606
|
+
void diag(`[bind] root=${!!root} parent=${parentId || '-'} child=${short(sess.value, 40)} mode=${mode} fwd=${!!sender && !!replyTarget}`);
|
|
488
607
|
|
|
489
608
|
if (!root || typeof root.on !== 'function') {
|
|
490
609
|
return { text: `⚠️ 拿不到宿主 ctx(需要权限包里的 kernel),这次没绑上。\n会话 id:\`${short(sess.value, 40)}\`` };
|
|
@@ -496,8 +615,8 @@ async function bindNow(ctx, st) {
|
|
|
496
615
|
childSessionId: sess.value,
|
|
497
616
|
label: short(sess.label, 40),
|
|
498
617
|
mode,
|
|
499
|
-
sender
|
|
500
|
-
replyTarget
|
|
618
|
+
sender,
|
|
619
|
+
replyTarget,
|
|
501
620
|
name: short(sess.label, 40),
|
|
502
621
|
workspace: wsPath(ws),
|
|
503
622
|
});
|
|
@@ -528,6 +647,7 @@ async function advance(ctx, st) {
|
|
|
528
647
|
while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
|
|
529
648
|
if (st.stepIdx >= STEPS.length) {
|
|
530
649
|
W.delete(st.cardId);
|
|
650
|
+
void dropState(ctx, st.cardId);
|
|
531
651
|
const r = await bindNow(ctx, st);
|
|
532
652
|
try { const caps = ctx?.caps ?? ctx ?? {}; if (typeof caps.markdown === 'function') { await caps.markdown(r.text); return ''; } } catch { /* ignore */ }
|
|
533
653
|
return r.text;
|
|
@@ -540,7 +660,8 @@ async function advance(ctx, st) {
|
|
|
540
660
|
}
|
|
541
661
|
|
|
542
662
|
export default {
|
|
543
|
-
name: ['会话列表', 'sessionlist', 'sessions'],
|
|
663
|
+
name: ['会话列表', 'sessionlist', 'sessions'],
|
|
664
|
+
description: '三步向导(工作区 → 该区会话 → 回灌模式)绑定任意已有会话,并给出可复制的路由模板',
|
|
544
665
|
usage: '/会话列表',
|
|
545
666
|
|
|
546
667
|
async handler(ctx) {
|
|
@@ -552,6 +673,14 @@ export default {
|
|
|
552
673
|
const psid = String(caps.peerSessionId ?? '');
|
|
553
674
|
if (psid) { CTX_CACHE.agentId = psid; CTX_CACHE.sessionId = psid; }
|
|
554
675
|
if (!CTX_CACHE.sender) CTX_CACHE.sender = ctx?.bot ?? caps.sender ?? null;
|
|
676
|
+
// ★ 把回调侧要用的一切**推给共享位**(回调可能是另一个模块实例,见文件头 SLOT 注释)
|
|
677
|
+
publishShared({
|
|
678
|
+
kernel: KERNEL,
|
|
679
|
+
sender: CTX_CACHE.sender,
|
|
680
|
+
replyTarget: CTX_CACHE.replyTarget,
|
|
681
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
682
|
+
dataRoot: dataRootOf(ctx),
|
|
683
|
+
});
|
|
555
684
|
void diag(`[handler] kernel=${!!KERNEL} sender=${!!CTX_CACHE.sender} rt=${!!CTX_CACHE.replyTarget} peerSessionId=${psid || '-'}`);
|
|
556
685
|
} catch { /* ignore */ }
|
|
557
686
|
|
|
@@ -563,6 +692,8 @@ export default {
|
|
|
563
692
|
options: [],
|
|
564
693
|
cardId: '',
|
|
565
694
|
error: '',
|
|
695
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
696
|
+
startedAt: Date.now(),
|
|
566
697
|
};
|
|
567
698
|
W.set('pending', st);
|
|
568
699
|
await paint(ctx, st);
|
|
@@ -574,19 +705,26 @@ export default {
|
|
|
574
705
|
async onInteraction(ctx, info) {
|
|
575
706
|
const cardId = cn(info?.cardId);
|
|
576
707
|
const bid = cn(info?.buttonId);
|
|
577
|
-
|
|
578
|
-
|
|
708
|
+
adoptShared(ctx);
|
|
709
|
+
let st = W.get(cardId);
|
|
710
|
+
if (!st) {
|
|
711
|
+
// ★ 命令侧与回调侧可能是**两个模块实例**(各自的 W 是空的)⇒ 回落到磁盘状态
|
|
712
|
+
st = await loadState(ctx, cardId);
|
|
713
|
+
if (st) W.set(cardId, st);
|
|
714
|
+
}
|
|
715
|
+
void diag(`[onInteraction] card=${cardId} btn=${bid} 命中=${!!st} keys=[${[...W.keys()].join(',')}] kernel=${!!kernelOf(ctx)}`);
|
|
579
716
|
if (!st) return '这个向导已经过期了,重新发一次 /会话列表 吧~';
|
|
580
|
-
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); return '向导放太久了,重新发一次吧~'; }
|
|
717
|
+
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); void dropState(ctx, cardId); return '向导放太久了,重新发一次吧~'; }
|
|
581
718
|
st.startedAt = st.startedAt ?? Date.now();
|
|
582
719
|
|
|
583
|
-
if (bid === 'cancel') { W.delete(cardId); return '已取消,没有绑定任何会话。'; }
|
|
720
|
+
if (bid === 'cancel') { W.delete(cardId); void dropState(ctx, cardId); return '已取消,没有绑定任何会话。'; }
|
|
584
721
|
if (bid === 'noop') return '';
|
|
585
722
|
if (bid === 'prev' || bid === 'next') {
|
|
586
723
|
st.page = Math.max(0, st.page + (bid === 'next' ? 1 : -1));
|
|
587
724
|
const ok = await paint(ctx, st);
|
|
588
725
|
W.delete(cardId);
|
|
589
726
|
W.set(st.cardId, st);
|
|
727
|
+
void dropState(ctx, cardId);
|
|
590
728
|
return ok ? `已翻到第 ${st.page + 1} 页 ✅(新卡片在上面)` : `翻页失败(第 ${st.page + 1} 页)`;
|
|
591
729
|
}
|
|
592
730
|
if (/^p\d+$/.test(bid)) {
|
|
@@ -595,6 +733,7 @@ export default {
|
|
|
595
733
|
if (!opt) return '这个选项没了,翻一页再选~';
|
|
596
734
|
st.picked[st.step] = opt;
|
|
597
735
|
st.stepIdx += 1;
|
|
736
|
+
void dropState(ctx, cardId);
|
|
598
737
|
const done = await advance(ctx, st);
|
|
599
738
|
return done || '';
|
|
600
739
|
}
|
|
@@ -228,3 +228,31 @@ export default { name: 'x', onInbound, onDecide, dispose() {} }; // 对象默
|
|
|
228
228
|
| 低分消息还是回话了 | 日志里搜 `强制入站(入站扩展 forceWake…` —— 可能是你自己的扩展要求的;也可能是 `forceWake` 之后 debounce 把标记带下去了(正常) |
|
|
229
229
|
| 禁言没生效 | `isMuted()` 是不是写在"消灭"之前却被后面的分支覆盖了;名单文件在 `.state/<你的文件名>.json` |
|
|
230
230
|
| 熔断了 | 日志搜 `⚠ 熔断「…」`;复位 = 改文件 + `/tools-reload` |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 附:`annihilate({ ownerAck: true })` —— 唯一能吞"主人消息"的通道(v1.10.5 新增)
|
|
235
|
+
|
|
236
|
+
**背景**:白名单写着「主人的消息永不可被入站扩展吞掉」,`ctx.annihilate()` 遇到主人发送者会**直接返回 false**
|
|
237
|
+
(审计 `refused-owner`),引擎还有第二道兜底(扩展没调 `next()` 时替它放行)。这两道一起保证了
|
|
238
|
+
"用户脚本坏了也吞不掉主人的话"。
|
|
239
|
+
|
|
240
|
+
**但有一类是主人自己的意图**:例如**二档路由隔绝** —— 群里那条 `[s:c0246642] 来我房间` 已经被扩展
|
|
241
|
+
投递到目标会话了,主人明确要求**不要再留在本会话上下文里**。这时扩展可以:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
const ok = ctx.annihilate({ reason: '二档路由隔绝:已投到目标会话', ownerAck: true });
|
|
245
|
+
if (!ok) return next(); // 没吞掉 ⇒ 交回原路径,别双投
|
|
246
|
+
// ★ 吞掉之后照样要 return next():annihilate 置位会让 next() 变成空操作、链停在这里
|
|
247
|
+
return next();
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**三条纪律**(写在 `ExtInboundCaps.annihilate` 的类型注释里):
|
|
251
|
+
|
|
252
|
+
1. **只在"你确实已经把内容投递到别处"时传** —— 否则主人的话会凭空消失;
|
|
253
|
+
2. 用了它**一定落审计** `result: 'ok-owner-ack'`(外加一条 warn),事后可查"哪条主人的消息被谁吞了、为什么";
|
|
254
|
+
3. 它只解开**这一条**的白名单 —— 挂点检查、熔断等其它保护一律照旧。
|
|
255
|
+
|
|
256
|
+
**向后兼容**:老版本插件不认识 `ownerAck`,会照旧按白名单拒绝 ⇒ 行为退回"主人的消息照常放行",不会更糟。
|
|
257
|
+
|
|
258
|
+
**参考实现**:同目录 `route-isolate.mjs`。
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* route-isolate.mjs — 入站中间件:**二档绑定的路由消息,不进父会话上下文(彻底隔绝)**
|
|
3
|
+
*
|
|
4
|
+
* 放在 `{数据根}/.qqbot-extensions/inbound/route-isolate.mjs`;改完 `/tools-reload` 立即生效。
|
|
5
|
+
*
|
|
6
|
+
* ── 它解决什么(主人 2026-10-14 需求,原话)────────────────────────────────
|
|
7
|
+
* 「如果是挡位2的情况下,所有的路由的消息,不进入父会话的上下文,彻底隔绝」
|
|
8
|
+
* 「我是说,用户发的消息,如果是路由用的,不进入你的上下文,不是说回灌的消息」
|
|
9
|
+
* 即:群里那条 `[s:c0249642] 喜欢你` —— 只该出现在**目标会话**里,
|
|
10
|
+
* 不该同时躺进鲸鱼娘本尊(父会话)的上下文。二档本就意味着"子会话内容只去 QQ、不进父上下文",
|
|
11
|
+
* 本扩展把**反方向**补齐:**父上下文 ↔ 子会话 彻底互不可见**。
|
|
12
|
+
*
|
|
13
|
+
* ── 为什么只能写在**早挂点 onInbound** ──────────────────────────────────────
|
|
14
|
+
* 链上位置:`… → quoteRef → ★onInbound(本文件)★ → 引用图预取 → 记群历史 → … → AI`
|
|
15
|
+
* 只有这里在"记群历史之前",所以只有这里的 `ctx.annihilate()` 才真的等于"这条没来过"
|
|
16
|
+
* (不进历史、不进上下文、不占冷却)。晚挂点调它会**被拒 + 告警**。
|
|
17
|
+
*
|
|
18
|
+
* ── 三个必须这么写的地方 ───────────────────────────────────────────────────
|
|
19
|
+
* ① **消灭之后必须自己投递**:`session_watch_lib` 原来那条路由投递挂在 `session/event` 的
|
|
20
|
+
* `user/message` 上;消息都被消灭了,那个事件**永远不会发生** ⇒ 这里消灭前先解析目标、
|
|
21
|
+
* 消灭后直接调 `lib.routeToSession()`。
|
|
22
|
+
* ② **`return next()`,不能"消灭完就 return"**:插件引擎有条兜底 —— 扩展"既没调 next、
|
|
23
|
+
* 又没有成功的消灭"时**替它放行**,且白名单写着"主人的消息永远不被入站扩展吞掉"。
|
|
24
|
+
* 但这条兜底**只在 `!onward` 时触发**(=扩展压根没调 next);调了 next,
|
|
25
|
+
* `annihilate` 的置位会**直接吃掉 next**(链停在这里 + 跳过兜底)⇒ **主人的消息也真的被消灭**。
|
|
26
|
+
* 这正是主人要的。⚠️ 该语义照 `src/features/ext-inbound.ts` 的
|
|
27
|
+
* `runEarlyInboundExtensions` 确认;哪天插件收紧它,这里会退化成"主人消息照进上下文"(不会更糟)。
|
|
28
|
+
* ③ **只有二档才隔绝**:`modeOfRoute(key) === 2`。没绑过 / 一档 / 三档 ⇒ 原样放行。
|
|
29
|
+
*
|
|
30
|
+
* ── 安全阀(宁可"不隔绝",也不要"消息凭空消失")──────────────────────────
|
|
31
|
+
* · 没路由标记 ⇒ 放行;· 不是二档 ⇒ 放行;
|
|
32
|
+
* · **路由 id 解析不出会话**(写错了)⇒ 放行;· `annihilate` 返回 false(熔断/挂点不允许)⇒ 放行,
|
|
33
|
+
* 交回原路径投递,避免"我投一次 + 原路径再投一次"双投;
|
|
34
|
+
* · 已消灭但投递失败(目标不在线且唤不醒)⇒ 不回父上下文(这是隔绝的代价),只记诊断日志。
|
|
35
|
+
*
|
|
36
|
+
* ── 诊断 ──────────────────────────────────────────────────────────────────
|
|
37
|
+
* 全程写 `{数据根}/.qqbot-extensions/data/route-isolate-diag.log`。
|
|
38
|
+
* (教训:一开始只往 logger 写,而**从来没人注入过 logger** ⇒ 真机上完全静默、无法定位。)
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** 只在插件日志里显示的名字 */
|
|
42
|
+
export const name = 'route-isolate';
|
|
43
|
+
|
|
44
|
+
/** ★ 诊断必须**落盘**:logger 没人注入过,只往 logger 写等于瞎的 */
|
|
45
|
+
const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/route-isolate-diag.log';
|
|
46
|
+
let LOG = null;
|
|
47
|
+
|
|
48
|
+
async function note(msg) {
|
|
49
|
+
try { LOG?.(msg); } catch { /* ignore */ }
|
|
50
|
+
try {
|
|
51
|
+
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
52
|
+
const { dirname } = await import('node:path');
|
|
53
|
+
await mkdir(dirname(DIAG_FILE), { recursive: true });
|
|
54
|
+
const line = new Date().toISOString() + ' ' + String(msg) + '\n';
|
|
55
|
+
await appendFile(DIAG_FILE, line);
|
|
56
|
+
} catch { /* 诊断失败绝不影响主流程 */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadLib() {
|
|
60
|
+
return await import(new URL('../tools/lib/session_watch_lib.mjs?v=' + Date.now(), import.meta.url).href);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 早挂点:链上唯一"还没记群历史"的位置 */
|
|
64
|
+
export async function onInbound(ctx, next) {
|
|
65
|
+
try {
|
|
66
|
+
// ★★ 正文从哪读:**`ctx.text` 才是插件给的规范读法**(它内部 = `textOf(ctx)`),
|
|
67
|
+
// 实际存在 `ctx.message.content` 里。**不存在 `ctx.message.text`** ——
|
|
68
|
+
// 2026-10-14 就是因为写成 `.text` 读到空串、第一行就 return next(),
|
|
69
|
+
// 整条链路静默放行(审计里一条记录都没有)才排查了半天。
|
|
70
|
+
const text = String(ctx?.text ?? ctx?.message?.content ?? ctx?.message?.text ?? '');
|
|
71
|
+
if (!text) return next();
|
|
72
|
+
const lib = await loadLib();
|
|
73
|
+
|
|
74
|
+
// ★★ 2026-10-14:**顺手把 sender/replyTarget 交给回灌库**(`rememberFwd`)。
|
|
75
|
+
// 为什么必须挂在入站链上:QQ 转发能力 `fwd` 是**内存态**,插件一重启就丢;
|
|
76
|
+
// 唯一的自愈路径是"父会话发言时 hydrateFwd",而它需要 sender ——
|
|
77
|
+
// 工具侧只有"被调用过"才注入(`session_watch.mjs` 里的 rememberFwd),
|
|
78
|
+
// 于是重启后如果没人记得调工具,转发就一直断(实测踩到)。
|
|
79
|
+
// 挂在这里 ⇒ **每条群消息都刷新一次** ⇒ 重启后父会话随便说一句话转发就自动恢复。
|
|
80
|
+
// 注意用 `ctx.caps.*` 而不是 `ctx.*`:ctx 上那个 `sender` 已被插件定义成"发送者 openid"(字符串)。
|
|
81
|
+
try {
|
|
82
|
+
lib.rememberFwd?.(ctx?.caps?.sender, ctx?.caps?.replyTarget);
|
|
83
|
+
} catch { /* 注入失败不影响隔绝 */ }
|
|
84
|
+
|
|
85
|
+
const key = lib.parseRouteKey(text);
|
|
86
|
+
// 每次都记"手上有什么" —— kernel / annihilate 缺任一个都会走放行分支,先看清
|
|
87
|
+
void note('[enter] text=' + JSON.stringify(text.slice(0, 50))
|
|
88
|
+
+ ' key=' + (key || '-')
|
|
89
|
+
+ ' kernel=' + typeof ctx?.kernel
|
|
90
|
+
+ ' annihilate=' + typeof ctx?.annihilate
|
|
91
|
+
+ ' next=' + typeof next);
|
|
92
|
+
|
|
93
|
+
if (!key) return next();
|
|
94
|
+
|
|
95
|
+
// ① 只有二档才隔绝(没绑 = 0,一/三档 = 1/3,都放行)
|
|
96
|
+
const mode = lib.modeOfRoute(key);
|
|
97
|
+
void note('[mode] key=' + key + ' mode=' + mode);
|
|
98
|
+
if (mode !== 2) return next();
|
|
99
|
+
|
|
100
|
+
// ② 目标解析不出来 ⇒ 不吞(写错 id 时别让消息凭空消失)
|
|
101
|
+
const kernel = ctx?.kernel ?? null;
|
|
102
|
+
let full = '';
|
|
103
|
+
try { full = lib.resolveShortId(kernel, key) ?? ''; } catch { full = ''; }
|
|
104
|
+
if (!full) {
|
|
105
|
+
try { full = lib.findSessionOnDisk(key)?.id ?? ''; } catch { full = ''; }
|
|
106
|
+
}
|
|
107
|
+
if (!full) { void note('[skip] id 解析不到会话 key=' + key); return next(); }
|
|
108
|
+
|
|
109
|
+
// ③ 先消灭再投递 —— 顺序不能反:先投再吞的话,一旦消灭被熔断拒绝就会双投
|
|
110
|
+
// ★ `ownerAck: true` —— 唯一能吞"主人消息"的通道(2026-10-14 为此专门给插件加了显式例外)。
|
|
111
|
+
// 语义:这条我已经**另行投递到目标会话**了,请允许对主人生效;插件会落审计 ok-owner-ack。
|
|
112
|
+
const acked = ctx?.annihilate?.({ reason: '二档路由隔绝:目标 ' + key, ownerAck: true });
|
|
113
|
+
if (!acked) { void note('[skip] annihilate 被拒 key=' + key + ' target=' + full.slice(0, 20)); return next(); }
|
|
114
|
+
|
|
115
|
+
// ④ 已消灭 ⇒ 自己投递(原路径的 user/message 钩子不会跑了)
|
|
116
|
+
let r = null;
|
|
117
|
+
try {
|
|
118
|
+
r = await lib.routeToSession(kernel, text, { message: ctx?.message });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
r = { ok: false, reason: 'throw:' + (e?.message ?? e) };
|
|
121
|
+
}
|
|
122
|
+
void note('[done] key=' + key + ' target=' + full.slice(0, 20)
|
|
123
|
+
+ ' 已从父上下文移除;投递=' + (r?.ok ? 'ok(' + r.reason + ')' : 'FAIL(' + r?.reason + ')'));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
void note('[error] ' + (e?.message ?? e));
|
|
126
|
+
}
|
|
127
|
+
// ★ 必须调 next():跳过"发送者是主人 ⇒ 引擎替它放行"那条兜底(见文件头 ②)
|
|
128
|
+
return next();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 插件可用它注入日志(没注入也会落盘诊断) */
|
|
132
|
+
export function setLogger(fn) { LOG = typeof fn === 'function' ? fn : null; }
|