@zaofan/dsh-qqbot 1.10.8 → 1.10.10

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.
Files changed (44) hide show
  1. package/README.md +3 -2
  2. package/client/qqbot-settings.js +6204 -6196
  3. package/dist/features/botplay.d.ts.map +1 -1
  4. package/dist/features/botplay.js +6 -2
  5. package/dist/features/botplay.js.map +1 -1
  6. package/dist/features/ext-capabilities.d.ts +2 -0
  7. package/dist/features/ext-capabilities.d.ts.map +1 -1
  8. package/dist/features/ext-capabilities.js +6 -0
  9. package/dist/features/ext-capabilities.js.map +1 -1
  10. package/dist/features/ext-outbound.d.ts +238 -0
  11. package/dist/features/ext-outbound.d.ts.map +1 -0
  12. package/dist/features/ext-outbound.js +518 -0
  13. package/dist/features/ext-outbound.js.map +1 -0
  14. package/dist/features/ext-reload.d.ts +19 -0
  15. package/dist/features/ext-reload.d.ts.map +1 -1
  16. package/dist/features/ext-reload.js +40 -0
  17. package/dist/features/ext-reload.js.map +1 -1
  18. package/dist/features/qq-user-questions.d.ts +6 -0
  19. package/dist/features/qq-user-questions.d.ts.map +1 -1
  20. package/dist/features/qq-user-questions.js +17 -3
  21. package/dist/features/qq-user-questions.js.map +1 -1
  22. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  23. package/dist/gateway/middleware-setup.js +19 -0
  24. package/dist/gateway/middleware-setup.js.map +1 -1
  25. package/dist/session/session-manager.d.ts.map +1 -1
  26. package/dist/session/session-manager.js +5 -2
  27. package/dist/session/session-manager.js.map +1 -1
  28. package/dist/transport/inbound.js +3 -3
  29. package/dist/transport/inbound.js.map +1 -1
  30. package/dist/transport/outbound-buffer.d.ts +31 -1
  31. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  32. package/dist/transport/outbound-buffer.js +49 -1
  33. package/dist/transport/outbound-buffer.js.map +1 -1
  34. package/dist/transport/outbound.d.ts.map +1 -1
  35. package/dist/transport/outbound.js +20 -0
  36. package/dist/transport/outbound.js.map +1 -1
  37. package/examples/ext-commands//344/274/232/350/257/235/345/210/227/350/241/250.mjs +756 -742
  38. package/examples/ext-commands//345/224/244/351/206/222.mjs +71 -0
  39. package/examples/ext-commands//345/255/220/344/274/232/350/257/235.mjs +16 -5
  40. package/examples/ext-outbound/README.md +139 -0
  41. package/examples/ext-outbound/sticker-semantic.mjs +524 -0
  42. package/examples/ext-tools/sticker_auto.mjs +260 -0
  43. package/examples/ext-tools/vote.mjs +534 -323
  44. package/package.json +144 -144
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 唤醒.mjs —— /唤醒 扩展命令:手动叫醒鲸鱼娘(2026-10-16 主人需求)
3
+ *
4
+ * 需求原文:
5
+ * 「私聊不能@,给私聊环境加一个强制唤醒的方式?可以弄个扩展斜杠指令 /唤醒,
6
+ * 作用就是发送一条『用户呼唤你』」
7
+ *
8
+ * ★ 唤醒方式(2026-10-16 第二次修订:跟投票工具走同一条路)
9
+ * 首选 `caps.appendWake(text)` —— 它就是扩展工具 `env.appendWake` 的同源实现
10
+ * (dist/features/ext-capabilities.js:285):
11
+ *
12
+ * appendWake: async (text) => {
13
+ * agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
14
+ * }
15
+ *
16
+ * ⇒ 直接对**本会话的 agent** 调 followup 开一个新回合唤醒 AI。投票结束把结算表推醒 AI,
17
+ * 用的就是这条路(实测有效)。命令侧能力包由 middleware-setup.js 的 capsForCmd 组装,
18
+ * 里面已经带了 `agent: manager.findByPeer(scope, peerId)?.agent`,所以命令同样拿得到。
19
+ *
20
+ * 兜底:若 appendWake 不可用(该 peer 还没有会话记录 / agent 拿不到),返回
21
+ * `{ wake: { content, senderName } }` —— 插件会走聚合层 injectSynthetic(scope, peerId, …)
22
+ * 把这条合成消息交给聚合窗口(c2c 与群聊都支持;与真人消息同窗口聚合;不伪造 @)。
23
+ *
24
+ * 用法:
25
+ * /唤醒 → 默认文案("用户在呼唤你")
26
+ * /唤醒 在吗 → 把「在吗」当成用户在呼唤她
27
+ *
28
+ * ⚠️ 命令新增/改完用 tools_reload 热刷(未生效就重启一次宿主)。
29
+ */
30
+
31
+ /** 合成消息的前缀 —— 让 AI 一眼看出这是"用户在呼唤"而不是普通群聊 */
32
+ const WAKE_PREFIX = '[用户呼唤]';
33
+
34
+ /** 默认呼唤文案 */
35
+ const DEFAULT_SAY = '用户在呼唤你';
36
+
37
+ export default {
38
+ name: ['唤醒', 'wake', 'huanxing'],
39
+ description: '强制唤醒: /唤醒 [要说的话] —— 把这句话当作"用户在呼唤你"送进会话(私聊没有 @,用它代替)',
40
+ usage: '/唤醒 [要说的话]',
41
+
42
+ async handler(ctx) {
43
+ // raw 可能是「/唤醒 在吗」也可能是「在吗」→ 两种都吃掉
44
+ let say = String(ctx?.command?.raw ?? '').trim();
45
+ say = say.replace(/^\/?\s*(唤醒|wake|huanxing)\s*/i, '').trim();
46
+ if (!say) say = DEFAULT_SAY;
47
+
48
+ const isGroup = String(ctx?.message?.kind ?? '') === 'group';
49
+ const who = String(ctx?.message?.senderName ?? '').trim() || (isGroup ? '群友' : '主人');
50
+ const where = isGroup ? '群里' : '私聊里';
51
+ const text = `${WAKE_PREFIX} ${who} 在${where}呼唤你:${say}`;
52
+ const receipt = `🔔 叫醒她啦~「${say.slice(0, 40)}」`;
53
+
54
+ // ── 首选:caps.appendWake(= 工具侧 env.appendWake:直接 agent.followup 开新回合)──
55
+ const caps = ctx?.caps ?? ctx ?? {};
56
+ const appendWake = caps.appendWake ?? ctx?.appendWake;
57
+ if (typeof appendWake === 'function') {
58
+ try {
59
+ const ok = await appendWake(text);
60
+ if (ok !== false) return { kind: 'text', content: receipt };
61
+ } catch { /* 落到下面的聚合层兜底 */ }
62
+ }
63
+
64
+ // ── 兜底:交给聚合层的合成消息通道(middleware-setup 里的 injectSynthetic)──
65
+ return {
66
+ kind: 'text',
67
+ content: receipt,
68
+ wake: { content: text, senderName: who },
69
+ };
70
+ },
71
+ };
@@ -57,13 +57,14 @@ function dataRootOf(ctx) {
57
57
  * ★ 诊断日志(2026-10-13 主人要求):写死路径,只为看清"点击到底有没有走到命令"。
58
58
  * 每条只记事实:handler 被调 / onInteraction 被调 / 发卡结果 / 状态表里有没有这张卡。
59
59
  */
60
- const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/子会话-diag.log';
60
+ // ★ 2026-10-16 修复: 原来写死 Windows 路径(D:/…) —— 手机(POSIX)上永远写不进去, 排查等于瞎。
61
+ const DIAG_URL = new URL('../data/子会话-diag.log', import.meta.url);
61
62
  async function diag(msg) {
62
63
  try {
63
64
  const { appendFileSync, mkdirSync } = await import('node:fs');
64
65
  const { dirname } = await import('node:path');
65
- mkdirSync(dirname(DIAG_FILE), { recursive: true });
66
- appendFileSync(DIAG_FILE, `${new Date().toISOString()} ${msg}\n`);
66
+ mkdirSync(new URL('../data/', import.meta.url), { recursive: true });
67
+ appendFileSync(DIAG_URL, `${new Date().toISOString()} ${msg}\n`);
67
68
  } catch { /* 诊断失败绝不影响主流程 */ }
68
69
  }
69
70
 
@@ -118,7 +119,13 @@ async function sendCard(ctx, text, keyboard) {
118
119
  const rt = ctx?.replyTarget ?? {};
119
120
  const pe = ctx?.peer ?? {};
120
121
  const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
121
- const gid = cn(mc.groupOpenid || rt.targetId || rt.openid || rt.id || pe.groupId || pe.groupOpenid || (scope === 'group' ? (pe.peerId || pe.id) : '') || '');
122
+ // ★ 2026-10-16 c2c 修复: 原来 rt.targetId 在私聊里就是**对方 openid**, 会被当成群号 →
123
+ // tier① 直连去请求 /v2/groups/<user_openid>/messages 必然被拒(gid 非空时 body 也不带
124
+ // c2c 需要的 content 字段) → 一路降级成"文字发出来了、按钮消失"。
125
+ // 私聊一律 gid='' 走 /v2/users/<openid>/messages。
126
+ const gid = scope === 'group'
127
+ ? cn(mc.groupOpenid || rt.targetId || rt.openid || rt.id || pe.groupId || pe.groupOpenid || pe.peerId || pe.id || '')
128
+ : '';
122
129
  const uid = cn(mc.senderId || rt.openid || rt.id || pe.openid || pe.userId || '');
123
130
  void diag(`[sendCard] peer=${JSON.stringify(pe ?? null).slice(0, 180)} scope=${scope} gid=${gid} uid=${uid}`);
124
131
 
@@ -132,7 +139,11 @@ async function sendCard(ctx, text, keyboard) {
132
139
  void diag(`[sendCard] gid=${gid || '-'} uid=${uid || '-'} kb=${hasKb} hasApi=${typeof api === 'function'} hasMdCard=${typeof caps.markdownCard === 'function'} hasSenderKb=${typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function'} hasMd=${typeof caps.markdown === 'function'}`);
133
140
  if (keyboard && typeof api === 'function' && (gid || uid)) {
134
141
  const path = gid ? `/v2/groups/${gid}/messages` : `/v2/users/${uid}/messages`;
135
- const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
142
+ // ★ 2026-10-16 c2c 修复: 私聊的 markdown+键盘消息带上 msg_id(被动回复锚点),
143
+ // 否则可能被拒/退化; 群聊保持原样(实测不带 msg_id 也能发)。
144
+ const replyMsgId = cn(rt.msgId || rt.messageId || mc.msgId || mc.messageId || '');
145
+ void diag(`[sendCard] ①path=${path} msgId=${replyMsgId || '-'} kbRows=${keyboard?.content?.rows?.length ?? '?'}`);
146
+ const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text, ...(replyMsgId ? { msg_id: replyMsgId } : {}) }) };
136
147
  try {
137
148
  const r = await api(path, { method: 'POST', body });
138
149
  void diag(`[sendCard] ①api 返回=${JSON.stringify(r ?? null).slice(0, 180)}`);
@@ -0,0 +1,139 @@
1
+ # 出站扩展(第六套扩展点:`outbound/`)
2
+
3
+ > 与 **入站扩展** 严格对称:
4
+ > 入站管「消息**进来**时改它 / 杀它」,出站管「消息**出去**时改它 / 杀它 / **补发媒体**」。
5
+
6
+ ## 装哪儿
7
+
8
+ ```
9
+ {数据根}/.qqbot-extensions/outbound/*.mjs ← 只扫**直接子文件**,按文件名升序
10
+ ```
11
+
12
+ 放好之后 `/tools-reload`(回执里会打印「出站扩展: N 个」)。改文件即热重载,**不用重启宿主**。
13
+
14
+ ## 最小形状
15
+
16
+ ```js
17
+ export const name = 'my-outbound';
18
+
19
+ export default async function onOutbound(ctx, next) {
20
+ // …改 / 杀 / 补发…
21
+ return next();
22
+ }
23
+ // 也支持 export async function onOutbound(ctx, next) {}
24
+ ```
25
+
26
+ `ctx` 上的关键字段:
27
+
28
+ | 字段 | 说明 |
29
+ | --- | --- |
30
+ | `ctx.text` | 本条出站正文(只读;要改请用 `ctx.rewrite()`) |
31
+ | `ctx.kind` | `'text' \| 'markdown' \| 'media' \| 'card' \| 'forward' \| 'other'` |
32
+ | `ctx.target` | `{ scope, peerId, msgId? }` —— **被动回复靠它自带的 msgId** |
33
+ | `ctx.turnId` / `ctx.chunkIndex` / `ctx.isLastChunk` | 回合内定位 |
34
+ | **`ctx.phase`** | `'send'` = 正文**发送前**;`'turnEnd'` = **回合真正结束** |
35
+ | `ctx.rewrite(t)` / `ctx.annihilate(why)` | 改写 / 消灭本条出站 |
36
+ | `ctx.sendMedia({path\|url,text?})` / `ctx.send(text)` | 额外补发(**先被动、失败/超限自动转主动**) |
37
+ | `ctx.background(fn)` | ★ **丢后台**:不 await、不阻塞正文(见下) |
38
+ | `ctx.note(msg)` | 审计/诊断一行(最坏丢日志,绝不抛) |
39
+
40
+ 另有与前五套同款的统一能力:`emit / markdown / image / media / store / identity / api / kernel / appendSilent / appendWake / logger`。
41
+
42
+ ## ★ 两条铁律(都是真机踩出来的)
43
+
44
+ ### 1. 一段回复会被拆成多个正文块 ⇒ **配图只放 `phase === 'turnEnd'`**
45
+
46
+ 一段「正文 → 工具调用 → 正文」的回复,每段都各走一次出站链。在 `'send'` 阶段配图,
47
+ **第一段根本不是回合最后一块**,于是要么不配、要么配两张。
48
+
49
+ `'turnEnd'` 阶段由 `transport/outbound.ts` 的 `turn/end`(`flush()` 之后)触发,
50
+ 此时 `ctx.text` 就是该回合**权威的最后一块正文**,而且正文早发完了 —— 慢活随便干。
51
+
52
+ ### 2. 重活必须 `ctx.background()`,**绝不能 await**
53
+
54
+ > 「图片自动发送的进程压根就不该和正文有关,唯一有关的只是从出站消息里读取到底是什么内容。」
55
+
56
+ 出站链与正文**唯一的联系是"读一眼正文写了什么"**。选图、算分、编码向量、发图这些
57
+ 一律丢后台,然后**立刻 `next()` 放行**:
58
+
59
+ ```js
60
+ export default async function onOutbound(ctx, next) {
61
+ if (ctx.phase !== 'turnEnd') return next(); // ① 只认回合结束
62
+ const safeNext = typeof next === 'function' ? next : () => undefined;
63
+ if (typeof ctx.background === 'function') {
64
+ ctx.background(() => pickAndSend(ctx)); // ② 重活丢后台
65
+ return safeNext(); // ③ 立刻放行正文
66
+ }
67
+ return pickAndSend(ctx); // 老宿主兜底
68
+ }
69
+ ```
70
+
71
+ ⚠️ 后台跑时 `next` 是 `undefined`,而业务函数里到处是早期 `return next()`
72
+ ⇒ 必须传一个 **安全 next**(实测踩过 `next is not a function`)。
73
+ ⚠️ 反面教材:曾在链上**同步**扫全库 1892 张图 + 首次向量编码(十几~几十秒),
74
+ 结果把 `sendRichOutbound` 的 3 秒硬超时打满 ⇒ **连正文都发不出去**(QQ 里什么都没有)。
75
+
76
+ ## 示范:`sticker-semantic.mjs`(按正文自动配表情包)
77
+
78
+ 拷到 `outbound/sticker-semantic.mjs` 即生效。它做两阶段召回:
79
+
80
+ 1. **阶段1**:随机抽 `POOL`(=300) 张 → 收集这些图的 `tags`(几百个关键词)→
81
+ 用**本地小模型**(`bge-small-zh`,没有就退化成字符 bigram)给 query 打关键词分
82
+ → 选出最贴的那个关键词。
83
+ 另加**存在性闸门**:最高分要过 `均值 + GATE_K*标准差`,并过绝对地板
84
+ (`FLOOR_EMBED`/`FLOOR_BIGRAM` —— **两个算法的分数量级差 10 倍,必须分两套**)。
85
+ 2. **阶段2**:拿选中的关键词**回全库精确搜**(`tags.includes`,零模型开销)→ 随机取一张发出。
86
+
87
+ query 取**末句**(一段话常混着寒暄/技术/情绪,整段进向量会被平均掉);
88
+ `msgText`(群友原话)**不进向量**、只用于「梗命中」加分。
89
+
90
+ ### 它的两级开关
91
+
92
+ | 级别 | 文件 / 工具 | 缺省 |
93
+ | --- | --- | --- |
94
+ | 总开关(全局) | `data/auto-sticker.json`(工具 `sticker_auto`) | **开** |
95
+ | **隔离(白名单)** | `data/sticker-semantic-scope.json` | **whitelist + 空名单 = 谁都不发** |
96
+
97
+ ```json
98
+ // data/sticker-semantic-scope.json
99
+ { "mode": "whitelist", "targets": ["group:<群openid>", "c2c:<用户openid>"] }
100
+ ```
101
+
102
+ 匹配用 `<scope>:<目标id>`,也接受**只写裸 openid**(手写方便);
103
+ `mode: "all"` 才恢复「所有目标都生效」。
104
+ **读不到 / 读坏了都从"不发"这一侧失败** —— 隔离的东西宁可哑火,不能漏发。
105
+
106
+ 用工具管理(实现见 `examples/ext-tools/sticker_auto.mjs`):
107
+
108
+ ```
109
+ sticker_auto {action:'status'} // 总开关 + 名单
110
+ sticker_auto {action:'scope-add', target:'group:<群openid>', label:'群名'}
111
+ sticker_auto {action:'scope-remove', target:'group:<群openid>'}
112
+ sticker_auto {action:'scope-mode', mode:'whitelist'|'all'}
113
+ ```
114
+
115
+ ### 排障
116
+
117
+ 失败**全是静默的**(异常被吞、找不到文件就 return),所以它自带一行式诊断日志:
118
+
119
+ ```
120
+ {数据根}/.qqbot-extensions/data/sticker-semantic-diag.log
121
+ ```
122
+
123
+ 每步都留痕:`skip:scope` / `skip:cooldown` / `stage1` / `skip:below-floor` /
124
+ `skip:not-salient` / `stage2` / `sent` / `send-failed` / `error`。
125
+ `CONFIG.FORCE = true` 是**故意留的排障开关**:跳过所有判断、无条件发库里第一张 ——
126
+ 用来区分「出站链压根没被调用」和「链通了但选图逻辑不发」。
127
+
128
+ ## 通过全局 API 给别的扩展用
129
+
130
+ ```js
131
+ const ob = globalThis[Symbol.for('im-qqbot.outbound')];
132
+ ob.send('文本'); ob.sendMedia({ path }); ob.last(); ob.active();
133
+ // 本插件自留:ob.embed(query, passages) / ob.embedAvailable()(本地小模型,用于给关键词打分)
134
+ ```
135
+
136
+ ## 熔断
137
+
138
+ 同一个扩展**连续 5 次抛错**会被停用(不拖累其它扩展和正文),
139
+ `resetOutboundBreaker(name?)` 复位;`listOutboundExtensions()` / `outboundActive()` / `lastOutboundCtx()` 供自检。