@zaofan/dsh-qqbot 1.10.9 → 1.10.11
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 +86 -2
- package/README_EN.md +31 -0
- package/client/qqbot-settings.js +6313 -6196
- package/dist/channel-tools.d.ts +0 -12
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +202 -20
- package/dist/channel-tools.js.map +1 -1
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/features/botplay.d.ts.map +1 -1
- package/dist/features/botplay.js +6 -2
- package/dist/features/botplay.js.map +1 -1
- package/dist/features/ext-capabilities.d.ts +2 -0
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js +6 -0
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-outbound.d.ts +238 -0
- package/dist/features/ext-outbound.d.ts.map +1 -0
- package/dist/features/ext-outbound.js +518 -0
- package/dist/features/ext-outbound.js.map +1 -0
- package/dist/features/ext-reload.d.ts +19 -0
- package/dist/features/ext-reload.d.ts.map +1 -1
- package/dist/features/ext-reload.js +40 -0
- package/dist/features/ext-reload.js.map +1 -1
- package/dist/features/msg-history.d.ts +78 -0
- package/dist/features/msg-history.d.ts.map +1 -0
- package/dist/features/msg-history.js +312 -0
- package/dist/features/msg-history.js.map +1 -0
- package/dist/features/msg-recent.d.ts +72 -0
- package/dist/features/msg-recent.d.ts.map +1 -0
- package/dist/features/msg-recent.js +131 -0
- package/dist/features/msg-recent.js.map +1 -0
- package/dist/features/qq-user-questions.d.ts +6 -0
- package/dist/features/qq-user-questions.d.ts.map +1 -1
- package/dist/features/qq-user-questions.js +17 -3
- package/dist/features/qq-user-questions.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +83 -4
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +55 -0
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +5 -2
- package/dist/session/session-manager.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +90 -4
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/msg-content-cache.d.ts +50 -0
- package/dist/transport/msg-content-cache.d.ts.map +1 -1
- package/dist/transport/msg-content-cache.js +58 -14
- package/dist/transport/msg-content-cache.js.map +1 -1
- package/dist/transport/outbound-buffer.d.ts +31 -1
- package/dist/transport/outbound-buffer.d.ts.map +1 -1
- package/dist/transport/outbound-buffer.js +49 -1
- package/dist/transport/outbound-buffer.js.map +1 -1
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +20 -0
- package/dist/transport/outbound.js.map +1 -1
- package/examples/ext-commands//344/274/232/350/257/235/345/210/227/350/241/250.mjs +756 -742
- package/examples/ext-commands//345/224/244/351/206/222.mjs +71 -0
- package/examples/ext-commands//345/255/220/344/274/232/350/257/235.mjs +16 -5
- package/examples/ext-outbound/README.md +139 -0
- package/examples/ext-outbound/sticker-semantic.mjs +524 -0
- package/examples/ext-tools/sticker_auto.mjs +260 -0
- package/examples/ext-tools/vote.mjs +534 -323
- package/package.json +144 -144
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sticker-semantic.mjs — 【出站扩展】示范:按"本回合最后一段正文"自动配一张表情包
|
|
3
|
+
*
|
|
4
|
+
* 放到 `{dataRoot}/.qqbot-extensions/outbound/sticker-semantic.mjs` 即生效(改完 `/tools-reload`)。
|
|
5
|
+
*
|
|
6
|
+
* ── 它做什么 ──────────────────────────────────────────────────────────────
|
|
7
|
+
* 1. 从本地表情包库(`{dataRoot}/表情包/index.json`)**随机抽 100 张**当候选池;
|
|
8
|
+
* 2. 拿**本回合最后一个正文块**(`ctx.isLastChunk === true`)当查询串;
|
|
9
|
+
* 3. 用「字符 bigram Jaccard 相似度」跟每张图的 `tags + desc + msgText` 比一遍,
|
|
10
|
+
* 取分最高且**过阈值**的那张;
|
|
11
|
+
* 4. 用 `ctx.sendMedia()` 发出去 —— 这条通道**先被动、失败/超限自动转主动**。
|
|
12
|
+
*
|
|
13
|
+
* ── 为什么用 bigram 而不是向量 ────────────────────────────────────────────
|
|
14
|
+
* 示范要"拷过去就能跑":bigram 零依赖、离线、微秒级。要上真语义就把
|
|
15
|
+
* `similarity()` 换成你自己调模型的实现(`ctx.kernel` 能拿到插件 cordis Context)。
|
|
16
|
+
*
|
|
17
|
+
* ── 开关 ──────────────────────────────────────────────────────────────────
|
|
18
|
+
* ★ 两级开关(2026-10-17 主人定,加了隔离):
|
|
19
|
+
* ① **总开关**:工具 `sticker_auto`,状态文件
|
|
20
|
+
* `{dataRoot}/.qqbot-extensions/data/auto-sticker.json` → `{ "enabled": true|false }`
|
|
21
|
+
* 文件不存在 = **默认开**(想默认关就改 DEFAULT_ENABLED)。
|
|
22
|
+
* ② **隔离(白名单)**:状态文件
|
|
23
|
+
* `{dataRoot}/.qqbot-extensions/data/sticker-semantic-scope.json`
|
|
24
|
+
* `{ "mode": "whitelist", "targets": ["group:<群openid>", "c2c:<用户openid>"] }`
|
|
25
|
+
* **文件不存在 = whitelist + 空名单 ⇒ 谁都不配图**;只对名单里的群/私聊生效。
|
|
26
|
+
* 用 `sticker_auto {action:'scope-add', target:'group:xxxx', label:'群名'}` 增删。
|
|
27
|
+
*
|
|
28
|
+
* ── 参数(文件顶部 CONFIG,直接改)────────────────────────────────────────
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
const CONFIG = {
|
|
34
|
+
/** 候选池大小(随机抽多少张)—— 300 张足够命中,也不至于每回合算几千次 */
|
|
35
|
+
POOL: 300,
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ 2026-10-16 主人定:**不要阈值了,直接发最高分那张。**
|
|
38
|
+
* 原因:`tags+desc` 是标签式短文本、正文是自然语句,bigram 的重合天生很低
|
|
39
|
+
* (实测长句 0.02~0.04、短句 <0.01),卡阈值的结果是"经常一张不发",体验更差。
|
|
40
|
+
* 现在只在候选池为空时才不发;**由 `MIN_TEXT` 兜住"太短没语义"的情况**。
|
|
41
|
+
* (想恢复阈值:把下面这行改成正数即可。)
|
|
42
|
+
*/
|
|
43
|
+
MIN_SCORE: 0,
|
|
44
|
+
/** 正文多短就不配图(太短没语义,配了也是瞎配) */
|
|
45
|
+
MIN_TEXT: 6,
|
|
46
|
+
/** 每回合最多发几张 */
|
|
47
|
+
MAX_PER_TURN: 1,
|
|
48
|
+
/**
|
|
49
|
+
* 发送冷却(毫秒):**同一个会话**在这个窗口内最多配 1 张。
|
|
50
|
+
* 为什么不用"每回合计数":出站块是分开调的,跨块拦不住(实测一次回复发了两张);
|
|
51
|
+
* 而用 targetId 当归零键又会导致"发过一次就永远不发"。时间窗两个坑都避开。
|
|
52
|
+
*/
|
|
53
|
+
COOLDOWN_MS: 25000,
|
|
54
|
+
/** 默认开关(状态文件不存在时用) */
|
|
55
|
+
DEFAULT_ENABLED: true,
|
|
56
|
+
|
|
57
|
+
// ── ★ 2026-10-16 专家团改法(主人拍板"按你的来")───────────────────────
|
|
58
|
+
/** 梗命中加分:query 的词出现在这张图的 msgText(群友原话)里 ⇒ 加一点分 */
|
|
59
|
+
MSG_BONUS: 0.06,
|
|
60
|
+
/** 存在性闸门系数:最高分没到 `均值 + GATE_K * 标准差` ⇒ 认为"库里没合适的" ⇒ 不发 */
|
|
61
|
+
GATE_K: 1.2,
|
|
62
|
+
/**
|
|
63
|
+
* 绝对地板(**必须按 scorer 分两套**:两个算法的分数尺度差一个数量级)
|
|
64
|
+
* · local-model(bge 余弦):中文短文本通常 0.3~0.7,低于 0.32 基本是不沾边
|
|
65
|
+
* · bigram(字符 2-gram Jaccard):长句实测最高才 0.02~0.04,地板得放 0.012
|
|
66
|
+
*/
|
|
67
|
+
FLOOR_EMBED: 0.32,
|
|
68
|
+
FLOOR_BIGRAM: 0.012,
|
|
69
|
+
/**
|
|
70
|
+
* ★ 诊断开关(已排查完毕,保持 false):
|
|
71
|
+
* true = 跳过所有判断,**每回合无条件发库里第一张图** —— 用来区分两种失败:
|
|
72
|
+
* · 连它都不发 ⇒ **出站链压根没被调用**(挂载点/装载问题,不是扩展逻辑)
|
|
73
|
+
* · 它发了 ⇒ 链是通的,问题在"选图/阈值/路径"这些判断里
|
|
74
|
+
* 2026-10-16 实测结论:置 true 后**立刻出图** ⇒ 链通,卡点确实在选图逻辑。
|
|
75
|
+
*/
|
|
76
|
+
FORCE: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const fsp = { readFileSync, existsSync };
|
|
80
|
+
|
|
81
|
+
/** 状态文件路径(与 tools/sticker_auto.mjs 共用同一份) */
|
|
82
|
+
function stateFile(dataRoot) {
|
|
83
|
+
return join(String(dataRoot || ''), '.qqbot-extensions', 'data', 'auto-sticker.json');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 读开关;读不到就用默认值(**任何异常都当作默认**,绝不因为读文件失败就不出站) */
|
|
87
|
+
function isEnabled(ctx) {
|
|
88
|
+
try {
|
|
89
|
+
const f = stateFile(ctx.dataRoot);
|
|
90
|
+
if (!fsp.existsSync(f)) return CONFIG.DEFAULT_ENABLED;
|
|
91
|
+
const j = JSON.parse(fsp.readFileSync(f, 'utf8'));
|
|
92
|
+
return j && typeof j.enabled === 'boolean' ? j.enabled : CONFIG.DEFAULT_ENABLED;
|
|
93
|
+
} catch {
|
|
94
|
+
return CONFIG.DEFAULT_ENABLED;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
99
|
+
// ★★ 隔离设置(2026-10-17 主人定)★★
|
|
100
|
+
// 「你这个自动发送表情包的能力,应该只对单个目标生效啊(就像智能恢复一样,
|
|
101
|
+
// 指定一个群或私聊有效,隔离设置)」
|
|
102
|
+
//
|
|
103
|
+
// ⇒ 从"全局开"改成**按目标白名单**:
|
|
104
|
+
// · 配置文件:`{dataRoot}/.qqbot-extensions/data/sticker-semantic-scope.json`
|
|
105
|
+
// `{ "mode": "whitelist"|"all", "targets": ["group:<群openid>", "c2c:<用户openid>"] }`
|
|
106
|
+
// · **文件不存在 = whitelist + 空名单 ⇒ 谁都不配图**(要全局开就写 mode:"all");
|
|
107
|
+
// · 匹配用 `<scope>:<targetId>`(同时接受只写裸 openid 的简写,方便手写);
|
|
108
|
+
// · 读文件/解析失败也**从"不发"这一侧失败** —— 隔离的东西宁可哑火,不能漏发。
|
|
109
|
+
// 用工具 `sticker_auto` 的 scope-add / scope-remove / scope-list / scope-mode 管理。
|
|
110
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
/** 隔离配置路径(与 tools/sticker_auto.mjs 共用) */
|
|
113
|
+
function scopeFile(dataRoot) {
|
|
114
|
+
return join(String(dataRoot || ''), '.qqbot-extensions', 'data', 'sticker-semantic-scope.json');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const EMPTY_SCOPE = { mode: 'whitelist', targets: [] };
|
|
118
|
+
|
|
119
|
+
/** 读隔离配置(读不到 / 读坏了 ⇒ 空白名单 = 不发) */
|
|
120
|
+
function readScope(ctx) {
|
|
121
|
+
try {
|
|
122
|
+
const f = scopeFile(ctx?.dataRoot);
|
|
123
|
+
if (!fsp.existsSync(f)) return EMPTY_SCOPE;
|
|
124
|
+
const j = JSON.parse(fsp.readFileSync(f, 'utf8'));
|
|
125
|
+
const mode = j?.mode === 'all' ? 'all' : 'whitelist';
|
|
126
|
+
const targets = (Array.isArray(j?.targets) ? j.targets : [])
|
|
127
|
+
.map((t) => (typeof t === 'string' ? t : t?.key))
|
|
128
|
+
.map((s) => String(s || '').trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
return { mode, targets };
|
|
131
|
+
} catch {
|
|
132
|
+
return EMPTY_SCOPE;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 本回合目标的可匹配写法:`scope:id` + 裸 `id`(两种写法都认) */
|
|
137
|
+
function targetKeysOf(ctx) {
|
|
138
|
+
const t = ctx?.target || {};
|
|
139
|
+
const id = String(t.targetId || t.peerId || '').trim();
|
|
140
|
+
const scope = String(t.scope || '').trim();
|
|
141
|
+
const keys = [];
|
|
142
|
+
if (scope && id) keys.push(`${scope}:${id}`);
|
|
143
|
+
if (id) keys.push(id);
|
|
144
|
+
return keys;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 本回合目标是否在白名单里 */
|
|
148
|
+
function scopeAllows(ctx) {
|
|
149
|
+
const s = readScope(ctx);
|
|
150
|
+
if (s.mode === 'all') return { ok: true, why: 'mode=all' };
|
|
151
|
+
const keys = targetKeysOf(ctx);
|
|
152
|
+
if (!keys.length) return { ok: false, why: 'no-target-id', keys };
|
|
153
|
+
const hit = keys.find((k) => s.targets.includes(k));
|
|
154
|
+
return hit ? { ok: true, why: `listed:${hit}`, keys } : { ok: false, why: 'not-listed', keys };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 表情包索引(带 mtime 缓存,避免每回合都读盘)──────────────────────────
|
|
158
|
+
let cache = { at: 0, items: [] };
|
|
159
|
+
|
|
160
|
+
function loadItems(dataRoot) {
|
|
161
|
+
const indexFile = join(String(dataRoot || ''), '表情包', 'index.json');
|
|
162
|
+
try {
|
|
163
|
+
const st = fsp.existsSync(indexFile) ? indexFile : '';
|
|
164
|
+
if (!st) return [];
|
|
165
|
+
const raw = fsp.readFileSync(indexFile, 'utf8');
|
|
166
|
+
const at = raw.length; // 简单指纹:长度变了就重读(够用,热刷也稳)
|
|
167
|
+
if (cache.at === at && cache.items.length) return cache.items;
|
|
168
|
+
const j = JSON.parse(raw);
|
|
169
|
+
// ⚠️ 2026-10-16:**必须只取 `layer === 'library'`**(正式收藏)——
|
|
170
|
+
// 索引里有 800 张 trash + 499 张 candidate,不过滤就会发出"垃圾层"的图
|
|
171
|
+
// (实测匹配到过 lib/trash/ 下一张写着"但是我看不到这个"的图,非常尴尬)。
|
|
172
|
+
const items = (Array.isArray(j?.items) ? j.items : []).filter((it) => it && it.layer === 'library');
|
|
173
|
+
cache = { at, items };
|
|
174
|
+
return items;
|
|
175
|
+
} catch {
|
|
176
|
+
return cache.items.length ? cache.items : [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 把一张图的关键词拼成可比较的串(**只用于日志展示**;进向量的见 vecTextOf) */
|
|
181
|
+
function keywordsOf(it) {
|
|
182
|
+
const tags = Array.isArray(it?.tags) ? it.tags.join(' ') : '';
|
|
183
|
+
return `${tags} ${it?.desc || ''} ${it?.msgText || ''}`.trim();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── ★ 2026-10-16 专家团改法(主人拍板"按你的来")的三个辅助 ──────────────
|
|
187
|
+
|
|
188
|
+
/** 改法2:query 只取**末句**(一段话里常混着寒暄/技术/情绪,整段进向量会被平均掉)。 */
|
|
189
|
+
function pickQuery(text) {
|
|
190
|
+
const t = String(text || '').trim();
|
|
191
|
+
if (!t) return '';
|
|
192
|
+
const parts = t
|
|
193
|
+
.split(/[。!?!?~~\n]+/)
|
|
194
|
+
.map((s) => s.trim())
|
|
195
|
+
.filter((s) => s.length >= 2);
|
|
196
|
+
const last = parts.length ? parts[parts.length - 1] : '';
|
|
197
|
+
// 末句太短(比如"好耶")就退回整段,别拿没信息量的句子去检索
|
|
198
|
+
return last && last.length >= CONFIG.MIN_TEXT ? last : t;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 改法3:**进向量的文本** = tags + desc。**msgText 不进**(群友原话常与画面无关,是污染源)。 */
|
|
202
|
+
function vecTextOf(it) {
|
|
203
|
+
const tags = Array.isArray(it?.tags) ? it.tags.join(' ') : '';
|
|
204
|
+
return `${tags} ${it?.desc || ''}`.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 改法3:**梗命中** —— query 的字面 2-gram 有 ≥1/3 出现在这张图的 msgText 里。 */
|
|
208
|
+
function msgHit(query, it) {
|
|
209
|
+
const m = String(it?.msgText || '');
|
|
210
|
+
if (!m) return false;
|
|
211
|
+
const q = bigrams(query);
|
|
212
|
+
if (!q.size) return false;
|
|
213
|
+
const b = bigrams(m);
|
|
214
|
+
let hit = 0;
|
|
215
|
+
for (const g of q) if (b.has(g)) hit += 1;
|
|
216
|
+
return hit / q.size >= 0.34;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 图的真实路径(**`表情包/lib/{layer}/{id}.{ext}`**);找不到就返回 null */
|
|
220
|
+
function fileOf(dataRoot, it) {
|
|
221
|
+
const layer = String(it?.layer || 'library');
|
|
222
|
+
// ⚠️⚠️ 2026-10-16 真机抓到的真凶:文件名是 **`id`**(12 位短串,如 `baa57a52309e.jpg`),
|
|
223
|
+
// **不是 `hash`**(40 位 sha1,只在 index 里当去重键)。用 hash 拼 ⇒ existsSync 恒 false
|
|
224
|
+
// ⇒ fileOf() 恒 null ⇒ "装好了却一张不发",而且全程**不报任何错**。
|
|
225
|
+
const id = String(it?.id || '');
|
|
226
|
+
const hash = String(it?.hash || '');
|
|
227
|
+
const ext = String(it?.ext || 'jpg');
|
|
228
|
+
const stem = id || hash;
|
|
229
|
+
if (!stem) return null;
|
|
230
|
+
const base = join(String(dataRoot || ''), '表情包');
|
|
231
|
+
const cands = [
|
|
232
|
+
join(base, 'lib', layer, `${stem}.${ext}`), // ← 真实布局:lib/<layer>/<id>.<ext>
|
|
233
|
+
join(base, 'lib', layer, `${hash}.${ext}`), // 旧写法兜底
|
|
234
|
+
join(base, layer, `${stem}.${ext}`),
|
|
235
|
+
join(base, 'lib', `${stem}.${ext}`),
|
|
236
|
+
];
|
|
237
|
+
for (const p of cands) {
|
|
238
|
+
if (fsp.existsSync(p)) return p;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 字符 bigram 集合(中文按 2-gram,英文数字也一起) */
|
|
244
|
+
function bigrams(s) {
|
|
245
|
+
const t = String(s || '').toLowerCase().replace(/\s+/g, '');
|
|
246
|
+
const out = new Set();
|
|
247
|
+
for (let i = 0; i < t.length - 1; i++) out.add(t.slice(i, i + 2));
|
|
248
|
+
if (!out.size && t) out.add(t);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Jaccard 相似度 0~1 */
|
|
253
|
+
function similarity(a, b) {
|
|
254
|
+
const A = bigrams(a);
|
|
255
|
+
const B = bigrams(b);
|
|
256
|
+
if (!A.size || !B.size) return 0;
|
|
257
|
+
let hit = 0;
|
|
258
|
+
for (const g of A) if (B.has(g)) hit += 1;
|
|
259
|
+
return hit / (A.size + B.size - hit);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 随机抽 n 个(Fisher–Yates 取前 n,不改原数组) */
|
|
263
|
+
function sample(arr, n) {
|
|
264
|
+
const a = arr.slice();
|
|
265
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
266
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
267
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
268
|
+
}
|
|
269
|
+
return a.slice(0, n);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── 发送冷却(2026-10-16 修 bug:原来按 turnId/targetId 归零,两条都错)──────────
|
|
273
|
+
// · 用 targetId 当归零键 ⇒ 同一个群永远相同 ⇒ 发过一次就**再也不发**;
|
|
274
|
+
// · 用 turnId 归零 ⇒ 一条回复被拆成多个出站块时跨块拦不住 ⇒ **一回两三条就发两张**。
|
|
275
|
+
// 改成**时间窗**:同一个会话在 COOLDOWN_MS 内最多发 1 张。既不会漏、也不会重。
|
|
276
|
+
let lastSentAt = 0;
|
|
277
|
+
let lastSentKey = '';
|
|
278
|
+
|
|
279
|
+
// ── 诊断日志(append)────────────────────────────────────────────────────
|
|
280
|
+
// ⚠️ 为什么留这个:出站扩展的失败**全是静默的**(异常被吞、找不到文件就 return),
|
|
281
|
+
// 真机排查时"没出图"完全看不出卡在哪一步。有它就能一眼定位。
|
|
282
|
+
// 路径:{dataRoot}/.qqbot-extensions/data/sticker-semantic-diag.log
|
|
283
|
+
// 拿不到 dataRoot 时退到系统临时目录(保证一定写得出去)。
|
|
284
|
+
import { appendFileSync } from 'node:fs';
|
|
285
|
+
import { tmpdir } from 'node:os';
|
|
286
|
+
|
|
287
|
+
function diag(ctx, step, extra) {
|
|
288
|
+
try {
|
|
289
|
+
const base = String(ctx?.dataRoot || '') || tmpdir();
|
|
290
|
+
const line =
|
|
291
|
+
`[${new Date().toISOString()}] ${step}` + (extra ? ` | ${JSON.stringify(extra)}` : '') + '\n';
|
|
292
|
+
appendFileSync(join(base, '.qqbot-extensions', 'data', 'sticker-semantic-diag.log'), line, 'utf8');
|
|
293
|
+
} catch {
|
|
294
|
+
/* 诊断失败绝不影响出站 */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const name = 'sticker-semantic';
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* ★★ 主入口(2026-10-16 主人定的架构)★★
|
|
302
|
+
*
|
|
303
|
+
* **出站链和正文唯一的联系,就是"读一眼正文写了什么"。**
|
|
304
|
+
* 读到之后,选图 / 算分 / 发图 **全部丢到后台独立跑**(`ctx.background`),
|
|
305
|
+
* 这里**立刻** `next()` 放行 —— 所以正文的发送速度**永远不会**被配图影响,
|
|
306
|
+
* 图片是事后补发的(走 sendMedia 的"先被动后主动"独立通道)。
|
|
307
|
+
*
|
|
308
|
+
* 老宿主没有 `ctx.background` ⇒ 退回"同步跑完再放行"的旧行为(慢,但能用)。
|
|
309
|
+
*/
|
|
310
|
+
export default async function onOutbound(ctx, next) {
|
|
311
|
+
// ① 正文发送前(phase='send'):本示范不改写正文 ⇒ 直接放行。
|
|
312
|
+
// ⚠️ **绝不能在这里配图**:一段回复有"正文→工具调用→正文"时,每段都走一次这里,
|
|
313
|
+
// 第一段根本不是回合最后一块(主人 2026-10-16 实测抓到)。
|
|
314
|
+
if (ctx?.phase !== 'turnEnd') return next();
|
|
315
|
+
|
|
316
|
+
// ② 回合真正结束(phase='turnEnd'):ctx.text = turnBlocks.last = **权威的最后一块正文**,
|
|
317
|
+
// 而且正文早发完了 ⇒ 丢后台配图,立刻返回。
|
|
318
|
+
// ⚠️ 丢后台时 `next` 是 undefined,而 pickAndSend 里到处是早期 `return next()`
|
|
319
|
+
// ⇒ 必须传一个**安全的 next** 进去(实测踩过:`next is not a function`)。
|
|
320
|
+
const safeNext = typeof next === 'function' ? next : () => undefined;
|
|
321
|
+
const bg = ctx?.background;
|
|
322
|
+
if (typeof bg === 'function') {
|
|
323
|
+
bg(() => pickAndSend(ctx, safeNext));
|
|
324
|
+
return safeNext();
|
|
325
|
+
}
|
|
326
|
+
return pickAndSend(ctx, safeNext); // 老宿主兜底
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 真正的配图逻辑(有 ctx.background 时在后台独立跑) */
|
|
330
|
+
async function pickAndSend(ctx, next) {
|
|
331
|
+
try {
|
|
332
|
+
const probe = {
|
|
333
|
+
dataRoot: String(ctx?.dataRoot || ''),
|
|
334
|
+
isLastChunk: ctx?.isLastChunk,
|
|
335
|
+
kind: ctx?.kind,
|
|
336
|
+
turnId: ctx?.turnId,
|
|
337
|
+
textLen: String(ctx?.text || '').length,
|
|
338
|
+
segs: Array.isArray(ctx?.segments) ? ctx.segments.length : -1,
|
|
339
|
+
hasSender: !!ctx?.sender,
|
|
340
|
+
hasTarget: !!ctx?.target,
|
|
341
|
+
target: targetKeysOf(ctx), // ← 隔离排查用:本回合目标的两种写法
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ★★ 诊断分支:无条件发库里第一张(放在最前,连 isLastChunk 都不看)★★
|
|
345
|
+
if (CONFIG.FORCE) {
|
|
346
|
+
diag(ctx, 'force:enter', probe);
|
|
347
|
+
const all = loadItems(ctx.dataRoot);
|
|
348
|
+
const first = all[0];
|
|
349
|
+
const p = first ? fileOf(ctx.dataRoot, first) : null;
|
|
350
|
+
diag(ctx, 'force:probe', { items: all.length, firstId: first?.id, path: p });
|
|
351
|
+
if (p) {
|
|
352
|
+
const ok = await ctx.sendMedia({ path: p });
|
|
353
|
+
diag(ctx, ok ? 'force:sent' : 'force:send-failed', { path: p });
|
|
354
|
+
} else {
|
|
355
|
+
diag(ctx, 'force:no-file');
|
|
356
|
+
}
|
|
357
|
+
return next();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ① 只处理"本回合最后一个正文块" —— 否则每块都配图会刷屏
|
|
361
|
+
if (!ctx.isLastChunk) return next();
|
|
362
|
+
if (ctx.kind && ctx.kind !== 'text') return next();
|
|
363
|
+
|
|
364
|
+
// ② 开关关了就直通
|
|
365
|
+
const enabled = isEnabled(ctx);
|
|
366
|
+
if (!enabled) {
|
|
367
|
+
diag(ctx, 'skip:disabled', probe);
|
|
368
|
+
return next();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ②b ★【隔离设置】★ 2026-10-17 主人定:**只对点名的群/私聊生效**
|
|
372
|
+
// 白名单没这条 ⇒ 直接放行(不配图)。文件不存在 = 空名单 = 谁都不配图。
|
|
373
|
+
// 放在 FORCE 之后:FORCE 只是排障开关,默认 false,不用它做正式判定。
|
|
374
|
+
const allow = scopeAllows(ctx);
|
|
375
|
+
if (!allow.ok) {
|
|
376
|
+
diag(ctx, 'skip:scope', { ...probe, why: allow.why, keys: allow.keys });
|
|
377
|
+
return next();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ③ 正文太短 / 已有图(她自己带了 [MEDIA:image])→ 不掺和
|
|
381
|
+
const text = String(ctx.text || '').replace(/\[MEDIA:[^\]]*\]/g, ' ').trim();
|
|
382
|
+
if (text.length < CONFIG.MIN_TEXT) {
|
|
383
|
+
diag(ctx, 'skip:text-too-short', { ...probe, text });
|
|
384
|
+
return next();
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(ctx.segments) && ctx.segments.some((s) => s && s.type === 'media')) {
|
|
387
|
+
diag(ctx, 'skip:already-has-media', probe);
|
|
388
|
+
return next();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ④ 发送冷却(**时间窗**,见文件上方注释)—— 同一会话 COOLDOWN_MS 内最多 1 张
|
|
392
|
+
const sentKey = String(ctx.target?.targetId || ctx.target?.peerId || '') || 'unknown';
|
|
393
|
+
if (sentKey === lastSentKey && Date.now() - lastSentAt < CONFIG.COOLDOWN_MS) {
|
|
394
|
+
diag(ctx, 'skip:cooldown', { ...probe, sinceMs: Date.now() - lastSentAt });
|
|
395
|
+
return next();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ⑤ 【事故回退 2026-10-16】**池子必须限流**。
|
|
399
|
+
// 原改法想"全库扫 1892 张",但首次要对全部条目做向量编码(十几~几十秒)
|
|
400
|
+
// ⇒ 把整条出站链拖死 ⇒ **连正文都发不出去**(主人实测:QQ 里什么都没有)。
|
|
401
|
+
// 现在:池子限流(默认 300,与改造前一致),但**保留**"末句 query / msgText 分离 /
|
|
402
|
+
// 存在性闸门 / 时间窗冷却"这些改造。等做了**向量落盘缓存**再放开全库扫。
|
|
403
|
+
const items = loadItems(ctx.dataRoot);
|
|
404
|
+
if (items.length === 0) {
|
|
405
|
+
diag(ctx, 'skip:no-items', { ...probe, indexFile: join(String(ctx.dataRoot || ''), '表情包', 'index.json') });
|
|
406
|
+
return next();
|
|
407
|
+
}
|
|
408
|
+
// ⑤a query 不用整段:一段话里可能"寒暄+技术+情绪"三件事,向量会被平均掉。
|
|
409
|
+
// 取**末句**(中文按句末标点切),它通常承载最终态度;太短则回退整段。
|
|
410
|
+
const query = pickQuery(text);
|
|
411
|
+
|
|
412
|
+
// ══════════════════════════════════════════════════════════════════
|
|
413
|
+
// ⑤b 【主人方案 · 两阶段召回】2026-10-16 定
|
|
414
|
+
// 阶段1:**随机抽 100 张 → 收集它们的"关键词"(tags)** → 跟正文匹配一次,
|
|
415
|
+
// 只选出**最符合的那个关键词**。⇒ 模型/相似度计算量 = 关键词个数(几十个),极快。
|
|
416
|
+
// 阶段2:拿选中的关键词**回整个图库精确搜**(纯字符串 `tags.includes`,零模型开销),
|
|
417
|
+
// 从命中图里挑一张发出去。
|
|
418
|
+
// 为什么这么设计:既避开了"全库 1892 条向量编码"把出站链拖死的事故,
|
|
419
|
+
// 又能让"库里真有那张好图"被捞出来(阶段2 是全库搜索)。
|
|
420
|
+
// ══════════════════════════════════════════════════════════════════
|
|
421
|
+
const samplePool = items.length > CONFIG.POOL ? sample(items, CONFIG.POOL) : items;
|
|
422
|
+
|
|
423
|
+
// 收集这批样本的关键词(去重)
|
|
424
|
+
const kwSet = new Set();
|
|
425
|
+
for (const it of samplePool) {
|
|
426
|
+
if (Array.isArray(it.tags)) for (const t of it.tags) {
|
|
427
|
+
const k = String(t || '').trim();
|
|
428
|
+
if (k) kwSet.add(k);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const kws = [...kwSet];
|
|
432
|
+
if (kws.length === 0) {
|
|
433
|
+
diag(ctx, 'skip:no-keywords', { ...probe, sampled: samplePool.length });
|
|
434
|
+
return next();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 给这批**关键词**打分(只算几十~几百个词,快)
|
|
438
|
+
let kwScores = null;
|
|
439
|
+
let scorer = 'bigram';
|
|
440
|
+
try {
|
|
441
|
+
const ob = globalThis[Symbol.for('im-qqbot.outbound')];
|
|
442
|
+
if (ob && typeof ob.embed === 'function') {
|
|
443
|
+
kwScores = await ob.embed(query, kws);
|
|
444
|
+
if (Array.isArray(kwScores) && kwScores.length === kws.length) scorer = 'local-model';
|
|
445
|
+
else kwScores = null;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
kwScores = null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let bestKw = null;
|
|
452
|
+
let bestKwScore = 0;
|
|
453
|
+
const all = [];
|
|
454
|
+
for (let i = 0; i < kws.length; i++) {
|
|
455
|
+
const s = kwScores ? Number(kwScores[i]) || 0 : similarity(query, kws[i]);
|
|
456
|
+
all.push(s);
|
|
457
|
+
if (s > bestKwScore) {
|
|
458
|
+
bestKwScore = s;
|
|
459
|
+
bestKw = kws[i];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ⑤c **存在性闸门**(作用在"关键词分数"上):最高分要显著高于本批关键词的分布
|
|
464
|
+
const n = all.length || 1;
|
|
465
|
+
const mean = all.reduce((a, b) => a + b, 0) / n;
|
|
466
|
+
const std = Math.sqrt(all.reduce((a, b) => a + (b - mean) * (b - mean), 0) / n) || 0;
|
|
467
|
+
const gate = mean + CONFIG.GATE_K * std;
|
|
468
|
+
const floor = scorer === 'local-model' ? CONFIG.FLOOR_EMBED : CONFIG.FLOOR_BIGRAM;
|
|
469
|
+
diag(ctx, 'stage1', {
|
|
470
|
+
scorer,
|
|
471
|
+
sampled: samplePool.length,
|
|
472
|
+
kws: kws.length,
|
|
473
|
+
bestKw,
|
|
474
|
+
best: bestKwScore.toFixed(4),
|
|
475
|
+
mean: mean.toFixed(4),
|
|
476
|
+
std: std.toFixed(4),
|
|
477
|
+
gate: gate.toFixed(4),
|
|
478
|
+
floor,
|
|
479
|
+
});
|
|
480
|
+
if (!bestKw) {
|
|
481
|
+
diag(ctx, 'skip:no-best-kw', probe);
|
|
482
|
+
return next();
|
|
483
|
+
}
|
|
484
|
+
if (bestKwScore < floor) {
|
|
485
|
+
diag(ctx, 'skip:below-floor', { bestKw, best: bestKwScore.toFixed(4), floor, scorer });
|
|
486
|
+
ctx.note?.(`表情包:最贴的关键词「${bestKw}」分太低(${bestKwScore.toFixed(3)}),本回合不配图`);
|
|
487
|
+
return next();
|
|
488
|
+
}
|
|
489
|
+
if (bestKwScore < gate) {
|
|
490
|
+
diag(ctx, 'skip:not-salient', { bestKw, best: bestKwScore.toFixed(4), gate: gate.toFixed(4) });
|
|
491
|
+
ctx.note?.(`表情包:关键词「${bestKw}」不够突出(${bestKwScore.toFixed(3)} < ${gate.toFixed(3)}),本回合不配图`);
|
|
492
|
+
return next();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ⑥ 【阶段2】用选中的关键词**回全库精确搜**(纯字符串,零模型开销)
|
|
496
|
+
const hits = items.filter((it) => Array.isArray(it.tags) && it.tags.includes(bestKw));
|
|
497
|
+
if (hits.length === 0) {
|
|
498
|
+
diag(ctx, 'skip:no-hit-for-kw', { bestKw });
|
|
499
|
+
return next();
|
|
500
|
+
}
|
|
501
|
+
const best = hits[Math.floor(Math.random() * hits.length)];
|
|
502
|
+
diag(ctx, 'stage2', { kw: bestKw, hits: hits.length, picked: best?.id });
|
|
503
|
+
|
|
504
|
+
// ⑥ 发(先被动后主动);发失败也不影响正文
|
|
505
|
+
const path = fileOf(ctx.dataRoot, best);
|
|
506
|
+
if (!path) {
|
|
507
|
+
diag(ctx, 'skip:file-not-found', { ...probe, id: best?.id, hash: best?.hash, layer: best?.layer, ext: best?.ext });
|
|
508
|
+
return next();
|
|
509
|
+
}
|
|
510
|
+
const ok = await ctx.sendMedia({ path });
|
|
511
|
+
diag(ctx, ok ? 'sent' : 'send-failed', { ...probe, path, kw: bestKw, score: bestKwScore.toFixed(4), scorer });
|
|
512
|
+
if (ok) {
|
|
513
|
+
lastSentAt = Date.now();
|
|
514
|
+
lastSentKey = sentKey;
|
|
515
|
+
ctx.note?.(`表情包:按关键词「${bestKw}」发「${keywordsOf(best).slice(0, 40)}」(分 ${bestKwScore.toFixed(3)}, ${scorer})`);
|
|
516
|
+
}
|
|
517
|
+
} catch (err) {
|
|
518
|
+
// ★ 出站扩展永远不能把消息弄丢:出错就静默放行
|
|
519
|
+
diag(ctx, 'error', { msg: err instanceof Error ? err.message : String(err) });
|
|
520
|
+
ctx?.note?.(`表情包示范出错(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
521
|
+
}
|
|
522
|
+
// 走"丢后台"通道时 next 是 undefined(正文早就放行了)⇒ 安全调用
|
|
523
|
+
return typeof next === 'function' ? next() : undefined;
|
|
524
|
+
}
|