@zaofan/dsh-qqbot 1.7.5 → 1.7.6

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 (33) hide show
  1. package/dist/channel-tools.d.ts.map +1 -1
  2. package/dist/channel-tools.js +12 -4
  3. package/dist/channel-tools.js.map +1 -1
  4. package/dist/features/card-callback.d.ts +6 -2
  5. package/dist/features/card-callback.d.ts.map +1 -1
  6. package/dist/features/card-callback.js +3 -1
  7. package/dist/features/card-callback.js.map +1 -1
  8. package/dist/features/ext-capabilities.d.ts +26 -0
  9. package/dist/features/ext-capabilities.d.ts.map +1 -1
  10. package/dist/features/ext-capabilities.js +32 -0
  11. package/dist/features/ext-capabilities.js.map +1 -1
  12. package/dist/features/ext-tool-cards.d.ts +52 -0
  13. package/dist/features/ext-tool-cards.d.ts.map +1 -0
  14. package/dist/features/ext-tool-cards.js +152 -0
  15. package/dist/features/ext-tool-cards.js.map +1 -0
  16. package/dist/features/ext-tool-interaction.d.ts +53 -0
  17. package/dist/features/ext-tool-interaction.d.ts.map +1 -0
  18. package/dist/features/ext-tool-interaction.js +103 -0
  19. package/dist/features/ext-tool-interaction.js.map +1 -0
  20. package/dist/features/extension-store.d.ts +24 -0
  21. package/dist/features/extension-store.d.ts.map +1 -1
  22. package/dist/features/extension-store.js +3 -0
  23. package/dist/features/extension-store.js.map +1 -1
  24. package/dist/features/qq-approval.d.ts +12 -0
  25. package/dist/features/qq-approval.d.ts.map +1 -1
  26. package/dist/features/qq-approval.js +43 -5
  27. package/dist/features/qq-approval.js.map +1 -1
  28. package/dist/gateway/bootstrap.d.ts.map +1 -1
  29. package/dist/gateway/bootstrap.js +50 -3
  30. package/dist/gateway/bootstrap.js.map +1 -1
  31. package/examples/ext-tools/README.md +47 -0
  32. package/examples/ext-tools/vote.mjs +318 -0
  33. package/package.json +1 -1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * vote.mjs — 「群投票」扩展工具 v3(2026-10-08)
3
+ *
4
+ * v3(主人当天的第三轮反馈):
5
+ * ① **中途回执极简** —— 投一票只回「「C. 还行」+1 票」,不再甩整张票数表;
6
+ * 完整结算**只在结束时**给(结束按钮 / action=end);
7
+ * ② **允许多选** —— 同一个人可以点多个选项(想全投也行);
8
+ * ③ **同一按钮只能投一次** —— 重复点同一个按钮回「你已经投过这一项了」;
9
+ * ④ 卡片**最后一行加「✋ 结束投票」按钮** —— 任何群友点一下就能结算(结算会发到群里并通知 AI);
10
+ * 因此选项上限调整为 **20 个**(4 行 × 5 列,第 5 行留给结束按钮)。
11
+ *
12
+ * v2(保留):多投票并存(V1/V2…)、中途加选项(action=add,补发新卡、旧卡仍可用)、
13
+ * 定时结束(minutes)、status 查票数、结束时 appendWake 通知 AI。
14
+ *
15
+ * 安装:复制到 `{数据根}/.qqbot-extensions/tools/vote.mjs`,然后 `tools_reload`(或重启宿主)。
16
+ *
17
+ * 用法(AI 侧):
18
+ * vote {action:'start', topic:'今晚吃什么', options:'火锅|烧烤|白饭'}
19
+ * vote {action:'add', voteId:'V1', options:'泡面'}
20
+ * vote {action:'status', voteId:'V1'}
21
+ * vote {action:'end', voteId:'V1'}
22
+ * vote {action:'start', topic:'周末去哪', options:'爬山|宅家', minutes:30}
23
+ *
24
+ * 底层依赖(插件侧,见维护手册 §11.32):env.registerInteractionCard 登记卡片;
25
+ * 按钮 action.type=1、data=`ext:vote:<投票号>:<下标或 e>`;导出 onInteraction 收点击。
26
+ */
27
+
28
+ const MAX_OPTIONS = 20; // 4 行 × 5 列(第 5 行留给「结束投票」)
29
+ const PER_ROW = 5;
30
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRST'; // 20 个字母
31
+ const END_BTN = 'e'; // 结束按钮的 buttonId
32
+ const TOOL = 'vote';
33
+ const SEP = /[||,,]/;
34
+
35
+ function loadAll(env) {
36
+ const d = env?.store && typeof env.store.load === 'function' ? env.store.load() : undefined;
37
+ const raw = d && typeof d === 'object' && !Array.isArray(d) ? d : {};
38
+ // ── v1 → v2/v3 数据迁移 ────────────────────────────────────────────
39
+ // v1:{ "<cardId>": { topic, options, votes:{openid:idx}, counts:[] } }
40
+ // v2+:{ seq, votes: { V1: { id, topic, options, votes } } }
41
+ // 旧 cardId 也挂成别名键 ⇒ 群里那张旧卡片照样能点。
42
+ if (!raw.votes) {
43
+ const migrated = { seq: 0, votes: {} };
44
+ for (const [cardId, old] of Object.entries(raw)) {
45
+ if (!old || typeof old !== 'object' || !Array.isArray(old.options)) continue;
46
+ migrated.seq += 1;
47
+ const id = `V${migrated.seq}`;
48
+ const rec = {
49
+ id,
50
+ topic: String(old.topic ?? '(旧投票)'),
51
+ options: old.options,
52
+ votes: old.votes && typeof old.votes === 'object' ? old.votes : {},
53
+ createdAt: Number(old.createdAt) || Date.now(),
54
+ note: `由 v1 卡片 ${cardId} 迁移`,
55
+ };
56
+ migrated.votes[id] = rec;
57
+ migrated.votes[cardId] = rec;
58
+ }
59
+ return migrated;
60
+ }
61
+ return raw;
62
+ }
63
+ function saveAll(env, d) {
64
+ try { env.store.save(d); } catch { /* 落盘失败不影响本次回复 */ }
65
+ }
66
+
67
+ /** 某个人的选择 —— 一律按数组读(兼容 v1/v2 的单个数字) */
68
+ function choicesOf(v, openid) {
69
+ const c = v.votes?.[openid];
70
+ if (c === undefined) return [];
71
+ return Array.isArray(c) ? c.slice() : [c];
72
+ }
73
+ /** 某人是否投过某个选项 */
74
+ function hasChosen(v, openid, idx) {
75
+ return choicesOf(v, openid).includes(idx);
76
+ }
77
+
78
+ /** 卡片正文:题目 + 选项字母表 + 状态(选项全文在这儿,按钮上只有字母) */
79
+ function cardBody(v, extra) {
80
+ const lines = [
81
+ `🗳 **${v.topic}**`,
82
+ `投票号:\`${v.id}\`${v.endsAt ? ` 截止:${new Date(v.endsAt).toLocaleString('zh-CN', { hour12: false })}` : ''}`,
83
+ '',
84
+ ];
85
+ v.options.forEach((o, i) => lines.push(`**${LETTERS[i] ?? '?'}**. ${o}`));
86
+ lines.push('');
87
+ if (v.closed) lines.push(`⛔ 已结束(共 ${Object.keys(v.votes).length} 人参与)`);
88
+ else lines.push('可多选:想投几个就点几个;每个按钮每人只能点一次。想收摊就点「✋ 结束投票」。');
89
+ if (extra) lines.push('', extra);
90
+ return lines.join('\n');
91
+ }
92
+
93
+ /** 选项按钮(每行 5 个)+ 最后一行的「结束投票」 */
94
+ function keyboardOf(voteId, options) {
95
+ const rows = [];
96
+ const n = Math.min(options.length, MAX_OPTIONS);
97
+ for (let r = 0; r * PER_ROW < n; r++) {
98
+ const buttons = [];
99
+ for (let c = 0; c < PER_ROW; c++) {
100
+ const i = r * PER_ROW + c;
101
+ if (i >= n) break;
102
+ buttons.push({
103
+ id: `b${i}`,
104
+ render_data: { label: LETTERS[i], visited_label: `${LETTERS[i]}✓`, style: 1 },
105
+ action: {
106
+ type: 1,
107
+ permission: { type: 2 },
108
+ data: `ext:${TOOL}:${voteId}:${i}`,
109
+ unsupport_tips: '请升级QQ客户端后重试',
110
+ },
111
+ });
112
+ }
113
+ rows.push({ buttons });
114
+ }
115
+ // 最后一行放「结束投票」(放在第二个位置,避免误触第一个按钮)
116
+ rows.push({
117
+ buttons: [
118
+ {
119
+ id: END_BTN,
120
+ render_data: { label: '✋ 结束投票', visited_label: '✋ 结束投票', style: 4 },
121
+ action: {
122
+ type: 1,
123
+ permission: { type: 2 },
124
+ data: `ext:${TOOL}:${voteId}:${END_BTN}`,
125
+ unsupport_tips: '请升级QQ客户端后重试',
126
+ },
127
+ },
128
+ ],
129
+ });
130
+ return { content: { rows } };
131
+ }
132
+
133
+ /** 完整结算块(只在"结束时"用;中途回执不许用它) */
134
+ function tallyBlock(v) {
135
+ const people = Object.keys(v.votes).length;
136
+ const lines = [`参与人数:**${people}**(可多选)`];
137
+ v.options.forEach((o, i) => {
138
+ const n = Object.values(v.votes).filter((c) => (Array.isArray(c) ? c : [c]).includes(i)).length;
139
+ lines.push(`· ${LETTERS[i]}. ${o} —— **${n}** 票`);
140
+ });
141
+ return lines.join('\n');
142
+ }
143
+
144
+ /** 取投票 + 顺手做"到点自动结束"判定(键先原样、再大写,兼容旧卡片别名) */
145
+ function pick(data, voteId) {
146
+ const key = String(voteId ?? '').trim();
147
+ const v = data?.votes?.[key] ?? data?.votes?.[key.toUpperCase()];
148
+ if (!v) return null;
149
+ if (!v.closed && v.endsAt && Date.now() >= v.endsAt) v.closed = true;
150
+ return v;
151
+ }
152
+
153
+ async function sendCard(env, v, extra) {
154
+ return env.markdownCard(cardBody(v, extra), keyboardOf(v.id, v.options));
155
+ }
156
+
157
+ /** 结束收尾(结束按钮 / action=end 共用):群里公示 + 通知 AI */
158
+ async function finish(env, v, byWhom) {
159
+ const first = !v.closed;
160
+ v.closed = true;
161
+ v.endedAt = Date.now();
162
+ const block = tallyBlock(v);
163
+ try {
164
+ await env.markdown([
165
+ `🗳 **投票结束:${v.topic}**(${v.id})`,
166
+ byWhom ? `由 ${byWhom} 点下了结束按钮。` : '',
167
+ '',
168
+ block,
169
+ ].filter(Boolean).join('\n'));
170
+ } catch { /* 公示失败不影响结算 */ }
171
+ try {
172
+ if (typeof env.appendWake === 'function') {
173
+ await env.appendWake(`[投票结束] ${v.id}「${v.topic}」已结算:\n${block}`);
174
+ }
175
+ } catch { /* 通知失败不影响结算 */ }
176
+ return { first, block };
177
+ }
178
+
179
+ export default {
180
+ name: TOOL,
181
+ description: '群投票(可多选):start=发起 / add=中途加选项 / status=看票数 / end=结束结算;卡片最后一行自带「结束投票」按钮;支持多投票并存、最多 20 个选项、可设定时结束;结束时结果会通知 AI。',
182
+ inputSchema: {
183
+ action: { type: 'string', required: true, description: 'start=发起投票;add=加选项;status=查票数;end=结束并结算' },
184
+ topic: { type: 'string', description: 'start 必填:投票题目' },
185
+ options: { type: 'string', description: 'start/add 必填:选项,用 | 分隔(每条建议 ≤12 字,合计最多 20 条)' },
186
+ voteId: { type: 'string', description: 'add/status/end 必填:投票号(start 时返回,如 V1)' },
187
+ minutes: { type: 'number', description: '可选(仅 start):多少分钟后自动结束;不填 = 手动/点结束按钮' },
188
+ },
189
+
190
+ async run(args, env) {
191
+ const action = String(args?.action ?? 'start').trim().toLowerCase();
192
+ const all = loadAll(env);
193
+ all.votes = all.votes && typeof all.votes === 'object' ? all.votes : {};
194
+ all.seq = Number(all.seq) || 0;
195
+
196
+ // ── 发起 ─────────────────────────────────────────────
197
+ if (action === 'start') {
198
+ const topic = String(args?.topic ?? '').trim();
199
+ const options = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
200
+ if (!topic) return { ok: false, msg: '缺 topic(投票题目)' };
201
+ if (options.length < 2) return { ok: false, msg: '至少 2 个选项(用 | 分隔)' };
202
+ if (options.length > MAX_OPTIONS) return { ok: false, msg: `选项太多:最多 ${MAX_OPTIONS} 个(卡片 4 行 × 5 列,第 5 行留给结束按钮)` };
203
+
204
+ all.seq += 1;
205
+ const id = `V${all.seq}`;
206
+ const minutes = Number(args?.minutes);
207
+ const v = {
208
+ id,
209
+ topic,
210
+ options,
211
+ votes: {},
212
+ createdAt: Date.now(),
213
+ ...(Number.isFinite(minutes) && minutes > 0 ? { endsAt: Date.now() + minutes * 60_000 } : {}),
214
+ };
215
+ all.votes[id] = v;
216
+ saveAll(env, all);
217
+
218
+ const ok = typeof env.registerInteractionCard === 'function'
219
+ ? env.registerInteractionCard({
220
+ cardId: id,
221
+ // ★ 结束按钮也要登记,否则点了没人认
222
+ buttonIds: [...options.slice(0, MAX_OPTIONS).map((_, i) => String(i)), END_BTN],
223
+ expireAt: Date.now() + (v.endsAt ? Math.max(24 * 3600_000, v.endsAt - Date.now() + 86400_000) : 3 * 86400_000),
224
+ })
225
+ : false;
226
+ const sent = await sendCard(env, v);
227
+ return {
228
+ ok: !!sent,
229
+ msg: sent
230
+ ? `投票已发起:${id}「${topic}」,${options.length} 个选项${v.endsAt ? `,${minutes} 分钟后自动结束` : '(结束时点卡片上的「✋ 结束投票」,或 action=end)'}${ok ? '' : ';⚠️ 卡片登记失败,按钮可能点不动'}`
231
+ : '发卡失败(确认在 QQ 会话里)',
232
+ };
233
+ }
234
+
235
+ // ── 中途加选项 ───────────────────────────────────────
236
+ if (action === 'add') {
237
+ const v = pick(all, args?.voteId);
238
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
239
+ if (v.closed) return { ok: false, msg: `投票 ${v.id} 已经结束了,不能再加选项` };
240
+ const added = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
241
+ if (added.length === 0) return { ok: false, msg: '要加什么选项?(options 用 | 分隔)' };
242
+ if (v.options.length + added.length > MAX_OPTIONS) {
243
+ return { ok: false, msg: `加不下:还余 ${MAX_OPTIONS - v.options.length} 个位(含结束按钮共 5 行)` };
244
+ }
245
+ const startIdx = v.options.length;
246
+ v.options.push(...added);
247
+ saveAll(env, all);
248
+ if (typeof env.registerInteractionCard === 'function') {
249
+ env.registerInteractionCard({
250
+ cardId: v.id,
251
+ buttonIds: [...v.options.map((_, i) => String(i)), END_BTN],
252
+ expireAt: Date.now() + 3 * 86400_000,
253
+ });
254
+ }
255
+ const newOnes = added.map((a, k) => `**${LETTERS[startIdx + k]}**. ${a}`).join('、');
256
+ const sent = await sendCard(env, v, `🆕 新增选项:${newOnes}(旧卡片上的按钮同样有效)`);
257
+ return { ok: !!sent, msg: `已给 ${v.id} 加上:${added.join('、')}(现共 ${v.options.length} 个选项)` };
258
+ }
259
+
260
+ // ── 查票数 ───────────────────────────────────────────
261
+ if (action === 'status') {
262
+ const v = pick(all, args?.voteId);
263
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
264
+ saveAll(env, all);
265
+ return { ok: true, msg: `${v.closed ? '【已结束】' : '【进行中】'}${v.id}「${v.topic}」\n${tallyBlock(v)}` };
266
+ }
267
+
268
+ // ── 结束并结算(AI 手动)────────────────────────────
269
+ if (action === 'end') {
270
+ const v = pick(all, args?.voteId);
271
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
272
+ const { first, block } = await finish(env, v, '');
273
+ saveAll(env, all);
274
+ return { ok: true, msg: `${first ? '已结束并结算' : '该投票此前已结束,重新结算'}:${v.id}「${v.topic}」\n${block}` };
275
+ }
276
+
277
+ return { ok: false, msg: `未知 action:${action}(可用:start / add / status / end)` };
278
+ },
279
+
280
+ async onInteraction(ctx, info) {
281
+ const openid = String(ctx?.performer?.openid ?? '');
282
+ if (!openid) return '拿不到你的身份,稍后再试~';
283
+
284
+ const all = loadAll(ctx);
285
+ const v = pick(all, info.cardId);
286
+ if (!v) return '这次投票不存在或已被清理。';
287
+
288
+ const who = `${openid.slice(0, 6)}…`;
289
+
290
+ // ── 结束按钮 ─────────────────────────────────────────
291
+ if (String(info.buttonId) === END_BTN) {
292
+ if (v.closed) return '这次投票已经结束啦,结算在上面那条消息里。';
293
+ await finish(ctx, v, who);
294
+ saveAll(ctx, all);
295
+ ctx.log?.(`[vote] ${v.id} 由 ${who} 点结束按钮`);
296
+ return '✅ 已结束,结算发到群里了(也通知到 AI 了)。';
297
+ }
298
+
299
+ if (v.closed) return '这次投票已经结束啦,结算在上面那条消息里。';
300
+
301
+ const idx = Number(String(info.buttonId ?? ''));
302
+ if (!Number.isInteger(idx) || idx < 0 || idx >= v.options.length) return '这个选项不认识…';
303
+
304
+ // 同一按钮只能投一次(但**可以**继续投别的选项 = 多选)
305
+ if (hasChosen(v, openid, idx)) {
306
+ return `你已经投过「${LETTERS[idx]}. ${v.options[idx]}」了(不过还能投别的选项)`;
307
+ }
308
+ const mine = choicesOf(v, openid);
309
+ mine.push(idx);
310
+ v.votes[openid] = mine;
311
+ saveAll(ctx, all);
312
+
313
+ const cnt = Object.values(v.votes).filter((c) => (Array.isArray(c) ? c : [c]).includes(idx)).length;
314
+ ctx.log?.(`[vote] ${v.id} by=${who} → ${LETTERS[idx]}(该项第 ${cnt} 票)`);
315
+ // ★ 中途回执只报这一票(主人要求:别甩整张表,完整结算只在结束时给)
316
+ return `✅ 「${LETTERS[idx]}. ${v.options[idx]}」+1 票(当前 ${cnt} 票)`;
317
+ },
318
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.7.5",
3
+ "version": "1.7.6",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",