@zaofan/dsh-qqbot 1.6.6 → 1.6.7
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 +42 -0
- package/client/qqbot-settings.js +281 -9
- package/dist/api/group-admin.d.ts +26 -0
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +38 -0
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts +45 -0
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +77 -10
- package/dist/channel-tools.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/features/botplay-ext.d.ts +269 -0
- package/dist/features/botplay-ext.d.ts.map +1 -0
- package/dist/features/botplay-ext.js +213 -0
- package/dist/features/botplay-ext.js.map +1 -0
- package/dist/features/botplay.d.ts +152 -21
- package/dist/features/botplay.d.ts.map +1 -1
- package/dist/features/botplay.js +706 -21
- package/dist/features/botplay.js.map +1 -1
- package/dist/features/extension-store.d.ts +49 -0
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +90 -0
- package/dist/features/extension-store.js.map +1 -1
- package/dist/features/session-registry.d.ts +17 -0
- package/dist/features/session-registry.d.ts.map +1 -1
- package/dist/features/session-registry.js +24 -0
- package/dist/features/session-registry.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +15 -1
- package/dist/gateway/bootstrap.js.map +1 -1
- package/docs/USER-GUIDE.md +230 -27
- package/docs/botplay-contract.md +92 -0
- package/package.json +2 -1
- package/settings-host.js +4533 -4245
package/dist/features/botplay.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botplay.ts — botplay 互动事件装配器(2026-09-08, Phase1 MVP)
|
|
3
|
+
*
|
|
4
|
+
* 设计定稿见工作区 参考文档/botplay互动事件装配器_设计完整稿.md(唯一权威):
|
|
5
|
+
* 一句话本质 = 自定义 bot 互动事件: ①bot(非LLM)发出什么交互卡片 →
|
|
6
|
+
* ②用户点哪个按钮 → ③这件事要不要/怎么影响 LLM, 全部可配置。
|
|
7
|
+
*
|
|
8
|
+
* 本文件职责(参照 qq-approval.ts / qq-user-questions.ts 的注册表范式):
|
|
9
|
+
* - BotplayController: 发卡(trigger) + interaction 回调结算(handleInteraction)
|
|
10
|
+
* + append 三档(no_append / append_silent / append_wake)
|
|
11
|
+
* - 事件配置从 live config(config.botplayEvents, settings 同源热更)现读;
|
|
12
|
+
* dock「🎮 互动事件」装配器编辑后保存即热更, 无需重启。
|
|
13
|
+
* - /botplay 命令经 triggerBotplay() 模块级注册表触发(仿 outbound-mode-switch)。
|
|
14
|
+
*
|
|
15
|
+
* Phase1 MVP 范围: schema 最小集(id/name/buttons[label,botAction.reply_text,
|
|
16
|
+
* llmEffect.mode]/maxClicks/expireSec); perm 默认 all, triggerer 推荐;
|
|
17
|
+
* 行为类型 reply_text 落地, jump_url/callback 预留。
|
|
18
|
+
*/
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { statSync } from 'node:fs';
|
|
1
21
|
import { readGroupMembers, readLedger } from './chat-ledger.js';
|
|
22
|
+
import { botplayExtDir, makeExtContext, appendExtDiag, } from './botplay-ext.js';
|
|
23
|
+
import { loadBotplayExtensionModule, safeBotplayFileName } from './extension-store.js';
|
|
24
|
+
/** 拼路径(仅用于展示/取键; 不 resolve, 免得 Windows 大小写/短名差异把缓存键搞散) */
|
|
25
|
+
function joinSafe(dir, file) {
|
|
26
|
+
return dir ? join(dir, file) : file;
|
|
27
|
+
}
|
|
2
28
|
/** 按钮回调数据前缀 + 编码: bp:<cardId>:<buttonId>(button_data 是回调唯一凭证) */
|
|
3
29
|
const BTN_PREFIX = 'bp:';
|
|
4
30
|
/** 默认 append 记录文本模板(事件级 llmEffect.contextText 留空时用它) */
|
|
@@ -115,9 +141,10 @@ function catalogKeyboard(page, events) {
|
|
|
115
141
|
});
|
|
116
142
|
const rows = [];
|
|
117
143
|
// 事件按钮: 每行 CAT_COLS 个(最多4行, 第5行留给翻页导航 → 合计≤5行)
|
|
144
|
+
// 2026-10-05: 自定义事件(file)加个 🤖 前缀 —— 群里一眼看出哪些是 AI 写的模块驱动的
|
|
118
145
|
const maxRows = 4;
|
|
119
146
|
for (let i = 0; i < pageEvents.length && rows.length < maxRows; i += CAT_COLS) {
|
|
120
|
-
rows.push({ buttons: pageEvents.slice(i, i + CAT_COLS).map((ev) => btn(
|
|
147
|
+
rows.push({ buttons: pageEvents.slice(i, i + CAT_COLS).map((ev) => btn(`${ev.file ? '🤖' : '🎮'}${ev.name}`, ev.id, 1)) });
|
|
121
148
|
}
|
|
122
149
|
// 翻页行: 上一页 + 页码 + 下一页
|
|
123
150
|
const navBtns = [];
|
|
@@ -136,6 +163,7 @@ export class BotplayController {
|
|
|
136
163
|
eventsGetter;
|
|
137
164
|
ownersGetter;
|
|
138
165
|
ledgerDataDirGetter;
|
|
166
|
+
dataRootGetter;
|
|
139
167
|
cards = new Map();
|
|
140
168
|
constructor(manager, sender, logger,
|
|
141
169
|
/** live 事件配置 getter(config.botplayEvents, 每次现读支持热更) */
|
|
@@ -143,19 +171,240 @@ export class BotplayController {
|
|
|
143
171
|
/** 主人 openid 白名单 getter(config.groupAdmin.owners; perm=owner 用) */
|
|
144
172
|
ownersGetter,
|
|
145
173
|
/** 台账 dataDir getter(表情包目录; 点击人昵称反查, 与 dock 禁言面板同源) */
|
|
146
|
-
ledgerDataDirGetter
|
|
174
|
+
ledgerDataDirGetter,
|
|
175
|
+
/** 数据根 getter(2026-10-05: 自定义事件模块放 {dataRoot}/.qqbot-extensions/botplay/) */
|
|
176
|
+
dataRootGetter = () => '') {
|
|
147
177
|
this.manager = manager;
|
|
148
178
|
this.sender = sender;
|
|
149
179
|
this.logger = logger;
|
|
150
180
|
this.eventsGetter = eventsGetter;
|
|
151
181
|
this.ownersGetter = ownersGetter;
|
|
152
182
|
this.ledgerDataDirGetter = ledgerDataDirGetter;
|
|
183
|
+
this.dataRootGetter = dataRootGetter;
|
|
153
184
|
}
|
|
154
185
|
/** 指令型按钮执行器(bootstrap 注入: 执行斜杠命令并返回文本) */
|
|
155
186
|
commandExecutor;
|
|
156
187
|
setCommandExecutor(fn) {
|
|
157
188
|
this.commandExecutor = fn;
|
|
158
189
|
}
|
|
190
|
+
// ── 昵称兜底(2026-10-05)─────────────────────────────────────────────
|
|
191
|
+
/** API 兜底查到的昵称缓存:`${peerId}|${openid}` → name */
|
|
192
|
+
memberNameApiCache = new Map();
|
|
193
|
+
/** 正在后台查的 key(防同一人并发重复查) */
|
|
194
|
+
memberNameProbing = new Set();
|
|
195
|
+
/** 官方 group-admin 客户端 getter(bootstrap 注入;没注入就跳过兜底) */
|
|
196
|
+
groupAdminGetter;
|
|
197
|
+
setGroupAdminGetter(fn) {
|
|
198
|
+
this.groupAdminGetter = fn;
|
|
199
|
+
}
|
|
200
|
+
/** bot 凭证 getter(卡片 = 代码,直接把 appId/appSecret 给它,让它自己调官方 API) */
|
|
201
|
+
credentialsGetter;
|
|
202
|
+
setCredentialsGetter(fn) {
|
|
203
|
+
this.credentialsGetter = fn;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 后台探测某人昵称(不阻塞调用方)。
|
|
207
|
+
* 命中 → 写进 memberNameApiCache,下次同步调用就能拿到真名。
|
|
208
|
+
* 未命中/无权限/网络错 → 静默(只记一行日志,避免刷屏)。
|
|
209
|
+
*/
|
|
210
|
+
probeMemberNameAsync(target, presser) {
|
|
211
|
+
if (target.scope !== 'group' || !presser)
|
|
212
|
+
return;
|
|
213
|
+
const key = `${target.targetId}|${presser}`;
|
|
214
|
+
if (this.memberNameProbing.has(key))
|
|
215
|
+
return;
|
|
216
|
+
const ga = (() => { try {
|
|
217
|
+
return this.groupAdminGetter?.();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
} })();
|
|
222
|
+
if (!ga || typeof ga.getMemberInfo !== 'function')
|
|
223
|
+
return;
|
|
224
|
+
this.memberNameProbing.add(key);
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const r = await ga.getMemberInfo(target.targetId, presser);
|
|
228
|
+
if (r && r.ok && r.data && r.data.username) {
|
|
229
|
+
const nm = String(r.data.username);
|
|
230
|
+
this.memberNameApiCache.set(key, nm);
|
|
231
|
+
if (this.memberNameApiCache.size > 1000) {
|
|
232
|
+
// 简易上限:满了清一半(昵称表不值得为它做 LRU)
|
|
233
|
+
let n = 0;
|
|
234
|
+
for (const k of this.memberNameApiCache.keys()) {
|
|
235
|
+
this.memberNameApiCache.delete(k);
|
|
236
|
+
if (++n >= 500)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
this.logger.info?.(`[botplay] 昵称兜底命中 ${presser} → ${nm}`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.logger.info?.(`[botplay] 昵称兜底未命中(code=${(r && r.err && r.err.code) || '?'}) ${presser}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
this.logger.info?.(`[botplay] 昵称兜底异常: ${err instanceof Error ? err.message : String(err)}`);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.memberNameProbing.delete(key);
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
253
|
+
}
|
|
254
|
+
// ── 自定义事件模块(2026-10-05) ─────────────────────────────────────────
|
|
255
|
+
/** 已加载模块缓存: 绝对路径 → 加载结果(按 mtime 判热重载; 不按时间戳刷 URL, 见 fileVersion 注释) */
|
|
256
|
+
extModules = new Map();
|
|
257
|
+
/** 每实例每按钮点击计数: `${cardId}|${buttonId}` → 次数(自定义事件的"是否点过"判据) */
|
|
258
|
+
extClicks = new Map();
|
|
259
|
+
/** 加载失败原因: 文件名 → 错误(面板展示红色摘要) */
|
|
260
|
+
extErrors = new Map();
|
|
261
|
+
/** 面板用: 每个自定义事件模块的状态(路径/是否存在/mtime/加载错误) —— settings-host 经此展示 */
|
|
262
|
+
extStatus() {
|
|
263
|
+
const dataRoot = (() => { try {
|
|
264
|
+
return this.dataRootGetter() || '';
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return '';
|
|
268
|
+
} })();
|
|
269
|
+
const dir = dataRoot ? botplayExtDir(dataRoot) : '';
|
|
270
|
+
const out = [];
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
const push = (file) => {
|
|
273
|
+
if (!file || seen.has(file))
|
|
274
|
+
return;
|
|
275
|
+
seen.add(file);
|
|
276
|
+
const safe = safeBotplayFileName(file) ?? file;
|
|
277
|
+
let exists = false;
|
|
278
|
+
let mtime = 0;
|
|
279
|
+
try {
|
|
280
|
+
// 现读文件系统(面板要"文件是否存在/最后修改时间"); 不用 statSync 缓存, 主人可能随时改文件
|
|
281
|
+
const st = this.statFile(dir, safe);
|
|
282
|
+
exists = st.exists;
|
|
283
|
+
mtime = st.mtime;
|
|
284
|
+
}
|
|
285
|
+
catch { /* 读不到就报不存在 */ }
|
|
286
|
+
const cached = this.extModules.get(joinSafe(dir, safe));
|
|
287
|
+
out.push({
|
|
288
|
+
file: safe,
|
|
289
|
+
path: joinSafe(dir, safe),
|
|
290
|
+
exists,
|
|
291
|
+
mtime,
|
|
292
|
+
loadedMtime: cached?.mtime ?? 0,
|
|
293
|
+
name: cached?.name ?? '',
|
|
294
|
+
error: this.extErrors.get(safe) ?? '',
|
|
295
|
+
hooks: cached ? Object.keys(cached.mod).filter((k) => typeof cached.mod[k] === 'function') : [],
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
for (const ev of this.listEvents())
|
|
299
|
+
if (ev?.file)
|
|
300
|
+
push(String(ev.file));
|
|
301
|
+
// 目录里存在但事件没登记的模块也列出来(排查"写好了没生效"最常问的一句)
|
|
302
|
+
for (const [, l] of this.extModules)
|
|
303
|
+
push(l.file);
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
/** 诊断日志(自定义事件加载/点击/报错都落这个文件; 面板可读) */
|
|
307
|
+
extDiag(line) {
|
|
308
|
+
const dataRoot = (() => { try {
|
|
309
|
+
return this.dataRootGetter() || '';
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return '';
|
|
313
|
+
} })();
|
|
314
|
+
if (!dataRoot)
|
|
315
|
+
return;
|
|
316
|
+
appendExtDiag(dataRoot, line);
|
|
317
|
+
}
|
|
318
|
+
statFile(dir, file) {
|
|
319
|
+
try {
|
|
320
|
+
const st = statSync(joinSafe(dir, file));
|
|
321
|
+
return { exists: true, mtime: Math.floor(st.mtimeMs) };
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return { exists: false, mtime: 0 };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** 事件是否由自定义模块驱动(带 file 且文件名合法) */
|
|
328
|
+
extFileOf(ev) {
|
|
329
|
+
const f = String(ev?.file ?? '').trim();
|
|
330
|
+
if (!f)
|
|
331
|
+
return undefined;
|
|
332
|
+
return safeBotplayFileName(f) ?? undefined;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 取(或热重载)模块: mtime 没变就复用缓存实例(模块里的内存状态得以跨卡持有),
|
|
336
|
+
* 变了就重新 import(`?v=<mtime>`)。加载失败**不抛**, 返回 {error}。
|
|
337
|
+
*/
|
|
338
|
+
async loadExt(file) {
|
|
339
|
+
const dataRoot = (() => { try {
|
|
340
|
+
return this.dataRootGetter() || '';
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return '';
|
|
344
|
+
} })();
|
|
345
|
+
if (!dataRoot)
|
|
346
|
+
return { error: '数据根未就绪(dataRoot 为空, 自定义事件模块无法定位)' };
|
|
347
|
+
const safe = safeBotplayFileName(file);
|
|
348
|
+
if (!safe)
|
|
349
|
+
return { error: `文件名不安全: ${file}(只允许纯文件名 + .mjs/.js/.cjs)` };
|
|
350
|
+
const key = joinSafe(botplayExtDir(dataRoot), safe);
|
|
351
|
+
// 先看 mtime: 文件没改 → 直接复用(避免每次点按钮都重新 import)
|
|
352
|
+
let mtime = 0;
|
|
353
|
+
try {
|
|
354
|
+
mtime = Math.floor(statSync(key).mtimeMs);
|
|
355
|
+
}
|
|
356
|
+
catch { /* 不存在, 交给下面的加载器报错 */ }
|
|
357
|
+
const cached = this.extModules.get(key);
|
|
358
|
+
if (cached && mtime > 0 && cached.mtime === mtime)
|
|
359
|
+
return { loaded: cached };
|
|
360
|
+
const r = await loadBotplayExtensionModule(dataRoot, safe, this.logger);
|
|
361
|
+
if (!r.ok) {
|
|
362
|
+
this.extErrors.set(safe, r.error);
|
|
363
|
+
this.extDiag(`加载失败 ${safe}: ${r.error}`);
|
|
364
|
+
return { error: r.error };
|
|
365
|
+
}
|
|
366
|
+
this.extErrors.delete(safe);
|
|
367
|
+
// 类型说明: extension-store 的加载器用 generic `BotplayModuleShape`(避免两模块互相 import),
|
|
368
|
+
// 这里断言成真正的契约类型 —— 运行期是同一个对象, 只是编译期形状标注不同。
|
|
369
|
+
const loaded = r.loaded;
|
|
370
|
+
this.extModules.set(key, loaded);
|
|
371
|
+
this.extDiag(`已加载模块 ${safe} mtime=${loaded.mtime} hooks=${Object.keys(loaded.mod).filter((k) => typeof loaded.mod[k] === 'function').join(',')}`);
|
|
372
|
+
return { loaded };
|
|
373
|
+
}
|
|
374
|
+
/** 热重载指定模块(文件名; 空=全部): 清缓存 → 下次解析卡片自动加载新版本 */
|
|
375
|
+
reloadExt(file) {
|
|
376
|
+
const target = String(file ?? '').trim();
|
|
377
|
+
if (!target) {
|
|
378
|
+
const n = this.extModules.size;
|
|
379
|
+
this.extModules.clear();
|
|
380
|
+
this.extErrors.clear();
|
|
381
|
+
this.extDiag('热重载全部模块(缓存已清)');
|
|
382
|
+
return { ok: true, msg: `已清空 ${n} 个模块缓存, 下次点击/发卡时重新加载` };
|
|
383
|
+
}
|
|
384
|
+
const safe = safeBotplayFileName(target);
|
|
385
|
+
if (!safe)
|
|
386
|
+
return { ok: false, msg: `文件名不安全: ${target}` };
|
|
387
|
+
let removed = 0;
|
|
388
|
+
for (const [k, v] of this.extModules) {
|
|
389
|
+
if (v.file === safe) {
|
|
390
|
+
this.extModules.delete(k);
|
|
391
|
+
removed += 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
this.extErrors.delete(safe);
|
|
395
|
+
this.extDiag(`热重载模块 ${safe}(清缓存 ${removed} 个)`);
|
|
396
|
+
return { ok: true, msg: `已热重载 ${safe}(下次发卡/点击生效)` };
|
|
397
|
+
}
|
|
398
|
+
/** 列表(面板/命令共用): 带"是否自定义事件"标记 */
|
|
399
|
+
listExtFiles() {
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const ev of this.listEvents()) {
|
|
402
|
+
const f = this.extFileOf(ev);
|
|
403
|
+
if (f && !out.includes(f))
|
|
404
|
+
out.push(f);
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
159
408
|
/** 列出所有事件(公开, dock/命令共用) */
|
|
160
409
|
listEvents() {
|
|
161
410
|
return Array.isArray(this.eventsGetter()) ? this.eventsGetter() : [];
|
|
@@ -167,12 +416,16 @@ export class BotplayController {
|
|
|
167
416
|
/**
|
|
168
417
|
* 触发发卡(/botplay 事件名)。定位会话用命令上下文所在 scope/peerId,
|
|
169
418
|
* 归属人 = 触发者本人 openid(group=c2c 同 senderId)。
|
|
419
|
+
*
|
|
420
|
+
* 2026-10-05 加: 事件带 `file`(自定义事件模块)时, 按钮/正文先问模块要(见 resolveExtCard),
|
|
421
|
+
* 模块挂了也不挡发卡(回落到事件自带的按钮配置)。
|
|
170
422
|
*/
|
|
171
423
|
async trigger(target, eventId, triggererId) {
|
|
172
424
|
const ev = this.findEvent(eventId);
|
|
173
425
|
if (!ev)
|
|
174
426
|
return { ok: false, msg: `事件不存在: ${eventId}(发 /botplay 查看列表)` };
|
|
175
|
-
|
|
427
|
+
const extFile = this.extFileOf(ev);
|
|
428
|
+
if (!extFile && (!Array.isArray(ev.buttons) || ev.buttons.length === 0)) {
|
|
176
429
|
return { ok: false, msg: `事件「${ev.name}」没有配置按钮` };
|
|
177
430
|
}
|
|
178
431
|
const expireSec = Math.max(30, Number(ev.expireSec ?? 600) || 600);
|
|
@@ -180,19 +433,48 @@ export class BotplayController {
|
|
|
180
433
|
const cardId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
181
434
|
const perm = ev.perm ?? { type: 'all' };
|
|
182
435
|
const ownerIds = this.ownersGetter();
|
|
436
|
+
const perRow = Number(ev.buttonsPerRow ?? 1) || 1;
|
|
437
|
+
// 自定义事件: 问模块拿按钮/正文(onInit 也在这条路上跑一次)
|
|
438
|
+
let extState;
|
|
439
|
+
let buttons = Array.isArray(ev.buttons) ? ev.buttons : [];
|
|
440
|
+
let cardContent;
|
|
441
|
+
let extError;
|
|
442
|
+
if (extFile) {
|
|
443
|
+
const r = await this.resolveExtCard(ev, extFile, cardId, target, triggererId);
|
|
444
|
+
extState = r.state;
|
|
445
|
+
buttons = r.state.buttons;
|
|
446
|
+
cardContent = r.state.contentText;
|
|
447
|
+
extError = r.error;
|
|
448
|
+
if (r.error)
|
|
449
|
+
this.extDiag(`发卡期模块异常 event=${ev.id} file=${extFile}: ${r.error}`);
|
|
450
|
+
}
|
|
451
|
+
if (buttons.length === 0) {
|
|
452
|
+
this.cards.delete(cardId);
|
|
453
|
+
// 自定义事件没按钮 = 模块没起来(加载失败/没给按钮) 或事件没配 buttons。
|
|
454
|
+
// ⚠️ 这里必须报出**模块的错误摘要**, 否则主人只会看到"没有可用按钮"一头雾水
|
|
455
|
+
// (2026-10-05 实测: 只报"没有可用按钮"时, 完全看不出是模块文件丢了)。
|
|
456
|
+
if (extFile && extError) {
|
|
457
|
+
return { ok: false, msg: `事件「${ev.name}」的模块没跑起来 → ${extError}` };
|
|
458
|
+
}
|
|
459
|
+
return { ok: false, msg: `事件「${ev.name}」没有可用按钮${extFile ? '(模块没给出按钮, 且事件里也没配 buttons)' : ''}` };
|
|
460
|
+
}
|
|
183
461
|
this.cards.set(cardId, {
|
|
184
462
|
cardId,
|
|
185
463
|
eventId: ev.id,
|
|
186
464
|
eventName: ev.name,
|
|
187
|
-
buttons: JSON.parse(JSON.stringify(
|
|
465
|
+
buttons: JSON.parse(JSON.stringify(buttons)),
|
|
188
466
|
perm: JSON.parse(JSON.stringify(perm)),
|
|
189
467
|
triggererId: perm.type === 'triggerer' ? triggererId : undefined,
|
|
190
468
|
remainClicks: maxClicks > 0 ? maxClicks : 0,
|
|
191
469
|
expireAt: Date.now() + expireSec * 1000,
|
|
470
|
+
target,
|
|
471
|
+
buttonsPerRow: perRow,
|
|
472
|
+
extFile,
|
|
473
|
+
extState,
|
|
192
474
|
});
|
|
193
|
-
const kb = botplayKeyboard(cardId,
|
|
194
|
-
// 卡片正文:
|
|
195
|
-
const tpl = (ev.contentText
|
|
475
|
+
const kb = botplayKeyboard(cardId, buttons, perm, ownerIds, triggererId, perRow);
|
|
476
|
+
// 卡片正文: 自定义模块给的(若模块没给就走事件模板) > 事件自定义模板 > 默认模板
|
|
477
|
+
const tpl = String(cardContent ?? ev.contentText ?? '').trim();
|
|
196
478
|
const prompt = tpl
|
|
197
479
|
? tpl.replace(/\{name\}/g, ev.name)
|
|
198
480
|
: [
|
|
@@ -204,7 +486,7 @@ export class BotplayController {
|
|
|
204
486
|
].join('\n');
|
|
205
487
|
try {
|
|
206
488
|
await this.sender.sendMarkdownWithKeyboard(target, prompt, kb);
|
|
207
|
-
this.logger.info(`[botplay] 发卡 ok event=${ev.id} card=${cardId} target=${target.scope}:${target.targetId} perm=${perm.type}`);
|
|
489
|
+
this.logger.info(`[botplay] 发卡 ok event=${ev.id} card=${cardId} target=${target.scope}:${target.targetId} perm=${perm.type}${extFile ? ` ext=${extFile}` : ''}`);
|
|
208
490
|
return { ok: true, msg: `已发出「${ev.name}」互动卡片 🎮` };
|
|
209
491
|
}
|
|
210
492
|
catch (err) {
|
|
@@ -213,6 +495,233 @@ export class BotplayController {
|
|
|
213
495
|
return { ok: false, msg: `发卡失败: ${err instanceof Error ? err.message : String(err)}(可能未开通卡片权限)` };
|
|
214
496
|
}
|
|
215
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* 解析自定义事件卡片: 加载模块 → 跑 onInit → 收下模块给的按钮/正文。
|
|
500
|
+
*
|
|
501
|
+
* 为什么整条 fail-soft 到底: 模块是主人(或 AI)手写的代码, 语法错了/钩子抛错都不该
|
|
502
|
+
* 让"发个卡片"这种小事炸掉, 更不该影响其它事件与 QQ 正常聊天 —— 一律记诊断 + 回落。
|
|
503
|
+
* 返回 state.buttons 一定非空(模块没给就用事件自带按钮)。
|
|
504
|
+
*/
|
|
505
|
+
async resolveExtCard(ev, extFile, cardId, target, triggererId) {
|
|
506
|
+
const base = Array.isArray(ev.buttons) ? ev.buttons : [];
|
|
507
|
+
const state = {
|
|
508
|
+
contentText: String(ev.contentText ?? ''),
|
|
509
|
+
buttons: JSON.parse(JSON.stringify(base)),
|
|
510
|
+
buttonsPerRow: Number(ev.buttonsPerRow ?? 1) || 1,
|
|
511
|
+
dirty: false,
|
|
512
|
+
};
|
|
513
|
+
const { loaded, error } = await this.loadExt(extFile);
|
|
514
|
+
if (!loaded) {
|
|
515
|
+
state.error = error;
|
|
516
|
+
this.logger.warn?.(`[botplay] 自定义事件 ${ev.id} 模块加载失败: ${error}`);
|
|
517
|
+
return { state, error };
|
|
518
|
+
}
|
|
519
|
+
state.filePath = loaded.path;
|
|
520
|
+
const moduleForCard = loaded.mod;
|
|
521
|
+
const ctx = this.buildExtCtx({ ev, extFile, cardId, target, state, moduleForCard, triggererId });
|
|
522
|
+
if (typeof moduleForCard.onInit === 'function') {
|
|
523
|
+
try {
|
|
524
|
+
await moduleForCard.onInit(ctx);
|
|
525
|
+
// onInit 里可能已改过按钮(label/顺序) → 立刻收下, 别等点击
|
|
526
|
+
if (Array.isArray(state.buttons) && state.buttons.length > 0) {
|
|
527
|
+
state.buttons = this.sanitizeExtButtons(state.buttons);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
532
|
+
state.error = `onInit 抛错: ${msg}`;
|
|
533
|
+
this.extDiag(`onInit 抛错 ${extFile}: ${msg}`);
|
|
534
|
+
this.logger.warn?.(`[botplay] ${extFile} onInit 抛错(已忽略): ${msg}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!state.buttons || state.buttons.length === 0)
|
|
538
|
+
state.buttons = JSON.parse(JSON.stringify(base));
|
|
539
|
+
state.dirty = false; // onInit 期不算"脏"(那是初始状态, 不是点击后的变化)
|
|
540
|
+
return { state };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* 构造自定义模块的 ctx(每个卡片实例一个)。
|
|
544
|
+
* 说明: ctx.emit/markdown/image 都发往**该实例卡片所在的会话**(target), 不是点击人私聊 ——
|
|
545
|
+
* 群里的签到统计就该发群里; 要私聊给某人可以 ctx.markdown 之外自己判断(本期不做)。
|
|
546
|
+
*/
|
|
547
|
+
buildExtCtx(args) {
|
|
548
|
+
const { ev, extFile, cardId, target, state, clickerId } = args;
|
|
549
|
+
const dataRoot = (() => { try {
|
|
550
|
+
return this.dataRootGetter() || '';
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return '';
|
|
554
|
+
} })();
|
|
555
|
+
return makeExtContext({
|
|
556
|
+
dataRoot,
|
|
557
|
+
logger: this.logger,
|
|
558
|
+
owners: this.ownersGetter(),
|
|
559
|
+
// 卡片 = 代码:直接把 bot 凭证给它,让它自己换 token 调官方 API(不经宿主/插件包能力)
|
|
560
|
+
credentialsGetter: () => {
|
|
561
|
+
try {
|
|
562
|
+
return this.credentialsGetter?.() || { appId: '', appSecret: '' };
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return { appId: '', appSecret: '' };
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
cardState: state,
|
|
569
|
+
emit: async (text) => {
|
|
570
|
+
try {
|
|
571
|
+
await this.sender.sendMarkdown(target, text);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
this.logger.warn?.(`[botplay] ${extFile} emit 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
markdown: async (content) => {
|
|
580
|
+
try {
|
|
581
|
+
await this.sender.sendMarkdown(target, content);
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
this.logger.warn?.(`[botplay] ${extFile} markdown 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
image: async (source) => {
|
|
590
|
+
try {
|
|
591
|
+
await this.sender.sendMedia(target, 'image', source);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
this.logger.warn?.(`[botplay] ${extFile} image 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
memberName: (openid) => this.memberName(target, openid),
|
|
600
|
+
clickCount: (buttonId) => {
|
|
601
|
+
if (buttonId)
|
|
602
|
+
return this.extClicks.get(`${cardId}|${buttonId}`) ?? 0;
|
|
603
|
+
let n = 0;
|
|
604
|
+
for (const [k, v] of this.extClicks)
|
|
605
|
+
if (k.startsWith(`${cardId}|`))
|
|
606
|
+
n += v;
|
|
607
|
+
return n;
|
|
608
|
+
},
|
|
609
|
+
reloadSelf: async () => this.reloadExt(extFile),
|
|
610
|
+
diag: (line) => this.extDiag(line),
|
|
611
|
+
/**
|
|
612
|
+
* 带 token 的官方 API 调用:转调 GroupAdminClient.apiCall。
|
|
613
|
+
* 模块自己 fetch 拿不到 access_token(没有 appSecret),这就是本能力的价值所在。
|
|
614
|
+
*/
|
|
615
|
+
onApiCall: async (method, path, body) => {
|
|
616
|
+
const ga = (() => { try {
|
|
617
|
+
return this.groupAdminGetter?.();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return undefined;
|
|
621
|
+
} })();
|
|
622
|
+
if (!ga || typeof ga.apiCall !== 'function') {
|
|
623
|
+
return { ok: false, err: { code: 'NO_CLIENT', human: '群管理客户端未就绪(检查 groupAdmin.enabled)' } };
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
return await ga.apiCall(method, path, body);
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
return { ok: false, err: { code: 'THROW', human: err instanceof Error ? err.message : String(err) } };
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
/**
|
|
633
|
+
* 影响 LLM 上下文:转调框架自己的 applyEffect(与事件级 llmEffect 同一套两档)。
|
|
634
|
+
* append_silent = 只落上下文不唤醒;append_wake = 落上下文并唤醒一轮 AI。
|
|
635
|
+
*/
|
|
636
|
+
onLlmAppend: async (mode, text) => {
|
|
637
|
+
try {
|
|
638
|
+
const presser = String(args.clickerId || args.triggererId || '');
|
|
639
|
+
await this.applyEffect(mode, target, text, presser);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
this.logger.warn?.(`[botplay] ${extFile} append 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
}, {
|
|
648
|
+
id: ev.id,
|
|
649
|
+
name: ev.name,
|
|
650
|
+
file: extFile,
|
|
651
|
+
maxClicks: ev.maxClicks,
|
|
652
|
+
expireSec: ev.expireSec,
|
|
653
|
+
scope: target.scope === 'group' ? 'group' : 'c2c',
|
|
654
|
+
peerId: target.targetId,
|
|
655
|
+
}, cardId, clickerId ? { openid: clickerId, clickedBefore: args.clickedBefore ?? 0 } : undefined);
|
|
656
|
+
}
|
|
657
|
+
/** 把模块给的按钮洗成合法形状(缺 id/label 的丢掉; 防止脏数据把 keyboard 生成搞炸) */
|
|
658
|
+
sanitizeExtButtons(buttons) {
|
|
659
|
+
const out = [];
|
|
660
|
+
for (const raw of buttons) {
|
|
661
|
+
const b = raw;
|
|
662
|
+
const id = String(b?.id ?? '').trim();
|
|
663
|
+
const label = String(b?.label ?? '').trim();
|
|
664
|
+
if (!id || !label)
|
|
665
|
+
continue;
|
|
666
|
+
const actType = String(b?.botAction?.type ?? 'reply_text');
|
|
667
|
+
out.push({
|
|
668
|
+
id,
|
|
669
|
+
label: label.slice(0, 20),
|
|
670
|
+
visitedLabel: String(b?.visitedLabel ?? '').slice(0, 20),
|
|
671
|
+
style: Number(b?.style ?? 1) === 0 ? 0 : 1,
|
|
672
|
+
botAction: {
|
|
673
|
+
// 只放行 reply_text/jump_url(自定义事件的按钮动作实际由模块 onClick 决定,
|
|
674
|
+
// 这两个字段只是键盘渲染需要; command/callback 由模块自己用 ctx 表达)
|
|
675
|
+
type: actType === 'jump_url' ? 'jump_url' : 'reply_text',
|
|
676
|
+
text: String(b?.botAction?.text ?? ''),
|
|
677
|
+
url: String(b?.botAction?.url ?? ''),
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return out.slice(0, 25); // QQ 键盘上限 5行×5列
|
|
682
|
+
}
|
|
683
|
+
/** 点击后按钮变了 ⇒ 重发卡片刷新(QQ 没有现成的"改卡片"接口, 重发是可靠做法) */
|
|
684
|
+
async refreshCard(card, content) {
|
|
685
|
+
try {
|
|
686
|
+
const kb = botplayKeyboard(card.cardId, card.buttons, card.perm, this.ownersGetter(), card.triggererId, card.buttonsPerRow);
|
|
687
|
+
const body = content.trim() || `## 🎮 ${card.eventName}`;
|
|
688
|
+
await this.sender.sendMarkdownWithKeyboard(card.target, body.replace(/\{name\}/g, card.eventName), kb);
|
|
689
|
+
this.logger.info(`[botplay] 卡片刷新 card=${card.cardId} event=${card.eventId}`);
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
this.logger.warn?.(`[botplay] 卡片刷新失败 card=${card.cardId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** 清理一个卡片实例的扩展资源(点击计数/模块 onDispose) */
|
|
696
|
+
async disposeCard(card) {
|
|
697
|
+
for (const k of [...this.extClicks.keys()])
|
|
698
|
+
if (k.startsWith(`${card.cardId}|`))
|
|
699
|
+
this.extClicks.delete(k);
|
|
700
|
+
if (!card.extFile)
|
|
701
|
+
return;
|
|
702
|
+
try {
|
|
703
|
+
const { loaded } = await this.loadExt(card.extFile);
|
|
704
|
+
if (!loaded)
|
|
705
|
+
return;
|
|
706
|
+
const mod = loaded.mod;
|
|
707
|
+
if (typeof mod.onDispose === 'function') {
|
|
708
|
+
const fallbackEv = { id: card.eventId, name: card.eventName, buttons: [] };
|
|
709
|
+
const ctx = this.buildExtCtx({
|
|
710
|
+
ev: this.findEvent(card.eventId) ?? fallbackEv,
|
|
711
|
+
extFile: card.extFile,
|
|
712
|
+
cardId: card.cardId,
|
|
713
|
+
target: card.target,
|
|
714
|
+
state: card.extState ?? { contentText: '', buttons: [], dirty: false },
|
|
715
|
+
moduleForCard: mod,
|
|
716
|
+
triggererId: card.triggererId ?? '',
|
|
717
|
+
});
|
|
718
|
+
await mod.onDispose(ctx);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
this.logger.warn?.(`[botplay] ${card.extFile} onDispose 抛错(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
216
725
|
/** 发送事件目录卡(/botplay 无参 或 目录卡翻页; 点事件名按钮 → handleInteraction 直接触发) */
|
|
217
726
|
async sendCatalog(target, page = 0) {
|
|
218
727
|
const events = this.listEvents();
|
|
@@ -277,9 +786,10 @@ export class BotplayController {
|
|
|
277
786
|
await this.safeReply(replyTarget, '这张互动卡片已失效, 请重新发 /botplay 触发~');
|
|
278
787
|
return true;
|
|
279
788
|
}
|
|
280
|
-
// 过期校验
|
|
789
|
+
// 过期校验(2026-10-05: 自定义事件也走一次 onExpire, 让模块知道卡片死了)
|
|
281
790
|
if (Date.now() > card.expireAt) {
|
|
282
791
|
this.cards.delete(parsed.cardId);
|
|
792
|
+
await this.fireExtExpire(card);
|
|
283
793
|
await this.safeReply(replyTarget, '这张互动卡片已超时失效, 请重新发 /botplay~');
|
|
284
794
|
return true;
|
|
285
795
|
}
|
|
@@ -311,6 +821,25 @@ export class BotplayController {
|
|
|
311
821
|
}
|
|
312
822
|
}
|
|
313
823
|
// ① bot(非LLM)行为(按快照)
|
|
824
|
+
// ── 自定义事件(2026-10-05): 走模块的 onClick, 完全不碰下面的 botAction 分支 ──
|
|
825
|
+
if (card.extFile && card.extState) {
|
|
826
|
+
await this.runExtClick(card, btn, presser, replyTarget);
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
await this.runBuiltinAction(btn, replyTarget);
|
|
830
|
+
}
|
|
831
|
+
// ② LLM 影响三档(按快照; 注入文本带点击人身份: 昵称(openid), 台账/会话历史反查)
|
|
832
|
+
// 自定义事件的 llmEffect 仍按事件快照生效(=模块写了 llmEffect 就照常影响 AI)。
|
|
833
|
+
const mode = btn.llmEffect?.mode ?? 'no_append';
|
|
834
|
+
if (mode !== 'no_append') {
|
|
835
|
+
const clicker = this.clickerLabel(replyTarget, presser);
|
|
836
|
+
const text = renderContext(btn.llmEffect?.contextText, card.eventName, btn.label ?? '', clicker);
|
|
837
|
+
await this.applyEffect(mode, replyTarget, text, presser);
|
|
838
|
+
}
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
/** 内置三型按钮行为(reply_text / jump_url / command; 普通事件用) */
|
|
842
|
+
async runBuiltinAction(btn, replyTarget) {
|
|
314
843
|
const actionType = btn.botAction?.type ?? 'reply_text';
|
|
315
844
|
if (actionType === 'reply_text') {
|
|
316
845
|
const text = String(btn.botAction?.text ?? '').trim();
|
|
@@ -349,14 +878,75 @@ export class BotplayController {
|
|
|
349
878
|
}
|
|
350
879
|
}
|
|
351
880
|
// callback=仅结算, 无 bot 回复
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* 跑自定义模块的 onClick(2026-10-05)。
|
|
884
|
+
*
|
|
885
|
+
* 顺序(为什么这么排):
|
|
886
|
+
* ① 计数先 ++, 让模块里的 ctx.clicked 立刻反映"这是第几次点"(签到判重的关键);
|
|
887
|
+
* ② 跑模块(它可能改按钮/正文/发消息/持久化);
|
|
888
|
+
* ③ 模块返回非空字符串 → 当普通文本回给点击者(比让模块自己 emit 更省事);
|
|
889
|
+
* ④ 模块把卡片改脏 → 重发卡片刷新按钮(QQ 没"改卡片"接口, 重发是可靠做法);
|
|
890
|
+
* ⑤ 模块抛错 → 回一句人话 + 落诊断, 绝不让一次点击把插件带崩(fail-soft)。
|
|
891
|
+
*/
|
|
892
|
+
async runExtClick(card, btn, presser, replyTarget) {
|
|
893
|
+
const extFile = card.extFile ?? '';
|
|
894
|
+
const state = card.extState;
|
|
895
|
+
const key = `${card.cardId}|${btn.id}`;
|
|
896
|
+
const count = (this.extClicks.get(key) ?? 0) + 1;
|
|
897
|
+
this.extClicks.set(key, count);
|
|
898
|
+
const { loaded, error } = await this.loadExt(extFile);
|
|
899
|
+
if (!loaded) {
|
|
900
|
+
this.extDiag(`点击期模块加载失败 ${extFile}: ${error}`);
|
|
901
|
+
// 加载失败时给用户一句人话(而不是静默无反应), 方便主人定位
|
|
902
|
+
await this.safeReply(replyTarget, `⚠️ 这个事件的模块加载失败, 请到面板看看错误摘要(文件: ${extFile})`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const mod = loaded.mod;
|
|
906
|
+
if (typeof mod.onClick !== 'function') {
|
|
907
|
+
this.extDiag(`模块 ${extFile} 没有 onClick, 点击被忽略 button=${btn.id}`);
|
|
908
|
+
await this.safeReply(replyTarget, '这个事件没有处理点击的逻辑(onClick)');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const ev = this.findEvent(card.eventId) ?? { id: card.eventId, name: card.eventName, buttons: [] };
|
|
912
|
+
const ctx = this.buildExtCtx({
|
|
913
|
+
ev: ev,
|
|
914
|
+
extFile,
|
|
915
|
+
cardId: card.cardId,
|
|
916
|
+
target: card.target,
|
|
917
|
+
state,
|
|
918
|
+
moduleForCard: mod,
|
|
919
|
+
triggererId: card.triggererId ?? '',
|
|
920
|
+
clickerId: presser,
|
|
921
|
+
clickedBefore: count - 1,
|
|
922
|
+
});
|
|
923
|
+
// 让按钮快照跟着模块的修改走(下一行点击/重发卡片都用最新按钮)
|
|
924
|
+
const beforeButtons = JSON.stringify(state.buttons);
|
|
925
|
+
try {
|
|
926
|
+
const ret = await mod.onClick(ctx, { buttonId: btn.id, buttonLabel: btn.label ?? '', clickedBefore: count - 1 });
|
|
927
|
+
if (state.buttons && JSON.stringify(state.buttons) !== beforeButtons) {
|
|
928
|
+
state.buttons = this.sanitizeExtButtons(state.buttons);
|
|
929
|
+
}
|
|
930
|
+
if (Array.isArray(state.buttons) && state.buttons.length > 0) {
|
|
931
|
+
card.buttons = JSON.parse(JSON.stringify(state.buttons));
|
|
932
|
+
}
|
|
933
|
+
if (typeof ret === 'string' && ret.trim())
|
|
934
|
+
await this.safeReply(replyTarget, ret.trim());
|
|
935
|
+
card.extReply = typeof ret === 'string' ? ret : '';
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
939
|
+
card.extError = msg;
|
|
940
|
+
state.error = `onClick 抛错: ${msg}`;
|
|
941
|
+
this.extDiag(`onClick 抛错 ${extFile} button=${btn.id}: ${msg}`);
|
|
942
|
+
this.logger.warn?.(`[botplay] ${extFile} onClick 抛错(已忽略): ${msg}`);
|
|
943
|
+
await this.safeReply(replyTarget, `⚠️ 事件逻辑出错了(onClick): ${msg}`);
|
|
944
|
+
}
|
|
945
|
+
// 模块改过卡片(按钮 label style / 正文) → 重发刷新, 群里看到的就是最新状态
|
|
946
|
+
if (state.dirty) {
|
|
947
|
+
state.dirty = false;
|
|
948
|
+
await this.refreshCard(card, state.contentText);
|
|
358
949
|
}
|
|
359
|
-
return true;
|
|
360
950
|
}
|
|
361
951
|
/**
|
|
362
952
|
* 点击人可读标签(与 dock 禁言面板/审批同源的昵称反查):
|
|
@@ -368,6 +958,23 @@ export class BotplayController {
|
|
|
368
958
|
clickerLabel(target, presser) {
|
|
369
959
|
if (!presser)
|
|
370
960
|
return '未知用户';
|
|
961
|
+
const name = this.memberName(target, presser);
|
|
962
|
+
// 台账/会话里查到的是纯昵称 → 补个 openid 尾巴(诊断/排查用); 查不到就是 openid 本身
|
|
963
|
+
return name === presser ? presser : `${name}(${presser.slice(0, 8)}…)`;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 昵称反查(2026-10-05 从 clickerLabel 抽出来给自定义模块用): 只要**纯昵称**, 不带 openid 尾巴
|
|
967
|
+
* (签到统计那种"列出所有签到者名字"的场景, 名字后面挂截断 openid 很难看)。
|
|
968
|
+
* 数据源与 clickerLabel 同源(见其注释): 群成员台账 → 私聊台账 → 会话消息壳 → 回落 openid。
|
|
969
|
+
*/
|
|
970
|
+
memberName(target, presser) {
|
|
971
|
+
if (!presser)
|
|
972
|
+
return '';
|
|
973
|
+
// ⓪ 内存缓存(后台 API 兜底查到的;比台账更新,优先用)
|
|
974
|
+
const _ck = `${target.targetId}|${presser}`;
|
|
975
|
+
const _cc = this.memberNameApiCache.get(_ck);
|
|
976
|
+
if (_cc)
|
|
977
|
+
return _cc;
|
|
371
978
|
// ① 台账(host 侧与 dock 禁言同源: dataDir=表情包目录, 同 group-members.jsonl)
|
|
372
979
|
try {
|
|
373
980
|
const dataDir = this.ledgerDataDirGetter();
|
|
@@ -376,13 +983,13 @@ export class BotplayController {
|
|
|
376
983
|
const members = readGroupMembers(dataDir, target.targetId);
|
|
377
984
|
const hit = members.find((m) => m.mid === presser);
|
|
378
985
|
if (hit && hit.name)
|
|
379
|
-
return
|
|
986
|
+
return hit.name;
|
|
380
987
|
}
|
|
381
988
|
else {
|
|
382
989
|
const chats = readLedger(dataDir);
|
|
383
990
|
const hit = chats.filter((c) => c.scope === 'c2c' && c.id === presser).sort((a, b) => b.ts - a.ts)[0];
|
|
384
991
|
if (hit && hit.name)
|
|
385
|
-
return
|
|
992
|
+
return hit.name;
|
|
386
993
|
}
|
|
387
994
|
}
|
|
388
995
|
}
|
|
@@ -410,12 +1017,18 @@ export class BotplayController {
|
|
|
410
1017
|
if (idx > 1) {
|
|
411
1018
|
const head = text.slice(0, idx).replace(/^\[+/, '').trim();
|
|
412
1019
|
if (head && !head.includes(presser))
|
|
413
|
-
return
|
|
1020
|
+
return head;
|
|
414
1021
|
}
|
|
415
1022
|
}
|
|
416
1023
|
}
|
|
417
1024
|
}
|
|
418
1025
|
catch { /* 解析失败回落 */ }
|
|
1026
|
+
// ④ API 兜底(2026-10-05,主人定:"兜底的时候用 api,其余的时候用台账")
|
|
1027
|
+
// ⚠️ 本函数是【同步】的(调用方很多,改异步要动一大片),所以做法是:
|
|
1028
|
+
// 本次仍回落 openid,**同时后台发起一次查询**;查到就写进内存缓存,
|
|
1029
|
+
// 下一次再问同一个人就有名字了(一场签到里通常只差几秒)。
|
|
1030
|
+
// 官方该接口标注「内邀接入中」(11253 = 无权限)⇒ 失败一律静默,只记一行日志。
|
|
1031
|
+
this.probeMemberNameAsync(target, presser);
|
|
419
1032
|
return presser;
|
|
420
1033
|
}
|
|
421
1034
|
/** 权限判定: all 放行 / triggerer=触发者本人 / owner=主人白名单 / users=指定 openid */
|
|
@@ -491,11 +1104,46 @@ export class BotplayController {
|
|
|
491
1104
|
}
|
|
492
1105
|
catch { /* 回复失败不致命 */ }
|
|
493
1106
|
}
|
|
494
|
-
/**
|
|
1107
|
+
/**
|
|
1108
|
+
* 通知自定义模块"这张卡片过期了"(onExpire; 2026-10-05)。
|
|
1109
|
+
* 为什么要有: 签到类事件常要"到点自动结算/清场", 模块没有这个回调就只能等主人手点。
|
|
1110
|
+
* fail-soft: 模块抛错只记日志。
|
|
1111
|
+
*/
|
|
1112
|
+
async fireExtExpire(card) {
|
|
1113
|
+
if (!card.extFile)
|
|
1114
|
+
return;
|
|
1115
|
+
try {
|
|
1116
|
+
const { loaded } = await this.loadExt(card.extFile);
|
|
1117
|
+
if (!loaded)
|
|
1118
|
+
return;
|
|
1119
|
+
const mod = loaded.mod;
|
|
1120
|
+
if (typeof mod.onExpire !== 'function')
|
|
1121
|
+
return;
|
|
1122
|
+
const ev = (this.findEvent(card.eventId) ?? { id: card.eventId, name: card.eventName, buttons: [] });
|
|
1123
|
+
const ctx = this.buildExtCtx({
|
|
1124
|
+
ev, extFile: card.extFile, cardId: card.cardId, target: card.target,
|
|
1125
|
+
state: card.extState ?? { contentText: '', buttons: [], dirty: false },
|
|
1126
|
+
moduleForCard: mod, triggererId: card.triggererId ?? '',
|
|
1127
|
+
});
|
|
1128
|
+
await mod.onExpire(ctx);
|
|
1129
|
+
this.extDiag(`onExpire ok ${card.extFile} card=${card.cardId}`);
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1133
|
+
this.extDiag(`onExpire 抛错 ${card.extFile}: ${msg}`);
|
|
1134
|
+
this.logger.warn?.(`[botplay] ${card.extFile} onExpire 抛错(已忽略): ${msg}`);
|
|
1135
|
+
}
|
|
1136
|
+
await this.disposeCard(card);
|
|
1137
|
+
}
|
|
1138
|
+
/** 清理全部活动卡(dispose 用; 自定义事件会收到 onDispose) */
|
|
495
1139
|
clear() {
|
|
1140
|
+
const snapshot = [...this.cards.values()];
|
|
496
1141
|
this.cards.clear();
|
|
1142
|
+
// onDispose 是异步的, 但不能让 dispose 链路等它 —— 逐个 fire 并吞掉异常
|
|
1143
|
+
for (const c of snapshot)
|
|
1144
|
+
void this.disposeCard(c).catch(() => { });
|
|
497
1145
|
}
|
|
498
|
-
/** 扫掉过期卡(Phase2: 定期/触发时调用,
|
|
1146
|
+
/** 扫掉过期卡(Phase2: 定期/触发时调用, 防内存积压; 2026-10-05 顺带通知模块 onExpire) */
|
|
499
1147
|
sweepExpired() {
|
|
500
1148
|
const now = Date.now();
|
|
501
1149
|
let n = 0;
|
|
@@ -503,6 +1151,10 @@ export class BotplayController {
|
|
|
503
1151
|
if (now > c.expireAt) {
|
|
504
1152
|
this.cards.delete(id);
|
|
505
1153
|
n += 1;
|
|
1154
|
+
if (c.extFile)
|
|
1155
|
+
void this.fireExtExpire(c).catch(() => { });
|
|
1156
|
+
else
|
|
1157
|
+
void this.disposeCard(c).catch(() => { });
|
|
506
1158
|
}
|
|
507
1159
|
}
|
|
508
1160
|
if (n > 0)
|
|
@@ -564,6 +1216,39 @@ export function listBotplayEventsAny() {
|
|
|
564
1216
|
}
|
|
565
1217
|
return out;
|
|
566
1218
|
}
|
|
1219
|
+
// ── 自定义事件面板出口(2026-10-05): settings-host 经 channel-tools 读这些 ──
|
|
1220
|
+
// host 半边不能直接拿 manager/controller, 只能走插件侧导出的模块级入口(与 triggerBotplay 同款)。
|
|
1221
|
+
/** 取某 ns 的 botplay 控制器(未注册返回 undefined) */
|
|
1222
|
+
export function botplayControllerOf(ns) {
|
|
1223
|
+
return botplayControllers.get(ns);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* 面板用: 某实例的自定义事件模块状态(路径/是否存在/mtime/加载错误/hooks)。
|
|
1227
|
+
* 找不到实例时返回空数组(面板显示"未就绪"即可, 不报错)。
|
|
1228
|
+
*/
|
|
1229
|
+
export function botplayExtStatus(ns) {
|
|
1230
|
+
const c = botplayControllers.get(ns);
|
|
1231
|
+
if (!c)
|
|
1232
|
+
return [];
|
|
1233
|
+
try {
|
|
1234
|
+
return c.extStatus();
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
return [];
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/** 面板「🔄 重载模块」用: 热重载指定模块(空=全部); 不重启宿主 */
|
|
1241
|
+
export function reloadBotplayExt(ns, file) {
|
|
1242
|
+
const c = botplayControllers.get(ns);
|
|
1243
|
+
if (!c)
|
|
1244
|
+
return { ok: false, msg: 'botplay 未就绪(插件未启动或该账号实例未加载)' };
|
|
1245
|
+
try {
|
|
1246
|
+
return c.reloadExt(file);
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
return { ok: false, msg: `重载异常: ${err instanceof Error ? err.message : String(err)}` };
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
567
1252
|
/**
|
|
568
1253
|
* 取指定实例的事件列表(命令层 /botplay 用)。
|
|
569
1254
|
* ⚠️ 2026-09-10 修复: 事件存储自 M4.3 起已迁到 {dataRoot}/botplay-events.json, 但命令层仍在读
|