@zaofan/dsh-qqbot 1.10.3 → 1.10.4
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.
|
@@ -22,6 +22,13 @@
|
|
|
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
33
|
const PAGE = 20; // 一页 20 个选项(4 行 × 5 列;第 5 行留给导航)
|
|
27
34
|
const LETTERS = 'ABCDEFGHIJKLMNOPQRST';
|
|
@@ -66,6 +73,81 @@ function dataRootOf(ctx) {
|
|
|
66
73
|
return 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot';
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* ★★ 跨模块实例的共享位 —— 2026-10-14 真机 bug 修复(「选了工作区就报向导已过期」)
|
|
78
|
+
*
|
|
79
|
+
* 现象:`/会话列表` 正常发卡、点按钮却回「这个向导已经过期了」,
|
|
80
|
+
* 诊断日志里 `[onInteraction] card=… 命中=false keys=[]`(状态表是**空**的),
|
|
81
|
+
* 而同一条日志里 `[paint] … 表=2 / 表=3`(命令侧写进去过)⇒ **两边的 W 不是同一个 Map**。
|
|
82
|
+
*
|
|
83
|
+
* 真因:插件对 `ext-commands` 是**两条独立加载路径**(斜杠命令注册 / 卡片回调查表),
|
|
84
|
+
* `tools_reload` 只热刷其中一条 ⇒ **同一个文件被实例化成两份模块**,
|
|
85
|
+
* 各自持有自己的 `W` / `KERNEL` / `CTX_CACHE`:
|
|
86
|
+
* · 命令侧实例:paint 写自己的 W(seq 正常递增)—— 看起来一切正常;
|
|
87
|
+
* · 回调侧实例:W 永远空 ⇒ 一点就"过期"。
|
|
88
|
+
*
|
|
89
|
+
* ⇒ 凡是**回调侧要用**的东西(kernel / sender / replyTarget / 父会话 id / 向导状态)
|
|
90
|
+
* **都不能只放模块变量**,必须落到 ① `globalThis` 符号槽(同进程跨实例可见)
|
|
91
|
+
* 或 ② 磁盘(跨实例、甚至跨重启都可见)。
|
|
92
|
+
*/
|
|
93
|
+
const SLOT = Symbol.for('dsh-qqbot.session-list.shared');
|
|
94
|
+
function shared() {
|
|
95
|
+
try { return globalThis[SLOT] ?? (globalThis[SLOT] = {}); } catch { return {}; }
|
|
96
|
+
}
|
|
97
|
+
function publishShared(patch) { try { Object.assign(shared(), patch ?? {}); } catch { /* ignore */ } }
|
|
98
|
+
|
|
99
|
+
/** 向导状态的磁盘副本(回调侧实例用来"认出"命令侧实例发的卡) */
|
|
100
|
+
function stateFileOf(ctx) {
|
|
101
|
+
const root = cn(shared().dataRoot) || dataRootOf(ctx);
|
|
102
|
+
return `${root}/.qqbot/session-list-wizard.json`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function saveState(ctx, cardId, st) {
|
|
106
|
+
try {
|
|
107
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
108
|
+
const { dirname } = await import('node:path');
|
|
109
|
+
const f = stateFileOf(ctx);
|
|
110
|
+
let all = {};
|
|
111
|
+
try { all = JSON.parse(readFileSync(f, 'utf8')) ?? {}; } catch { /* 首次或坏文件 */ }
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
for (const k of Object.keys(all)) if (Number(all[k]?.expireAt ?? 0) < now) delete all[k];
|
|
114
|
+
all[cardId] = { ...st, expireAt: now + CARD_EXPIRE_MS };
|
|
115
|
+
const keys = Object.keys(all);
|
|
116
|
+
if (keys.length > 20) for (const k of keys.slice(0, keys.length - 20)) delete all[k];
|
|
117
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
118
|
+
writeFileSync(f, JSON.stringify(all));
|
|
119
|
+
return true;
|
|
120
|
+
} catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadState(ctx, cardId) {
|
|
124
|
+
try {
|
|
125
|
+
const { readFileSync } = await import('node:fs');
|
|
126
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
127
|
+
const st = all[cardId];
|
|
128
|
+
if (!st || Number(st.expireAt ?? 0) < Date.now()) return null;
|
|
129
|
+
return st;
|
|
130
|
+
} catch { return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function dropState(ctx, cardId) {
|
|
134
|
+
try {
|
|
135
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
136
|
+
const all = JSON.parse(readFileSync(stateFileOf(ctx), 'utf8')) ?? {};
|
|
137
|
+
if (all[cardId]) { delete all[cardId]; writeFileSync(stateFileOf(ctx), JSON.stringify(all)); }
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 回调侧补齐"命令侧才知道"的上下文(同进程共享位优先,其次回调 ctx 自带的 kernel) */
|
|
142
|
+
function adoptShared(ctx) {
|
|
143
|
+
const s = shared();
|
|
144
|
+
KERNEL = KERNEL ?? s.kernel ?? ctx?.kernel ?? ctx?.caps?.kernel ?? null;
|
|
145
|
+
CTX_CACHE.sender = CTX_CACHE.sender ?? s.sender ?? null;
|
|
146
|
+
CTX_CACHE.replyTarget = CTX_CACHE.replyTarget ?? s.replyTarget ?? null;
|
|
147
|
+
CTX_CACHE.agentId = CTX_CACHE.agentId || cn(s.parentId);
|
|
148
|
+
CTX_CACHE.sessionId = CTX_CACHE.sessionId || cn(s.parentId);
|
|
149
|
+
}
|
|
150
|
+
|
|
69
151
|
/** 命令侧自写卡表(不依赖插件是否注入 registerInteractionCard) */
|
|
70
152
|
async function regCardSelf(ctx, cardId, buttonIds) {
|
|
71
153
|
const root = dataRootOf(ctx);
|
|
@@ -468,6 +550,8 @@ async function paint(ctx, st) {
|
|
|
468
550
|
if (W.size > 30) {
|
|
469
551
|
for (const k of [...W.keys()].slice(0, W.size - 30)) if (k !== st.cardId) W.delete(k);
|
|
470
552
|
}
|
|
553
|
+
publishShared({ dataRoot: dataRootOf(ctx) });
|
|
554
|
+
try { await saveState(ctx, st.cardId, st); } catch { /* ignore */ } // ★ 落盘:回调侧可能是**另一个模块实例**
|
|
471
555
|
void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${ok ? 'ok' : 'FAIL'} 表=${W.size}`);
|
|
472
556
|
if (!ok) st.error = '卡片没发出去(当前环境可能不支持富文本卡片)';
|
|
473
557
|
return ok;
|
|
@@ -478,13 +562,18 @@ async function paint(ctx, st) {
|
|
|
478
562
|
* 「绑定任何已有会话」这件事本身不需要新能力:`register()` 的 childSessionId 就是"任意会话 id"。
|
|
479
563
|
*/
|
|
480
564
|
async function bindNow(ctx, st) {
|
|
565
|
+
adoptShared(ctx);
|
|
481
566
|
const lib = await loadLib();
|
|
482
|
-
const root =
|
|
567
|
+
const root = kernelOf(ctx);
|
|
483
568
|
const sess = st.picked.session ?? {};
|
|
484
569
|
const ws = st.picked.workspace?.value ?? '';
|
|
485
570
|
const mode = Number(st.picked.mode?.value ?? 1);
|
|
486
|
-
|
|
487
|
-
|
|
571
|
+
// ★ 父会话 id 优先取**命令侧存进向导状态**的那份:回调侧可能是另一个模块实例,
|
|
572
|
+
// 它自己的 CTX_CACHE 是空的(详见文件头 SLOT 注释)。
|
|
573
|
+
const parentId = cn(st.parentId) || CTX_CACHE.agentId || CTX_CACHE.sessionId || '';
|
|
574
|
+
const sender = CTX_CACHE.sender ?? shared().sender ?? null;
|
|
575
|
+
const replyTarget = CTX_CACHE.replyTarget ?? shared().replyTarget ?? null;
|
|
576
|
+
void diag(`[bind] root=${!!root} parent=${parentId || '-'} child=${short(sess.value, 40)} mode=${mode} fwd=${!!sender && !!replyTarget}`);
|
|
488
577
|
|
|
489
578
|
if (!root || typeof root.on !== 'function') {
|
|
490
579
|
return { text: `⚠️ 拿不到宿主 ctx(需要权限包里的 kernel),这次没绑上。\n会话 id:\`${short(sess.value, 40)}\`` };
|
|
@@ -496,8 +585,8 @@ async function bindNow(ctx, st) {
|
|
|
496
585
|
childSessionId: sess.value,
|
|
497
586
|
label: short(sess.label, 40),
|
|
498
587
|
mode,
|
|
499
|
-
sender
|
|
500
|
-
replyTarget
|
|
588
|
+
sender,
|
|
589
|
+
replyTarget,
|
|
501
590
|
name: short(sess.label, 40),
|
|
502
591
|
workspace: wsPath(ws),
|
|
503
592
|
});
|
|
@@ -528,6 +617,7 @@ async function advance(ctx, st) {
|
|
|
528
617
|
while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
|
|
529
618
|
if (st.stepIdx >= STEPS.length) {
|
|
530
619
|
W.delete(st.cardId);
|
|
620
|
+
void dropState(ctx, st.cardId);
|
|
531
621
|
const r = await bindNow(ctx, st);
|
|
532
622
|
try { const caps = ctx?.caps ?? ctx ?? {}; if (typeof caps.markdown === 'function') { await caps.markdown(r.text); return ''; } } catch { /* ignore */ }
|
|
533
623
|
return r.text;
|
|
@@ -540,7 +630,8 @@ async function advance(ctx, st) {
|
|
|
540
630
|
}
|
|
541
631
|
|
|
542
632
|
export default {
|
|
543
|
-
name: ['会话列表', 'sessionlist', 'sessions'],
|
|
633
|
+
name: ['会话列表', 'sessionlist', 'sessions'],
|
|
634
|
+
description: '三步向导(工作区 → 该区会话 → 回灌模式)绑定任意已有会话,并给出可复制的路由模板',
|
|
544
635
|
usage: '/会话列表',
|
|
545
636
|
|
|
546
637
|
async handler(ctx) {
|
|
@@ -552,6 +643,14 @@ export default {
|
|
|
552
643
|
const psid = String(caps.peerSessionId ?? '');
|
|
553
644
|
if (psid) { CTX_CACHE.agentId = psid; CTX_CACHE.sessionId = psid; }
|
|
554
645
|
if (!CTX_CACHE.sender) CTX_CACHE.sender = ctx?.bot ?? caps.sender ?? null;
|
|
646
|
+
// ★ 把回调侧要用的一切**推给共享位**(回调可能是另一个模块实例,见文件头 SLOT 注释)
|
|
647
|
+
publishShared({
|
|
648
|
+
kernel: KERNEL,
|
|
649
|
+
sender: CTX_CACHE.sender,
|
|
650
|
+
replyTarget: CTX_CACHE.replyTarget,
|
|
651
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
652
|
+
dataRoot: dataRootOf(ctx),
|
|
653
|
+
});
|
|
555
654
|
void diag(`[handler] kernel=${!!KERNEL} sender=${!!CTX_CACHE.sender} rt=${!!CTX_CACHE.replyTarget} peerSessionId=${psid || '-'}`);
|
|
556
655
|
} catch { /* ignore */ }
|
|
557
656
|
|
|
@@ -563,6 +662,8 @@ export default {
|
|
|
563
662
|
options: [],
|
|
564
663
|
cardId: '',
|
|
565
664
|
error: '',
|
|
665
|
+
parentId: CTX_CACHE.agentId || CTX_CACHE.sessionId || '',
|
|
666
|
+
startedAt: Date.now(),
|
|
566
667
|
};
|
|
567
668
|
W.set('pending', st);
|
|
568
669
|
await paint(ctx, st);
|
|
@@ -574,19 +675,26 @@ export default {
|
|
|
574
675
|
async onInteraction(ctx, info) {
|
|
575
676
|
const cardId = cn(info?.cardId);
|
|
576
677
|
const bid = cn(info?.buttonId);
|
|
577
|
-
|
|
578
|
-
|
|
678
|
+
adoptShared(ctx);
|
|
679
|
+
let st = W.get(cardId);
|
|
680
|
+
if (!st) {
|
|
681
|
+
// ★ 命令侧与回调侧可能是**两个模块实例**(各自的 W 是空的)⇒ 回落到磁盘状态
|
|
682
|
+
st = await loadState(ctx, cardId);
|
|
683
|
+
if (st) W.set(cardId, st);
|
|
684
|
+
}
|
|
685
|
+
void diag(`[onInteraction] card=${cardId} btn=${bid} 命中=${!!st} keys=[${[...W.keys()].join(',')}] kernel=${!!kernelOf(ctx)}`);
|
|
579
686
|
if (!st) return '这个向导已经过期了,重新发一次 /会话列表 吧~';
|
|
580
|
-
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); return '向导放太久了,重新发一次吧~'; }
|
|
687
|
+
if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); void dropState(ctx, cardId); return '向导放太久了,重新发一次吧~'; }
|
|
581
688
|
st.startedAt = st.startedAt ?? Date.now();
|
|
582
689
|
|
|
583
|
-
if (bid === 'cancel') { W.delete(cardId); return '已取消,没有绑定任何会话。'; }
|
|
690
|
+
if (bid === 'cancel') { W.delete(cardId); void dropState(ctx, cardId); return '已取消,没有绑定任何会话。'; }
|
|
584
691
|
if (bid === 'noop') return '';
|
|
585
692
|
if (bid === 'prev' || bid === 'next') {
|
|
586
693
|
st.page = Math.max(0, st.page + (bid === 'next' ? 1 : -1));
|
|
587
694
|
const ok = await paint(ctx, st);
|
|
588
695
|
W.delete(cardId);
|
|
589
696
|
W.set(st.cardId, st);
|
|
697
|
+
void dropState(ctx, cardId);
|
|
590
698
|
return ok ? `已翻到第 ${st.page + 1} 页 ✅(新卡片在上面)` : `翻页失败(第 ${st.page + 1} 页)`;
|
|
591
699
|
}
|
|
592
700
|
if (/^p\d+$/.test(bid)) {
|
|
@@ -595,6 +703,7 @@ export default {
|
|
|
595
703
|
if (!opt) return '这个选项没了,翻一页再选~';
|
|
596
704
|
st.picked[st.step] = opt;
|
|
597
705
|
st.stepIdx += 1;
|
|
706
|
+
void dropState(ctx, cardId);
|
|
598
707
|
const done = await advance(ctx, st);
|
|
599
708
|
return done || '';
|
|
600
709
|
}
|