@zaofan/dsh-qqbot 1.7.0 → 1.7.2

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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * ⚠️ 本文件是**插件自带的示例模块**(随仓库分发:examples/botplay/checkin-stats.mjs)。
3
+ * 想用它:复制到 {数据根}/.qqbot-extensions/botplay/ ,再登记一个 botplay 事件
4
+ * { "id":"checkin-stats", "name":"签到人数统计", "perm":{"type":"all"}, "file":"checkin-stats.mjs" }
5
+ * (或在面板「🎮 互动事件」里新建、选中后填模块文件名),然后 /botplay checkin-stats 发卡。
6
+ * 改这个示例不影响插件本体;你完全可以把它当骨架改成自己的互动。
7
+ */
8
+ /**
9
+ * checkin-stats.mjs — botplay 自定义事件: 签到人数统计(2026-10-05)
10
+ *
11
+ * 需求原文(主人 2026-10-05):
12
+ * 「我想写一个签到人数统计, 有两个按钮, 按钮1是签到, 所有人都能点且每个人只能点一次;
13
+ * 按钮2是结束, 只能我点。我点结束后, bot 发送消息到 QQ 上, 这条消息统计了点击了签到人数
14
+ * 和所有签到让的名称」
15
+ * 追加需求(主人 2026-10-05 06:11):
16
+ * 「修改已有的 checkin-stats, 我需要将统计结果的时候唤醒 ai, 让你知道统计结果」
17
+ * → 见下方 wakeAi(): 结算时(主人点结束 / 卡片超时)除发统计文本外, 再 ctx.appendWake()
18
+ * 把结果写进会话并唤醒 AI 本体, 让它知道自己刚统计了什么。
19
+ *
20
+ * ⚠️ 这个文件放在**数据根**下: {dataRoot}/.qqbot-extensions/botplay/checkin-stats.mjs
21
+ * 放数据根的意义: 插件升级/重装只换 node_modules 里的引擎, **不会覆盖它**。
22
+ * 模块自己的状态存在同目录的 data/checkin-stats.mjs.json 里(见 ctx.store), 升级也不丢。
23
+ *
24
+ * 怎么用:
25
+ * ① 事件登记(botplay-events.json)里已有: { "id":"checkin-stats", "name":"签到人数统计",
26
+ * "file":"checkin-stats.mjs" }。
27
+ * ② 群里发 `/botplay checkin-stats` 发出卡片。
28
+ * ③ 群友点「✅ 签到」→ 每人只能成功一次; 重复点会收到"你已经签过了"。
29
+ * ④ 主人点「🏁 结束并统计」→ 群里发出统计消息, **同时唤醒 AI**(它会知道结果)。
30
+ *
31
+ * 钩子与 ctx 能力(完整契约见插件源码 src/features/botplay-ext.ts 头部注释):
32
+ * onInit(ctx) 卡片实例首次加载(含每次重新发卡)时执行
33
+ * onClick(ctx, info) 某个按钮被点击; 返回非空字符串 = 把这句回给点击者
34
+ * onExpire(ctx) 卡片超时被回收(这里没用到, 但留着演示"到点自动收尾"怎么写)
35
+ *
36
+ * ⚠️ 一处容易踩的坑(重要, 也是这个示例想教会的):
37
+ * framework 的按钮 permission 是**整张卡片**一个, 没法"按钮1谁都能点、按钮2只有主人能点"。
38
+ * 所以本模块的做法是: 事件 perm 设 all(谁都能点), 细粒度权限**在 onClick 里自己判**
39
+ * (ctx.user.isOwner / ctx.owners)。
40
+ */
41
+
42
+ /** 事件 id(与 botplay-events.json 里的登记一致; 只用于日志/自检, 不参与逻辑) */
43
+ const EVENT_ID = 'checkin-stats';
44
+
45
+ /** 模块状态(每个模块文件只有一份实例, 被同名卡片的多个实例共享) */
46
+ let S = {
47
+ /** openid → 昵称(用 Set 语义: 每人只成功一次) */
48
+ signed: new Map(),
49
+ /** 是否已结束(结束过就锁定, 防止统计完还有人继续签) */
50
+ ended: false,
51
+ /** 从磁盘装载过没有(懒加载一次) */
52
+ loaded: false,
53
+ /**
54
+ * 连点去重: `${cardId}|${buttonId}|${openid}` → 上次处理时间戳(ms)。
55
+ * 只挡 1.5 秒内的重复投递(QQ 客户端偶尔会把同一次点击投两次),
56
+ * 业务规则(每人一次)照旧由 S.signed 决定。
57
+ */
58
+ handled: new Map(),
59
+ };
60
+
61
+ /** 同一人同一按钮 1.5 秒内只算一次(挡客户端重复投递; 不参与业务判重) */
62
+ const DEDUP_MS = 1500;
63
+
64
+ /**
65
+ * 载入本轮状态。
66
+ * 状态**按 ctx.cardId 分轮**存(rounds[cardId]),换卡即换轮。
67
+ */
68
+ function seed(ctx) {
69
+ const cid = String(ctx.cardId || "");
70
+ if (S.cardId !== cid) {
71
+ // 换卡 = 新的一轮:丢弃内存里的旧名单,重新从磁盘按本轮 cardId 载入
72
+ S = { cardId: cid, signed: new Map(), ended: false, loaded: false, handled: (S && S.handled) || new Map() };
73
+ }
74
+ if (S.loaded) return;
75
+ S.loaded = true;
76
+ const saved = ctx.store.load();
77
+ const rounds = (saved && typeof saved === "object" && saved.rounds) || {};
78
+ const row = rounds[cid];
79
+ if (row && typeof row === "object") {
80
+ if (Array.isArray(row.signed)) {
81
+ for (const r of row.signed) {
82
+ if (r && r.openid) S.signed.set(String(r.openid), String(r.name || r.openid));
83
+ }
84
+ }
85
+ S.ended = row.ended === true;
86
+ }
87
+ }
88
+
89
+ /** 落盘(按轮存;只保留最近 20 轮,避免文件无限涨) */
90
+ function persist(ctx) {
91
+ const cid = String(ctx.cardId || "");
92
+ const signed = [...S.signed.entries()].map(([openid, name]) => ({ openid, name }));
93
+ const saved = ctx.store.load();
94
+ const rounds = (saved && typeof saved === "object" && saved.rounds) || {};
95
+ rounds[cid] = { signed, ended: S.ended, updatedAt: new Date().toISOString() };
96
+ const ids = Object.keys(rounds);
97
+ if (ids.length > 20) { for (const k of ids.slice(0, ids.length - 20)) delete rounds[k]; }
98
+ const ok = ctx.store.save({ rounds });
99
+ if (!ok) ctx.log("状态写盘失败(下次点击会再试)");
100
+ }
101
+
102
+ /** 卡片正文(随签到人数实时变) */
103
+ function cardMd(tagline) {
104
+ const n = S.signed.size;
105
+ return [
106
+ '## 📋 签到统计',
107
+ '',
108
+ tagline || '点下面按钮签到 👇 每人只能签一次。',
109
+ '',
110
+ `**当前已签到 ${n} 人**${S.ended ? '(本轮已结束)' : ''}`,
111
+ '',
112
+ '---',
113
+ '· ✅ 签到 —— 所有人都能点,每人限一次',
114
+ '· 🏁 结束并统计 —— 只有主人能点,点完在群里公布名单',
115
+ ].join('\n');
116
+ }
117
+
118
+ /** 按钮定义(签到按钮文字带人数, 群里一眼能看到进度) */
119
+ function buttons() {
120
+ return [
121
+ {
122
+ id: 'signin',
123
+ label: `✅ 签到 (${S.signed.size})`,
124
+ visitedLabel: '✅ 签到',
125
+ style: 1,
126
+ botAction: { type: 'reply_text', text: '' },
127
+ },
128
+ {
129
+ id: 'end',
130
+ label: '🏁 结束并统计',
131
+ visitedLabel: '🏁 已结束',
132
+ style: 0,
133
+ botAction: { type: 'reply_text', text: '' },
134
+ },
135
+ ];
136
+ }
137
+
138
+ /**
139
+ * 把最新状态写进卡片定义。
140
+ * ⚠️ 只有 repaint=true 才让框架【重发整张卡片】;默认不重发。
141
+ */
142
+ function syncCard(ctx, tagline, repaint) {
143
+ const c = ctx.card();
144
+ c.contentText = cardMd(tagline);
145
+ c.buttons = buttons();
146
+ if (repaint) c.dirty = true;
147
+ }
148
+
149
+ /** 组装统计文本: 人数 + 全部签到者名称 */
150
+ function statsText() {
151
+ const names = [...S.signed.values()];
152
+ const lines = [
153
+ '### 🏁 签到结束 · 统计结果',
154
+ '',
155
+ `**签到人数:${names.length} 人**`,
156
+ '',
157
+ ];
158
+ if (names.length === 0) {
159
+ lines.push('本轮没有人签到 🐳');
160
+ } else {
161
+ // 每行最多 6 个名字, 群名多时不至于糊成一大坨
162
+ for (let i = 0; i < names.length; i += 6) {
163
+ lines.push(names.slice(i, i + 6).map((n) => `· ${n}`).join(' '));
164
+ }
165
+ lines.push('', '(按签到先后排序)');
166
+ }
167
+ return lines.join('\n');
168
+ }
169
+
170
+ /**
171
+ * ★ 2026-10-05 主人新增需求:结算时唤醒 AI,让 AI 本体知道统计结果。
172
+ *
173
+ * 为什么用 ctx.appendWake:
174
+ * appendWake 会把一段文本作为「用户消息」写进本会话,**并触发一次 AI 回合**——
175
+ * 也就是把结果直接交到 AI 手里(AI 手里有插件的全部工具:能发言、发图、发文件、写日志……)。
176
+ * 而 appendSilent 只写上下文不唤醒,AI 要等到下次被叫才看得到,不符合"结算即知晓"。
177
+ * ⚠️ appendWake 会消耗 token,所以只在**真正出结果的那一刻**调一次
178
+ * (主人点「结束并统计」 或 卡片超时自动结算),高频点击里绝不要调。
179
+ * ⚠️ 失败不能影响卡片流程,所以整段包 try/catch。
180
+ */
181
+ function wakeAi(ctx, reason) {
182
+ try {
183
+ const names = [...S.signed.values()];
184
+ const list = names.length
185
+ ? names.map((n, i) => `${i + 1}. ${n}`).join('、')
186
+ : '(本轮无人签到)';
187
+ const brief = [
188
+ '【botplay 自定义事件 · 签到统计】本轮签到已结算,通知你一下结果:',
189
+ `· 触发方式:${reason}`,
190
+ `· 卡片实例:${ctx.cardId}`,
191
+ `· 签到人数:${names.length} 人`,
192
+ `· 名单(按签到先后):${list}`,
193
+ '',
194
+ '统计文本已经由卡片直接发到群里了(群友已能看到)。',
195
+ '你只需要用你自己的口吻在群里补一句简短回应(例如一句播报/一句吐槽/一句感谢),',
196
+ '不需要把名单再复述一遍;如果你愿意,也可以顺手把这次结果记进今日工作日志。',
197
+ ].join('\n');
198
+ if (typeof ctx.appendWake !== 'function') {
199
+ ctx.log('当前框架不支持 appendWake,跳过唤醒 AI');
200
+ return;
201
+ }
202
+ ctx.appendWake(brief);
203
+ ctx.log(`已唤醒 AI 同步统计结果(${names.length} 人, reason=${reason})`);
204
+ } catch (e) {
205
+ ctx.log(`唤醒 AI 失败(不影响签到流程): ${e && e.message}`);
206
+ }
207
+ }
208
+
209
+ export default {
210
+ name: '签到人数统计',
211
+
212
+ /** 卡片实例加载/重新发卡时: 装载状态 + 布置按钮/正文 */
213
+ onInit(ctx) {
214
+ seed(ctx);
215
+ syncCard(ctx);
216
+ ctx.log(`onInit card=${ctx.cardId} 已签到=${S.signed.size} ended=${S.ended} scope=${ctx.event.scope}`);
217
+ },
218
+
219
+ /**
220
+ * 按钮点击。
221
+ * 返回非空字符串 = 把这句回给点击者(status=成功/提示/失败); 返回空 = 不回文本。
222
+ */
223
+ onClick(ctx, info) {
224
+ seed(ctx);
225
+ const btn = String(info.buttonId || '');
226
+ const who = ctx.user;
227
+ // 连点去重: 同一卡片/按钮/人 1.5 秒内只处理一次(挡客户端重复投递, 不参与业务判重)
228
+ const stamp = `${ctx.cardId}|${btn}|${who.openid}`;
229
+ const now = Date.now();
230
+ const prev = S.handled.get(stamp) || 0;
231
+ if (now - prev < DEDUP_MS) {
232
+ ctx.log(`重复投递被挡(1.5s 内) ${stamp}`);
233
+ return undefined;
234
+ }
235
+ S.handled.set(stamp, now);
236
+ if (S.handled.size > 2000) S.handled.clear(); // 兜底: 别让去重表无限涨
237
+
238
+ // ── 按钮1: 签到(所有人都能点, 每人只成功一次) ──
239
+ if (btn === 'signin') {
240
+ if (S.ended) return '本轮签到已经结束啦,等下一轮吧~';
241
+ if (!who.openid) return '拿不到你的身份,稍后再试~';
242
+ if (S.signed.has(who.openid)) {
243
+ ctx.log(`重复签到被拒 openid=${who.openid}`);
244
+ return '你已经签过了 ✅'; // ← 需求里明确要的提示
245
+ }
246
+ S.signed.set(who.openid, who.pureName || who.name || who.openid);
247
+ persist(ctx);
248
+ // 点签到只**记录 + 回一句话**,不重发卡片(2026-10-05 修):
249
+ // 重发整卡既刷屏、又可能撞 QQ 发卡限频;名单在「结束」时统一公布就够。
250
+ ctx.log(`签到 ok openid=${who.openid} name=${who.pureName} 共=${S.signed.size}`);
251
+ return `✅ 签到成功,你是第 ${S.signed.size} 位!`;
252
+ }
253
+
254
+ // ── 按钮2: 结束并统计(只有主人能点) ──
255
+ if (btn === 'end') {
256
+ // 细粒度权限只能自己判(卡片级 perm 做不到按按钮区分) —— 见文件头注释里那个坑
257
+ if (!who.isOwner) {
258
+ ctx.log(`非主人点结束被拒 openid=${who.openid}`);
259
+ return '🙅 这个按钮只有主人能点哦~';
260
+ }
261
+ if (S.ended) return `本轮已经结束过了(签到 ${S.signed.size} 人)`;
262
+ S.ended = true;
263
+ persist(ctx);
264
+ // 需求核心①: 主人点结束后, bot 往群里发一条统计消息
265
+ ctx.emit(statsText());
266
+ // 需求核心②(2026-10-05 追加): 同时唤醒 AI, 让它知道统计结果
267
+ wakeAi(ctx, '主人点击「🏁 结束并统计」');
268
+ ctx.log(`结束统计发出: ${S.signed.size} 人`);
269
+ return `🏁 已结束,统计已发到群里(共 ${S.signed.size} 人)`;
270
+ }
271
+
272
+ ctx.log(`未知按钮 ${btn}, 忽略`);
273
+ return undefined;
274
+ },
275
+
276
+ /**
277
+ * 卡片超时被回收: 到点还没人点结束, 就由 bot 自己公布一轮结果。
278
+ * 同一套结算逻辑 —— 也会唤醒 AI。
279
+ * 不想要这个行为就删掉本钩子 —— 钩子全部可选。
280
+ */
281
+ onExpire(ctx) {
282
+ seed(ctx);
283
+ if (S.ended || S.signed.size === 0) return;
284
+ S.ended = true;
285
+ persist(ctx);
286
+ ctx.emit(statsText());
287
+ wakeAi(ctx, '卡片超时自动结算');
288
+ ctx.log(`卡片过期自动结算: ${S.signed.size} 人`);
289
+ },
290
+
291
+ /** 卡片关闭/热重载/插件卸载时的收尾(这里把内存去重表清掉即可; 状态已在磁盘上) */
292
+ onDispose(ctx) {
293
+ S.handled.clear();
294
+ ctx.log(`onDispose card=${ctx.cardId} event=${EVENT_ID}`);
295
+ },
296
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",
@@ -59,6 +59,8 @@
59
59
  "dist",
60
60
  "docs/USER-GUIDE.md",
61
61
  "docs/botplay-contract.md",
62
+ "docs/host-services.md",
63
+ "examples",
62
64
  "client",
63
65
  "cordis.patch.yml",
64
66
  "settings-host.js",