@zaofan/dsh-qqbot 1.9.3 → 1.10.1

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.
@@ -1,138 +1,201 @@
1
- /**
2
- * qq_send — 查 QQ 聊天对象 + 发消息(单条 / 连发多条)
3
- *
4
- * ★ 2026-10-10 主人定:原 `send_texts` 已**并入本工具** —— 一个入口覆盖
5
- * 「查对象 / 发一条 / 连发多条」,省得在工具之间选错。
6
- *
7
- * 场景:**非 QQ 通道**的会话(web / 终端)也能隔着通道把消息发进某个群或私聊。
8
- * 数据来源:`expose: 'all'` 注入的 `env.qq`(账号包,由插件按"人设 id"构造)。
9
- *
10
- * 动作:
11
- * · action:"list" —— 列出已知聊天对象(群 / 私聊,带最近活跃时间),可用 query 过滤
12
- * · action:"send"(默认)—— 发消息:target 可用 **openid**,也可用**群名 / 昵称**
13
- * · 连发 —— 同一次 send 里给 `texts:[多条]`(内容不同)或 `text`+`count`(同一条重复 N 次)
14
- *
15
- * 发送链路(插件侧 env.qq.sendText 实现):先查**目标会话最近的 msgId** 走被动回复,
16
- * 失败(过期/配额用尽/没窗口)自动去掉 msgId 转**主动**兜底。
17
- *
18
- * 纪律:
19
- * ⛔ 按名字寻址时**绝不猜** —— 命中 0 个或多个只回候选清单;
20
- * ✅ 返回据实 —— 成功/失败条数都报出来,不谎报。
21
- */
22
- const MAX_LIST = 40;
23
- const MAX_SEND = 50;
24
-
25
- const isOpenid = (s) => /^[A-Za-z0-9_-]{16,}$/.test(String(s || ''));
26
- const shortId = (id) => (String(id || '').length > 14 ? String(id).slice(0, 12) + '…' : String(id || ''));
27
- const when = (ts) => {
28
- const n = Number(ts);
29
- if (!Number.isFinite(n) || n <= 0) return '';
30
- try { return new Date(n).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return ''; }
31
- };
32
- const line = (scope, x) => `[${scope === 'c2c' ? '私聊' : '群'}] ${x.name ? x.name : '(无名)'} · ${shortId(x.id)}${when(x.lastAt) ? ' (' + when(x.lastAt) + ')' : ''}`;
33
-
34
- export default {
35
- name: 'qq_send',
36
- expose: 'all',
37
- description: '查询本机器人的 QQ 聊天对象(群/私聊),并向指定对象发送文本消息——单条或连发多条。目标可用 openid,也可用群名/昵称(模糊命中多个时不猜,只回候选)',
38
- inputSchema: {
39
- action: { type: 'string', enum: ['list', 'send'], description: 'list=只查询聊天对象;send=发消息(默认)' },
40
- query: { type: 'string', description: 'action=list 用:按名字或 ID 过滤的关键词' },
41
- target: { type: 'string', description: 'action=send 用:群 openid / 用户 openid / 群名·昵称' },
42
- scope: { type: 'string', enum: ['group', 'c2c'], description: 'action=send 用:目标类型(用 openid 时建议写明;按名字找可省)' },
43
- text: { type: 'string', description: '要发的文本(单条;配 count 可重复发 N 次)' },
44
- texts: { type: 'array', items: { type: 'string' }, description: '要依次发的多条文本(与 text+count 二选一)' },
45
- count: { type: 'integer', description: '把 text 重复发几次(1~50,默认 1)' },
46
- intervalMs: { type: 'integer', description: '连发时每条之间的间隔毫秒(默认 600,防 QQ 吞消息)' },
47
- },
48
- run: async (args, env) => {
49
- const qq = env && env.qq;
50
- if (!qq) {
51
- return { ok: false, msg: '拿不到 QQ 账号包(env.qq)—— 需要该工具声明 expose:"all",且对应人设实例在线' };
52
- }
53
- const groups = Array.isArray(qq.groups) ? qq.groups : [];
54
- const c2c = Array.isArray(qq.c2c) ? qq.c2c : [];
55
-
56
- // ── list:只查 ──────────────────────────────────────────────
57
- if (args.action === 'list') {
58
- const q = String(args.query || '').trim().toLowerCase();
59
- const hit = (x) => !q || String(x.name || '').toLowerCase().includes(q) || String(x.id || '').toLowerCase().includes(q);
60
- const g = groups.filter(hit);
61
- const c = c2c.filter(hit);
62
- if (!g.length && !c.length) {
63
- return { ok: true, msg: `没有匹配「${String(args.query || '')}」的对象(已知群 ${groups.length} 个 / 私聊 ${c2c.length} 个)` };
64
- }
65
- const out = [];
66
- out.push(`人设「${qq.preset || qq.tag || '?'}」已知聊天对象:群 ${g.length} / 私聊 ${c.length}`);
67
- if (g.length) out.push('— 群 —\n' + g.slice(0, MAX_LIST).map((x) => ' ' + line('group', x)).join('\n'));
68
- if (c.length) out.push('— 私聊 —\n' + c.slice(0, MAX_LIST).map((x) => ' ' + line('c2c', x)).join('\n'));
69
- if (g.length > MAX_LIST || c.length > MAX_LIST) out.push(`(每类最多显示 ${MAX_LIST} 条,可用 query 过滤)`);
70
- return { ok: true, msg: out.join('\n') };
71
- }
72
-
73
- // ── send:发(单条 / 连发)────────────────────────────────────
74
- const raw = String(args.target || '').trim();
75
- if (!raw) return { ok: false, msg: '需要 target(群 openid / 用户 openid / 群名·昵称)' };
76
- let scope = args.scope === 'c2c' ? 'c2c' : (args.scope === 'group' ? 'group' : '');
77
-
78
- // 组装待发清单:texts 优先,其次 text × count
79
- let list = [];
80
- if (Array.isArray(args.texts)) list = args.texts.map((s) => String(s)).filter((s) => s.trim());
81
- else if (args.text) {
82
- const n = Math.max(1, Math.min(MAX_SEND, Math.round(Number(args.count) || 1)));
83
- list = Array.from({ length: n }, () => String(args.text));
84
- }
85
- if (!list.length) return { ok: false, msg: '没有要发的内容:给 text(单条/配 count 重复)或 texts(多条)' };
86
- if (list.length > MAX_SEND) list = list.slice(0, MAX_SEND);
87
-
88
- // 解析目标:openid 直接用;否则按名字找(唯一才发)
89
- let id = '';
90
- let label = raw;
91
- if (isOpenid(raw)) {
92
- id = raw;
93
- if (!scope) {
94
- if (groups.some((x) => x.id === raw)) scope = 'group';
95
- else if (c2c.some((x) => x.id === raw)) scope = 'c2c';
96
- else scope = raw.length > 30 ? 'group' : 'c2c';
97
- }
98
- const known = (scope === 'group' ? groups : c2c).find((x) => x.id === raw);
99
- label = (known && known.name) ? known.name : raw;
100
- } else {
101
- const pool = [];
102
- if (!scope || scope === 'group') {
103
- for (const x of groups) if (String(x.name || '').includes(raw) || String(x.id) === raw) pool.push({ scope: 'group', ...x });
104
- }
105
- if (!scope || scope === 'c2c') {
106
- for (const x of c2c) if (String(x.name || '').includes(raw) || String(x.id) === raw) pool.push({ scope: 'c2c', ...x });
107
- }
108
- if (!pool.length) return { ok: false, msg: `找不到「${raw}」这个聊天对象 —— 可先用 action:"list" 看一眼已知的群/私聊` };
109
- if (pool.length > 1) {
110
- return {
111
- ok: false,
112
- msg: `「${raw}」命中 ${pool.length} 个候选,人家不猜着发 —— 请用 openid 指定:\n` + pool.slice(0, 8).map((x) => ' ' + line(x.scope, x)).join('\n'),
113
- };
114
- }
115
- scope = pool[0].scope;
116
- id = pool[0].id;
117
- label = pool[0].name || id;
118
- }
119
-
120
- const gap = Number.isFinite(Number(args.intervalMs)) ? Number(args.intervalMs) : 600;
121
- const where = scope === 'c2c' ? '私聊' : '群';
122
- let ok = 0;
123
- const errs = [];
124
- for (let i = 0; i < list.length; i++) {
125
- let sent = false;
126
- try { sent = await qq.sendText(scope, id, list[i]); } catch { sent = false; }
127
- if (sent) ok += 1; else errs.push('第' + (i + 1) + '条');
128
- if (i < list.length - 1 && gap > 0) await new Promise((r) => setTimeout(r, gap));
129
- }
130
- if (ok === list.length) {
131
- return { ok: true, msg: list.length === 1 ? `已发送到${where}「${label}」✓` : `已向${where}「${label}」连发 ${ok} 条 ✓(间隔 ${gap}ms)` };
132
- }
133
- return {
134
- ok: ok > 0,
135
- msg: `发往${where}「${label}」:成功 ${ok}/${list.length} 条${errs.length ? ',失败:' + errs.slice(0, 5).join('、') : ''}(失败多为 QQ 侧拒绝:被动窗口过期且主动消息不可用)`,
136
- };
137
- },
138
- };
1
+ /**
2
+ * qq_send — 查 QQ 聊天对象 + 发消息(文本 / markdown / 图片,可连发)
3
+ *
4
+ * ★ 2026-10-10 主人定:原 `send_texts` 已**并入本工具** —— 一个入口覆盖
5
+ * 「查对象 / 发一条 / 连发多条」,省得在工具之间选错。
6
+ * ★ 2026-10-13 主人要求:**支持图片与 markdown**。底层能力插件早就有了
7
+ * (`env.qq.sendImage` / `env.qq.sendMarkdown`),本工具以前只暴露了纯文本。
8
+ * 同版修掉一个**真 bug**:插件给的字段名是 `gid`(群)/ `openid`(私聊),
9
+ * 本工具却按 `x.id` 读 ⇒ 列表里 ID 是空的、**按名字寻址时 id 取到 undefined**
10
+ * (`sendText` 里 `if (!id) return false` ⇒ 静默发不出去)。现在三种字段名都认。
11
+ *
12
+ * 场景:**非 QQ 通道**的会话(web / 终端)也能隔着通道把消息发进某个群或私聊。
13
+ * 数据来源:`expose: 'all'` 注入的 `env.qq`(账号包,由插件按"人设 id"构造)。
14
+ *
15
+ * 动作:
16
+ * · action:"list" —— 列出已知聊天对象(群 / 私聊,带最近活跃时间),可用 query 过滤
17
+ * · action:"send"(默认)—— 发消息,三路内容选一路:
18
+ * ① 纯文本:`text`(配 `count` 重复)或 `texts:[多条]`
19
+ * ② 富文本:`markdown`(卡片通道,自带纯文本降级)
20
+ * ③ 图片:`image` / `images:[多张]`,**可再配 `text` 当配文**(先图后文)
21
+ *
22
+ * 发送链路:
23
+ * · 文本 `env.qq.sendText`:先查**目标会话最近的 msgId** 走被动回复,失败自动转主动兜底;
24
+ * · 图片 / markdown `env.qq.sendImage` / `sendMarkdown`:**只走主动发送**
25
+ * (插件侧目前没给它们接被动窗口)⇒ 会消耗主动消息配额,发之前心里有数。
26
+ *
27
+ * 纪律:
28
+ * ⛔ 按名字寻址时**绝不猜** —— 命中 0 个或多个只回候选清单;
29
+ * ⛔ markdown 不与图片/多条文本混发(想配图就 `image` + `text`),要发就明确发;
30
+ * ✅ 返回据实 —— 成功/失败条数与失败位置都报出来,不谎报。
31
+ */
32
+ const MAX_LIST = 40;
33
+ const MAX_SEND = 50;
34
+ const MAX_IMAGES = 10;
35
+
36
+ /** 聊天对象的 id:插件给的是 `gid`(群)/ `openid`(私聊),老版本可能叫 `id` —— 三种都认 */
37
+ const chatId = (x) => String((x && (x.openid != null ? x.openid : (x.gid != null ? x.gid : x.id))) ?? '');
38
+ const isOpenid = (s) => /^[A-Za-z0-9_-]{16,}$/.test(String(s || ''));
39
+ const isUrl = (s) => /^https?:\/\//i.test(String(s || ''));
40
+ const shortId = (id) => (String(id || '').length > 14 ? String(id).slice(0, 12) + '…' : String(id || ''));
41
+ const when = (ts) => {
42
+ const n = Number(ts);
43
+ if (!Number.isFinite(n) || n <= 0) return '';
44
+ try { return new Date(n).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return ''; }
45
+ };
46
+ const line = (scope, x) => `[${scope === 'c2c' ? '私聊' : '群'}] ${x.name ? x.name : '(无名)'} · ${shortId(chatId(x))}${when(x.lastAt) ? ' (' + when(x.lastAt) + ')' : ''}`;
47
+ const KIND_CN = { text: '文本', markdown: 'markdown', image: '图片' };
48
+
49
+ export default {
50
+ name: 'qq_send',
51
+ expose: 'all',
52
+ description: '查询本机器人的 QQ 聊天对象(群/私聊),并向指定对象发送消息:纯文本(可连发多条)、markdown 富文本卡片、或图片(可配一句文字)。目标可用 openid,也可用群名/昵称(模糊命中多个时不猜,只回候选)',
53
+ inputSchema: {
54
+ action: { type: 'string', enum: ['list', 'send'], description: 'list=只查询聊天对象;send=发消息(默认)' },
55
+ query: { type: 'string', description: 'action=list 用:按名字或 ID 过滤的关键词' },
56
+ target: { type: 'string', description: 'action=send 用:群 openid / 用户 openid / 群名·昵称' },
57
+ scope: { type: 'string', enum: ['group', 'c2c'], description: 'action=send 用:目标类型(用 openid 时建议写明;按名字找可省)' },
58
+ text: { type: 'string', description: '纯文本(配 count 可重复发 N 次);给了 image/images 时它是**配文**(在图片之后发)' },
59
+ texts: { type: 'array', items: { type: 'string' }, description: '要依次发的多条纯文本(与 text+count 二选一)' },
60
+ count: { type: 'integer', description: '把 text 重复发几次(1~50,默认 1)' },
61
+ markdown: { type: 'string', description: '要发的 markdown 富文本卡片(自带纯文本降级)。⚠️ 独立一路,不要和图片/texts 混发' },
62
+ image: { type: 'string', description: '要发的图片:http(s) 网址,或本机图片的绝对路径' },
63
+ images: { type: 'array', items: { type: 'string' }, description: `依次发多张图(最多 ${MAX_IMAGES} 张),与 image 可同用(自动去重)` },
64
+ intervalMs: { type: 'integer', description: '连发时每条之间的间隔毫秒(默认 600,防 QQ 吞消息)' },
65
+ },
66
+ run: async (args, env) => {
67
+ const qq = env && env.qq;
68
+ if (!qq) {
69
+ return { ok: false, msg: '拿不到 QQ 账号包(env.qq)—— 需要该工具声明 expose:"all",且对应人设实例在线' };
70
+ }
71
+ const groups = Array.isArray(qq.groups) ? qq.groups : [];
72
+ const c2c = Array.isArray(qq.c2c) ? qq.c2c : [];
73
+
74
+ // ── list:只查 ──────────────────────────────────────────────
75
+ if (args.action === 'list') {
76
+ const q = String(args.query || '').trim().toLowerCase();
77
+ const hit = (x) => !q || String(x.name || '').toLowerCase().includes(q) || chatId(x).toLowerCase().includes(q);
78
+ const g = groups.filter(hit);
79
+ const c = c2c.filter(hit);
80
+ if (!g.length && !c.length) {
81
+ return { ok: true, msg: `没有匹配「${String(args.query || '')}」的对象(已知群 ${groups.length} 个 / 私聊 ${c2c.length} 个)` };
82
+ }
83
+ const out = [];
84
+ out.push(`人设「${qq.preset || qq.tag || '?'}」已知聊天对象:群 ${g.length} / 私聊 ${c.length}`);
85
+ if (g.length) out.push('— 群 —\n' + g.slice(0, MAX_LIST).map((x) => ' ' + line('group', x)).join('\n'));
86
+ if (c.length) out.push('— 私聊 —\n' + c.slice(0, MAX_LIST).map((x) => ' ' + line('c2c', x)).join('\n'));
87
+ if (g.length > MAX_LIST || c.length > MAX_LIST) out.push(`(每类最多显示 ${MAX_LIST} 条,可用 query 过滤)`);
88
+ return { ok: true, msg: out.join('\n') };
89
+ }
90
+
91
+ // ── send:组装待发清单 ───────────────────────────────────────
92
+ const raw = String(args.target || '').trim();
93
+ if (!raw) return { ok: false, msg: '需要 target(群 openid / 用户 openid / 群名·昵称)' };
94
+ let scope = args.scope === 'c2c' ? 'c2c' : (args.scope === 'group' ? 'group' : '');
95
+
96
+ const md = typeof args.markdown === 'string' ? args.markdown.trim() : '';
97
+ const caption = typeof args.text === 'string' ? args.text : '';
98
+ const texts = Array.isArray(args.texts) ? args.texts.map((s) => String(s)).filter((s) => s.trim()) : [];
99
+ const imgs = [];
100
+ for (const s of (Array.isArray(args.images) ? args.images : [])) {
101
+ const v = String(s || '').trim();
102
+ if (v && !imgs.includes(v) && imgs.length < MAX_IMAGES) imgs.push(v);
103
+ }
104
+ if (typeof args.image === 'string' && args.image.trim()) {
105
+ const v = args.image.trim();
106
+ if (!imgs.includes(v)) imgs.unshift(v);
107
+ if (imgs.length > MAX_IMAGES) imgs.length = MAX_IMAGES;
108
+ }
109
+
110
+ if (md && (imgs.length || texts.length)) {
111
+ return { ok: false, msg: 'markdown 是独立一路,不和图片 / 多条文本混发 —— 想「图 + 一句话」就发 image + text(不带 markdown)' };
112
+ }
113
+
114
+ /** 发送计划:一条一条来,顺序就是数组顺序 */
115
+ let plan = [];
116
+ if (md) {
117
+ plan = [{ type: 'markdown', payload: md }];
118
+ } else if (imgs.length) {
119
+ plan = imgs.map((s) => ({ type: 'image', payload: s }));
120
+ if (caption.trim()) plan.push({ type: 'text', payload: caption }); // 配文跟在图后面
121
+ } else if (texts.length) {
122
+ plan = texts.slice(0, MAX_SEND).map((t) => ({ type: 'text', payload: t }));
123
+ } else if (caption.trim()) {
124
+ const n = Math.max(1, Math.min(MAX_SEND, Math.round(Number(args.count) || 1)));
125
+ plan = Array.from({ length: n }, () => ({ type: 'text', payload: caption }));
126
+ } else {
127
+ return { ok: false, msg: '没有要发的内容:给 text / texts(纯文本)、markdown(富文本卡片)、或 image / images(图片,可再配 text 当配文)' };
128
+ }
129
+
130
+ // 能力自检:老版本插件可能没这两个方法 —— 明确说清,别让人以为是发送失败
131
+ if (plan.some((it) => it.type === 'markdown') && typeof qq.sendMarkdown !== 'function') {
132
+ return { ok: false, msg: '当前插件版本没给 env.qq.sendMarkdown —— 发 markdown 需要插件 ≥ v1.9.x(升级插件后重试)' };
133
+ }
134
+ if (plan.some((it) => it.type === 'image') && typeof qq.sendImage !== 'function') {
135
+ return { ok: false, msg: '当前插件版本没给 env.qq.sendImage —— 发图需要插件 ≥ v1.9.x(升级插件后重试)' };
136
+ }
137
+
138
+ // ── 解析目标:openid 直接用;否则按名字找(唯一才发)──────────
139
+ let id = '';
140
+ let label = raw;
141
+ if (isOpenid(raw)) {
142
+ id = raw;
143
+ if (!scope) {
144
+ if (groups.some((x) => chatId(x) === raw)) scope = 'group';
145
+ else if (c2c.some((x) => chatId(x) === raw)) scope = 'c2c';
146
+ else scope = raw.length > 30 ? 'group' : 'c2c';
147
+ }
148
+ const known = (scope === 'group' ? groups : c2c).find((x) => chatId(x) === raw);
149
+ label = (known && known.name) ? known.name : raw;
150
+ } else {
151
+ const pool = [];
152
+ if (!scope || scope === 'group') {
153
+ for (const x of groups) if (String(x.name || '').includes(raw) || chatId(x) === raw) pool.push({ scope: 'group', ...x });
154
+ }
155
+ if (!scope || scope === 'c2c') {
156
+ for (const x of c2c) if (String(x.name || '').includes(raw) || chatId(x) === raw) pool.push({ scope: 'c2c', ...x });
157
+ }
158
+ if (!pool.length) return { ok: false, msg: `找不到「${raw}」这个聊天对象 —— 可先用 action:"list" 看一眼已知的群/私聊` };
159
+ if (pool.length > 1) {
160
+ return {
161
+ ok: false,
162
+ msg: `「${raw}」命中 ${pool.length} 个候选,人家不猜着发 —— 请用 openid 指定:\n` + pool.slice(0, 8).map((x) => ' ' + line(x.scope, x)).join('\n'),
163
+ };
164
+ }
165
+ scope = pool[0].scope;
166
+ id = chatId(pool[0]);
167
+ label = pool[0].name || id;
168
+ if (!id) return { ok: false, msg: `「${raw}」解析到空 id(插件字段名可能又变了)—— 请改用 openid 发` };
169
+ }
170
+
171
+ // ── 逐条发 ─────────────────────────────────────────────────
172
+ const gap = Number.isFinite(Number(args.intervalMs)) ? Number(args.intervalMs) : 600;
173
+ const where = scope === 'c2c' ? '私聊' : '群';
174
+ let ok = 0;
175
+ const errs = [];
176
+ for (let i = 0; i < plan.length; i++) {
177
+ const it = plan[i];
178
+ let sent = false;
179
+ try {
180
+ if (it.type === 'markdown') sent = await qq.sendMarkdown(scope, id, it.payload);
181
+ else if (it.type === 'image') sent = await qq.sendImage(scope, id, isUrl(it.payload) ? { url: it.payload } : { localPath: it.payload });
182
+ else sent = await qq.sendText(scope, id, it.payload);
183
+ } catch { sent = false; }
184
+ if (sent) ok += 1; else errs.push(`第${i + 1}条(${KIND_CN[it.type] || it.type})`);
185
+ if (i < plan.length - 1 && gap > 0) await new Promise((r) => setTimeout(r, gap));
186
+ }
187
+
188
+ // 汇总结论(把发的是什么也说清楚)
189
+ const what = [...new Set(plan.map((it) => KIND_CN[it.type] || it.type))].join('+');
190
+ if (ok === plan.length) {
191
+ const tail = plan.length === 1 ? '' : `连发 ${ok} 条(间隔 ${gap}ms)`;
192
+ const note = plan.some((it) => it.type !== 'text') ? '(图/markdown 走主动通道,会占主动配额)' : '';
193
+ return { ok: true, msg: `已发送到${where}「${label}」:${what}${plan.length === 1 ? ' 1 条' : ''}✓ ${tail}${note}`.replace(/\s+/g, ' ').trim() };
194
+ }
195
+ return {
196
+ ok: ok > 0,
197
+ msg: `发往${where}「${label}」(${what}):成功 ${ok}/${plan.length} 条${errs.length ? ',失败:' + errs.slice(0, 5).join('、') : ''}`
198
+ + (plan.some((it) => it.type === 'text') ? '(文本失败多为 QQ 侧拒绝:被动窗口过期且主动消息不可用)' : '(图/markdown 只走主动通道:配额用尽或被 QQ 拒绝都会失败)'),
199
+ };
200
+ },
201
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * session_children.mjs — 列出「自己创建的会话」(跨工作区/跨会话,按血缘)
3
+ *
4
+ * 「把子会话那套搬到跨会话级」的第 4 块:创建者要能随时看到自己开了哪些会话、跑得怎么样。
5
+ * 血缘不用自己记台账 —— 宿主 header 里本来就有 `parentSession` / `origin` / `delegationDepth`。
6
+ *
7
+ * ⚠️ 主人要求:本工具**只给 QQ 会话用**(父会话专用,不给子会话看)。
8
+ * 已落地为 `expose: 'qq'`(2026-10-12)——非 'all' 的值只在 QQ 侧注册。
9
+ */
10
+ export default {
11
+ name: 'session_children',
12
+ // ★ 2026-10-12:**父会话专用** ⇒ 不给子会话看(`expose:'all'` 会被挂到插件根、暴露给所有会话;
13
+ // 非 'all' 的值只在 QQ 会话注册 ⇒ 子会话(web)看不到)。本工具只对"有子代的人"有意义。
14
+ // 只注册到 QQ 会话(子会话不可见)—— 本工具是"父会话专用"。
15
+ expose: 'qq',
16
+ description:
17
+ '列出「自己创建的会话」(按 parentSession 血缘,默认只列直接子代,deep=true 递归到全部后代):按最新活跃在前,可 limit。纯只读。',
18
+ inputSchema: {
19
+ limit: { type: 'integer', description: '最多列几个(默认 20,上限 100)' },
20
+ deep: { type: 'boolean', description: 'true=递归列全部后代(默认 false=只看直接子代)' },
21
+ all: { type: 'boolean', description: 'true=列所有会话里的"非我创建"的部分(排查用,默认 false)' },
22
+ },
23
+
24
+ async run(args = {}, env = {}) {
25
+ const ctx = env?.ctx ?? null;
26
+ const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
27
+ if (!root?.get) return { ok: false, msg: '拿不到宿主 ctx(需要 expose:\'all\')' };
28
+
29
+ const me = String(env?.exec?.agent?.id ?? env?.exec?.sessionId ?? '').trim();
30
+ if (!me) return { ok: false, msg: '拿不到"我是谁"(env.exec.agent.id 缺失)→ 无法按血缘筛选' };
31
+
32
+ const limit = Math.max(1, Math.min(100, Number(args?.limit) > 0 ? Math.floor(Number(args.limit)) : 20));
33
+ const deep = !!args?.deep;
34
+
35
+ const sq = (() => { try { return root.get('sessionQuery'); } catch { return undefined; } })();
36
+ if (!sq || typeof sq.listSessions !== 'function') return { ok: false, msg: '宿主没挂 sessionQuery 服务' };
37
+
38
+ let list = [];
39
+ try { list = (await sq.listSessions()) ?? []; } catch (e) { return { ok: false, msg: 'listSessions 抛错: ' + String(e?.message ?? e) }; }
40
+ if (!Array.isArray(list)) list = [];
41
+
42
+ const hdr = (s) => s?.header ?? s ?? {};
43
+ const idOf = (s) => String(hdr(s).id ?? '');
44
+ const parentOf = (s) => String(hdr(s).parentSession ?? '');
45
+ const timeOf = (s) => {
46
+ const t = hdr(s).createdAt ?? s?.lastActivity;
47
+ const d = new Date(typeof t === 'number' ? t : String(t ?? ''));
48
+ return Number.isNaN(d.getTime()) ? 0 : d.getTime();
49
+ };
50
+
51
+ // 直接子代
52
+ const direct = list.filter((s) => parentOf(s) === me);
53
+ // 递归后代(BFS,防环)
54
+ let picked = direct;
55
+ if (deep) {
56
+ const seen = new Set(direct.map(idOf));
57
+ const out = [...direct];
58
+ let frontier = direct;
59
+ for (let depth = 0; depth < 6 && frontier.length; depth += 1) {
60
+ const next = [];
61
+ for (const p of frontier) {
62
+ const pid = idOf(p);
63
+ for (const s of list) {
64
+ const sid = idOf(s);
65
+ if (sid && parentOf(s) === pid && !seen.has(sid)) { seen.add(sid); out.push(s); next.push(s); }
66
+ }
67
+ }
68
+ frontier = next;
69
+ }
70
+ picked = out;
71
+ }
72
+ picked.sort((a, b) => timeOf(b) - timeOf(a));
73
+
74
+ const p = (n) => String(n).padStart(2, '0');
75
+ const fmt = (s) => {
76
+ const t = new Date(timeOf(s));
77
+ const when = Number.isNaN(t.getTime()) ? '' : `${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`;
78
+ const h = hdr(s);
79
+ const wid = idOf(s);
80
+ const short = wid.length > 14 ? wid.slice(0, 8) + '…' + wid.slice(-4) : wid;
81
+ const live = (h.live ?? s?.live) ? 'live' : 'persisted';
82
+ const cwd = String(h.cwd ?? '').replace(/^.*[\\/]/, '') || '?';
83
+ return ` · ${when} [${live}] ${short} ${cwd} origin=${h.origin ?? '?'} depth=${h.delegationDepth ?? '?'}`;
84
+ };
85
+
86
+ const head = `【我创建的会话】我=${me.slice(0, 8)}… 直接子代 ${direct.length} 个` +
87
+ (deep ? ` / 全部后代 ${picked.length} 个` : '');
88
+ const body = picked.length
89
+ ? picked.slice(0, limit).map(fmt).join('\n') +
90
+ (picked.length > limit ? `\n … 还有 ${picked.length - limit} 个(用 limit 调整)` : '')
91
+ : ' (还没有子会话 —— 用"创建对话"那个工具开一个就会出现在这里)';
92
+
93
+ const otherLine = args?.all
94
+ ? `\n(对照)非我创建的会话 ${list.length - picked.length} 个,其中 live ${list.filter((s) => hdr(s).live ?? s?.live).length} 个`
95
+ : '';
96
+
97
+ return { ok: true, msg: [head, body].join('\n') + otherLine };
98
+ },
99
+ };