@zaofan/dsh-qqbot 1.10.9 → 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 -6199
  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
@@ -1,323 +1,534 @@
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
- let woke = false;
172
- try {
173
- if (typeof env.appendWake === 'function') {
174
- // appendWake 返回 false = 没唤醒成功(例如 caps 没拿到该会话的 agent)—— 别嘴上说通知了
175
- woke = (await env.appendWake(`[投票结束] ${v.id}「${v.topic}」已结算:\n${block}`)) !== false;
176
- }
177
- } catch { /* 通知失败不影响结算 */ }
178
- return { first, block, woke };
179
- }
180
-
181
- export default {
182
- name: TOOL,
183
- description: '群投票(可多选):start=发起 / add=中途加选项 / status=看票数 / end=结束结算;卡片最后一行自带「结束投票」按钮;支持多投票并存、最多 20 个选项、可设定时结束;结束时结果会通知 AI。',
184
- inputSchema: {
185
- action: { type: 'string', required: true, description: 'start=发起投票;add=加选项;status=查票数;end=结束并结算' },
186
- topic: { type: 'string', description: 'start 必填:投票题目' },
187
- options: { type: 'string', description: 'start/add 必填:选项,用 | 分隔(每条建议 ≤12 字,合计最多 20 条)' },
188
- voteId: { type: 'string', description: 'add/status/end 必填:投票号(start 时返回,如 V1)' },
189
- minutes: { type: 'number', description: '可选(仅 start):多少分钟后自动结束;不填 = 手动/点结束按钮' },
190
- },
191
-
192
- async run(args, env) {
193
- const action = String(args?.action ?? 'start').trim().toLowerCase();
194
- const all = loadAll(env);
195
- all.votes = all.votes && typeof all.votes === 'object' ? all.votes : {};
196
- all.seq = Number(all.seq) || 0;
197
-
198
- // ── 发起 ─────────────────────────────────────────────
199
- if (action === 'start') {
200
- const topic = String(args?.topic ?? '').trim();
201
- const options = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
202
- if (!topic) return { ok: false, msg: '缺 topic(投票题目)' };
203
- if (options.length < 2) return { ok: false, msg: '至少 2 个选项(用 | 分隔)' };
204
- if (options.length > MAX_OPTIONS) return { ok: false, msg: `选项太多:最多 ${MAX_OPTIONS} 个(卡片 4 行 × 5 列,第 5 行留给结束按钮)` };
205
-
206
- all.seq += 1;
207
- const id = `V${all.seq}`;
208
- const minutes = Number(args?.minutes);
209
- const v = {
210
- id,
211
- topic,
212
- options,
213
- votes: {},
214
- createdAt: Date.now(),
215
- ...(Number.isFinite(minutes) && minutes > 0 ? { endsAt: Date.now() + minutes * 60_000 } : {}),
216
- };
217
- all.votes[id] = v;
218
- saveAll(env, all);
219
-
220
- const ok = typeof env.registerInteractionCard === 'function'
221
- ? env.registerInteractionCard({
222
- cardId: id,
223
- // ★ 结束按钮也要登记,否则点了没人认
224
- buttonIds: [...options.slice(0, MAX_OPTIONS).map((_, i) => String(i)), END_BTN],
225
- expireAt: Date.now() + (v.endsAt ? Math.max(24 * 3600_000, v.endsAt - Date.now() + 86400_000) : 3 * 86400_000),
226
- })
227
- : false;
228
- const sent = await sendCard(env, v);
229
- return {
230
- ok: !!sent,
231
- msg: sent
232
- ? `投票已发起:${id}「${topic}」,${options.length} 个选项${v.endsAt ? `,${minutes} 分钟后自动结束` : '(结束时点卡片上的「✋ 结束投票」,或 action=end)'}${ok ? '' : ';⚠️ 卡片登记失败,按钮可能点不动'}`
233
- : '发卡失败(确认在 QQ 会话里)',
234
- };
235
- }
236
-
237
- // ── 中途加选项 ───────────────────────────────────────
238
- if (action === 'add') {
239
- const v = pick(all, args?.voteId);
240
- if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
241
- if (v.closed) return { ok: false, msg: `投票 ${v.id} 已经结束了,不能再加选项` };
242
- const added = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
243
- if (added.length === 0) return { ok: false, msg: '要加什么选项?(options 用 | 分隔)' };
244
- if (v.options.length + added.length > MAX_OPTIONS) {
245
- return { ok: false, msg: `加不下:还余 ${MAX_OPTIONS - v.options.length} 个位(含结束按钮共 5 行)` };
246
- }
247
- const startIdx = v.options.length;
248
- v.options.push(...added);
249
- saveAll(env, all);
250
- if (typeof env.registerInteractionCard === 'function') {
251
- env.registerInteractionCard({
252
- cardId: v.id,
253
- buttonIds: [...v.options.map((_, i) => String(i)), END_BTN],
254
- expireAt: Date.now() + 3 * 86400_000,
255
- });
256
- }
257
- const newOnes = added.map((a, k) => `**${LETTERS[startIdx + k]}**. ${a}`).join('、');
258
- const sent = await sendCard(env, v, `🆕 新增选项:${newOnes}(旧卡片上的按钮同样有效)`);
259
- return { ok: !!sent, msg: `已给 ${v.id} 加上:${added.join('、')}(现共 ${v.options.length} 个选项)` };
260
- }
261
-
262
- // ── 查票数 ───────────────────────────────────────────
263
- if (action === 'status') {
264
- const v = pick(all, args?.voteId);
265
- if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
266
- saveAll(env, all);
267
- return { ok: true, msg: `${v.closed ? '【已结束】' : '【进行中】'}${v.id}「${v.topic}」\n${tallyBlock(v)}` };
268
- }
269
-
270
- // ── 结束并结算(AI 手动)────────────────────────────
271
- if (action === 'end') {
272
- const v = pick(all, args?.voteId);
273
- if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
274
- const { first, block } = await finish(env, v, '');
275
- saveAll(env, all);
276
- return { ok: true, msg: `${first ? '已结束并结算' : '该投票此前已结束,重新结算'}:${v.id}「${v.topic}」\n${block}` };
277
- }
278
-
279
- return { ok: false, msg: `未知 action:${action}(可用:start / add / status / end)` };
280
- },
281
-
282
- async onInteraction(ctx, info) {
283
- const openid = String(ctx?.performer?.openid ?? '');
284
- if (!openid) return '拿不到你的身份,稍后再试~';
285
-
286
- const all = loadAll(ctx);
287
- const v = pick(all, info.cardId);
288
- if (!v) return '这次投票不存在或已被清理。';
289
-
290
- const who = `${openid.slice(0, 6)}…`;
291
-
292
- // ── 结束按钮 ─────────────────────────────────────────
293
- if (String(info.buttonId) === END_BTN) {
294
- if (v.closed) return '这次投票已经结束啦,结算在上面那条消息里。';
295
- const { woke } = await finish(ctx, v, who);
296
- saveAll(ctx, all);
297
- ctx.log?.(`[vote] ${v.id} 由 ${who} 点结束按钮 woke=${woke}`);
298
- // 回执据实:通知失败时别嘴硬(2026-10-08 主人抓到过一次"说通知了其实没通知")
299
- return woke
300
- ? '✅ 已结束,结算发到群里了(也把结果通知给 AI 了)。'
301
- : '✅ 已结束,结算发到群里了(⚠️ 但通知 AI 没成功,可以让她用 status 自查)。';
302
- }
303
-
304
- if (v.closed) return '这次投票已经结束啦,结算在上面那条消息里。';
305
-
306
- const idx = Number(String(info.buttonId ?? ''));
307
- if (!Number.isInteger(idx) || idx < 0 || idx >= v.options.length) return '这个选项不认识…';
308
-
309
- // 同一按钮只能投一次(但**可以**继续投别的选项 = 多选)
310
- if (hasChosen(v, openid, idx)) {
311
- return `你已经投过「${LETTERS[idx]}. ${v.options[idx]}」了(不过还能投别的选项)`;
312
- }
313
- const mine = choicesOf(v, openid);
314
- mine.push(idx);
315
- v.votes[openid] = mine;
316
- saveAll(ctx, all);
317
-
318
- const cnt = Object.values(v.votes).filter((c) => (Array.isArray(c) ? c : [c]).includes(idx)).length;
319
- ctx.log?.(`[vote] ${v.id} by=${who} → ${LETTERS[idx]}(该项第 ${cnt} 票)`);
320
- // ★ 中途回执只报这一票(主人要求:别甩整张表,完整结算只在结束时给)
321
- return `✅ 「${LETTERS[idx]}. ${v.options[idx]}」+1 票(当前 ${cnt} 票)`;
322
- },
323
- };
1
+ import { join } from 'node:path';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+
4
+ /**
5
+ * vote.mjs — 「群投票」扩展工具 v3(2026-10-08)
6
+ *
7
+ * v3(主人当天的第三轮反馈):
8
+ * ① **中途回执极简** —— 投一票只回「「C. 还行」+1 票」,不再甩整张票数表;
9
+ * 完整结算**只在结束时**给(结束按钮 / action=end);
10
+ * ② **允许多选** —— 同一个人可以点多个选项(想全投也行);
11
+ * ③ **同一按钮只能投一次** —— 重复点同一个按钮回「你已经投过这一项了」;
12
+ * ④ 卡片**最后一行加「✋ 结束投票」按钮** —— 任何群友点一下就能结算(结算会发到群里并通知 AI);
13
+ * 因此选项上限调整为 **20 个**(4 行 × 5 列,第 5 行留给结束按钮)。
14
+ *
15
+ * v2(保留):多投票并存(V1/V2…)、中途加选项(action=add,补发新卡、旧卡仍可用)、
16
+ * 定时结束(minutes)、status 查票数、结束时 appendWake 通知 AI。
17
+ *
18
+ * 安装:复制到 `{数据根}/.qqbot-extensions/tools/vote.mjs`,然后 `tools_reload`(或重启宿主)。
19
+ *
20
+ * 用法(AI 侧):
21
+ * vote {action:'start', topic:'今晚吃什么', options:'火锅|烧烤|白饭'}
22
+ * vote {action:'add', voteId:'V1', options:'泡面'}
23
+ * vote {action:'status', voteId:'V1'}
24
+ * vote {action:'end', voteId:'V1'}
25
+ * vote {action:'start', topic:'周末去哪', options:'爬山|宅家', minutes:30}
26
+ *
27
+ * 底层依赖(插件侧,见维护手册 §11.32):env.registerInteractionCard 登记卡片;
28
+ * 按钮 action.type=1、data=`ext:vote:<投票号>:<下标或 e>`;导出 onInteraction 收点击。
29
+ */
30
+
31
+ const MAX_OPTIONS = 20; // 4 行 × 5 列(第 5 行留给「结束投票」)
32
+ const PER_ROW = 5;
33
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRST'; // 20 个字母
34
+ const END_BTN = 'e'; // 结束按钮的 buttonId
35
+ const TOOL = 'vote';
36
+ const SEP = /[||,,]/;
37
+
38
+ /**
39
+ * ★ 2026-10-16 主人要求:「结束投票」按钮**只有主人能点**,别人点了不生效。
40
+ * 之前任何群友点一下就结算(主人开的那次被秒结束)。
41
+ * 白名单写在这里;要加人往数组里加 openid 即可。
42
+ */
43
+ const END_ALLOWED_OPENIDS = [
44
+ 'B901EA3CE83714B5719E96A5938F6C37', // 做早饭(群主/主人)
45
+ 'E9020753E73AD84C10A79B6006E96C63', // 做早饭(旧号,一并放行)
46
+ ];
47
+ /** 点击者是否有权结束。空值一律拒绝(fail-closed)。 */
48
+ function canEnd(openid) {
49
+ const id = String(openid ?? '').trim();
50
+ return id !== '' && END_ALLOWED_OPENIDS.includes(id);
51
+ }
52
+
53
+ /**
54
+ * ★ 2026-10-16 主人要求:投票记录要带**真实用户名**(谁投了什么、谁点的结束)。
55
+ *
56
+ * 取名字的优先级(先官方、后兜底),跟插件自己的 botplay/memberName 同源:
57
+ * ① `ctx.user.name` —— 插件官方契约(docs/extension-contract.md):
58
+ * 身份三件套 `env.user.openid / .name / .isOwner`,按钮回调侧同样可读。
59
+ * ② **群成员台账** `{dataRoot}/表情包/group-members.jsonl` ——
60
+ * 这就是插件 `Botplay.memberName()` 查昵称时读的那份台账(见 dist/features/botplay.js:1016
61
+ * 「台账(host 侧与 dock 禁言同源: dataDir=表情包目录, 同 group-members.jsonl)」)。
62
+ * 每行 `{ts, gid, mid, name}`:mid=openid、name=**真实 QQ 昵称**(不是 openid)。
63
+ * 后写的行覆盖先写的 ⇒ 自然取到最新昵称。
64
+ * ③ 回落 `msg-index` 的 openid→昵称表(`{dataRoot}/.qqbot/msg-index/<scope>_<peerId>/refs.json`
65
+ * 里 `entries[].s`=openid、`n`=昵称)——台账里还没出现的人(从没说过话)用它兜。
66
+ * ④ 再不行才显示 openid 前 6 位;全程不抛错(名字只是锦上添花,别拖垮投票)。
67
+ */
68
+ function dataRootOf(env) {
69
+ return env?.dataRoot || env?.caps?.dataRoot || process.cwd();
70
+ }
71
+ /** 台账路径:{dataRoot}/表情包/group-members.jsonl */
72
+ function ledgerPath(env) {
73
+ return join(dataRootOf(env), '表情包', 'group-members.jsonl');
74
+ }
75
+ /** msg-index 目录:{dataRoot}/.qqbot/msg-index */
76
+ function nameIndexPath(env) {
77
+ return join(dataRootOf(env), '.qqbot', 'msg-index');
78
+ }
79
+ /** 读群成员台账(openid → 真实昵称)。任何异常都返回空表,绝不抛。 */
80
+ function loadMemberLedger(env) {
81
+ const map = new Map();
82
+ let raw;
83
+ try {
84
+ raw = readFileSync(ledgerPath(env), 'utf8');
85
+ } catch {
86
+ return map; // 台账不存在 = 还没攒到成员,正常
87
+ }
88
+ for (const line of raw.split('\n')) {
89
+ const s = line.trim();
90
+ if (!s) continue;
91
+ try {
92
+ const o = JSON.parse(s);
93
+ const mid = String(o?.mid ?? '').trim();
94
+ const name = String(o?.name ?? '').trim();
95
+ if (mid && name) map.set(mid, name);
96
+ } catch { /* 单行坏了跳过 */ }
97
+ }
98
+ return map;
99
+ }
100
+ /** 读一遍 msg-index,拼 openid → 昵称。任何异常都返回空表。 */
101
+ function loadNameMap(env) {
102
+ const map = new Map();
103
+ const base = nameIndexPath(env);
104
+ let scopes;
105
+ try {
106
+ scopes = readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory());
107
+ } catch {
108
+ return map; // 目录不存在 = 没有历史,正常
109
+ }
110
+ for (const d of scopes) {
111
+ try {
112
+ const raw = readFileSync(join(base, d.name, 'refs.json'), 'utf8');
113
+ const j = JSON.parse(raw);
114
+ for (const e of j?.entries ?? []) {
115
+ const s = String(e?.s ?? '').trim();
116
+ const n = String(e?.n ?? '').trim();
117
+ if (s && n) map.set(s, n);
118
+ }
119
+ } catch { /* 单个会话文件坏了不影响其它 */ }
120
+ }
121
+ return map;
122
+ }
123
+ /** 一次把两张表都读出来(按钮回调里只调一次,别每个人名读一次盘)。 */
124
+ function loadNames(env) {
125
+ return { ledger: loadMemberLedger(env), index: loadNameMap(env) };
126
+ }
127
+
128
+ /** 进程内 API 查询缓存:`${gid}|${openid}` → 真名(免得同一人反复打官方接口) */
129
+ const API_NAME_CACHE = new Map();
130
+
131
+ /**
132
+ * ★ 2026-10-16 主人(小号)指路:**只要有 openid 就能从 QQ 官方接口拿昵称**。
133
+ *
134
+ * 官方接口(插件 `dist/api/group-admin.js:213` 用的就是这条):
135
+ * GET https://api.bot.qq.com/v2/groups/{group_openid}/members/{member_openid}
136
+ * 返回 data.username = **真实 QQ 昵称**
137
+ * 扩展契约(docs/extension-contract.md §2)把这条路直接开给了扩展:
138
+ * `await env.api(path)` —— 「官方 API,自带 token 与 2h 缓存;返回 {ok,data} 或 {ok:false,err}」。
139
+ * 所以不需要 appSecret、不需要自己换 token。
140
+ *
141
+ * 拿不到 group openid(单聊)或接口没权限时返回空串,由上层继续往下兜。
142
+ */
143
+ async function fetchNameViaApi(env, openid) {
144
+ const gid = String(env?.peer?.peerId ?? '').trim();
145
+ const scope = String(env?.peer?.scope ?? '').trim();
146
+ const id = String(openid ?? '').trim();
147
+ if (!id || !gid || (scope && scope !== 'group')) return '';
148
+ if (typeof env?.api !== 'function') return '';
149
+ const key = `${gid}|${id}`;
150
+ if (API_NAME_CACHE.has(key)) return API_NAME_CACHE.get(key);
151
+ let nm = '';
152
+ try {
153
+ const r = await env.api(`/v2/groups/${encodeURIComponent(gid)}/members/${encodeURIComponent(id)}`);
154
+ if (r?.ok) nm = String(r.data?.username ?? '').trim();
155
+ else env.log?.(`[vote] 官方接口查昵称未命中 ${id.slice(0, 6)}… code=${r?.err?.code ?? '?'}`);
156
+ } catch (e) {
157
+ env.log?.(`[vote] 官方接口查昵称异常(不影响投票): ${e?.message ?? e}`);
158
+ }
159
+ API_NAME_CACHE.set(key, nm);
160
+ if (API_NAME_CACHE.size > 500) API_NAME_CACHE.clear();
161
+ return nm;
162
+ }
163
+
164
+ /**
165
+ * 取点击者真实昵称,优先级:
166
+ * ① 插件官方给的 `ctx.user.pureName / .name`
167
+ * ② ★ QQ 官方接口 `/v2/groups/{gid}/members/{openid}` → username(只要 openid 就能查到真名)
168
+ * ③ 群成员台账 `表情包/group-members.jsonl`(本地、瞬时、覆盖说过话的人)
169
+ * ④ msg-index 的 openid→昵称表
170
+ * ⑤ 实在没有才显示 openid 前 6 位
171
+ * 全程不抛错:名字拿不到也绝不能拖垮投票。
172
+ */
173
+ async function resolveName(env, ctx, openid, names) {
174
+ const official =
175
+ String(ctx?.user?.pureName ?? '').trim() ||
176
+ String(ctx?.user?.name ?? '').trim();
177
+ if (official) return official;
178
+ const id = String(openid ?? '').trim();
179
+ if (!id) return '(未知)';
180
+ const viaApi = await fetchNameViaApi(env, id);
181
+ if (viaApi) return viaApi;
182
+ const hit = names?.ledger?.get(id) || names?.index?.get(id);
183
+ if (hit) return hit;
184
+ // ⑤ 最后兜底:**用 @ 代替名字**
185
+ // 只点按钮、从没在群里说过话的人不会进成员台账,官方接口又没权限 ⇒ 本地查不到。
186
+ // 但 QQ 自己认得 openid:把 `<@openid>` 放进回执/结算里,客户端会把昵称渲染出来,
187
+ // 于是照样「看得到是谁」,而不是一串 openid 前六位。
188
+ try {
189
+ if (typeof env?.at === 'function') return env.at(id);
190
+ } catch { /* at 失败就退回前缀 */ }
191
+ return `${id.slice(0, 6)}…`;
192
+ }
193
+
194
+ function loadAll(env) {
195
+ const d = env?.store && typeof env.store.load === 'function' ? env.store.load() : undefined;
196
+ const raw = d && typeof d === 'object' && !Array.isArray(d) ? d : {};
197
+ // ── v1 → v2/v3 数据迁移 ────────────────────────────────────────────
198
+ // v1:{ "<cardId>": { topic, options, votes:{openid:idx}, counts:[] } }
199
+ // v2+:{ seq, votes: { V1: { id, topic, options, votes } } }
200
+ // 旧 cardId 也挂成别名键 ⇒ 群里那张旧卡片照样能点。
201
+ if (!raw.votes) {
202
+ const migrated = { seq: 0, votes: {} };
203
+ for (const [cardId, old] of Object.entries(raw)) {
204
+ if (!old || typeof old !== 'object' || !Array.isArray(old.options)) continue;
205
+ migrated.seq += 1;
206
+ const id = `V${migrated.seq}`;
207
+ const rec = {
208
+ id,
209
+ topic: String(old.topic ?? '(旧投票)'),
210
+ options: old.options,
211
+ votes: old.votes && typeof old.votes === 'object' ? old.votes : {},
212
+ createdAt: Number(old.createdAt) || Date.now(),
213
+ note: `由 v1 卡片 ${cardId} 迁移`,
214
+ };
215
+ migrated.votes[id] = rec;
216
+ migrated.votes[cardId] = rec;
217
+ }
218
+ return migrated;
219
+ }
220
+ return raw;
221
+ }
222
+ function saveAll(env, d) {
223
+ try { env.store.save(d); } catch { /* 落盘失败不影响本次回复 */ }
224
+ }
225
+
226
+ /** 某个人的选择 —— 一律按数组读(兼容 v1/v2 的单个数字) */
227
+ function choicesOf(v, openid) {
228
+ const c = v.votes?.[openid];
229
+ if (c === undefined) return [];
230
+ return Array.isArray(c) ? c.slice() : [c];
231
+ }
232
+ /** 某人是否投过某个选项 */
233
+ function hasChosen(v, openid, idx) {
234
+ return choicesOf(v, openid).includes(idx);
235
+ }
236
+
237
+ /** 卡片正文:题目 + 选项字母表 + 状态(选项全文在这儿,按钮上只有字母) */
238
+ function cardBody(v, extra) {
239
+ const lines = [
240
+ `🗳 **${v.topic}**`,
241
+ `投票号:\`${v.id}\`${v.endsAt ? ` 截止:${new Date(v.endsAt).toLocaleString('zh-CN', { hour12: false })}` : ''}`,
242
+ '',
243
+ ];
244
+ v.options.forEach((o, i) => lines.push(`**${LETTERS[i] ?? '?'}**. ${o}`));
245
+ lines.push('');
246
+ if (v.closed) lines.push(`⛔ 已结束(共 ${Object.keys(v.votes).length} 人参与)`);
247
+ else lines.push('可多选:想投几个就点几个;每个按钮每人只能点一次。(「✋ 结束投票」只有主人能点)');
248
+ if (extra) lines.push('', extra);
249
+ return lines.join('\n');
250
+ }
251
+
252
+ /** 选项按钮(每行 5 个)+ 最后一行的「结束投票」 */
253
+ function keyboardOf(voteId, options) {
254
+ const rows = [];
255
+ const n = Math.min(options.length, MAX_OPTIONS);
256
+ for (let r = 0; r * PER_ROW < n; r++) {
257
+ const buttons = [];
258
+ for (let c = 0; c < PER_ROW; c++) {
259
+ const i = r * PER_ROW + c;
260
+ if (i >= n) break;
261
+ buttons.push({
262
+ id: `b${i}`,
263
+ render_data: { label: LETTERS[i], visited_label: `${LETTERS[i]}✓`, style: 1 },
264
+ action: {
265
+ type: 1,
266
+ permission: { type: 2 },
267
+ data: `ext:${TOOL}:${voteId}:${i}`,
268
+ unsupport_tips: '请升级QQ客户端后重试',
269
+ },
270
+ });
271
+ }
272
+ rows.push({ buttons });
273
+ }
274
+ // 最后一行放「结束投票」(放在第二个位置,避免误触第一个按钮)
275
+ rows.push({
276
+ buttons: [
277
+ {
278
+ id: END_BTN,
279
+ render_data: { label: '✋ 结束投票', visited_label: '✋ 结束投票', style: 4 },
280
+ action: {
281
+ type: 1,
282
+ permission: { type: 2 },
283
+ data: `ext:${TOOL}:${voteId}:${END_BTN}`,
284
+ unsupport_tips: '请升级QQ客户端后重试',
285
+ },
286
+ },
287
+ ],
288
+ });
289
+ return { content: { rows } };
290
+ }
291
+
292
+ /** 完整结算块(只在"结束时"用;中途回执不许用它) */
293
+ function tallyBlock(v) {
294
+ const nameOf = (id) => v.names?.[id] || `${String(id).slice(0, 6)}…`;
295
+ const entries = Object.entries(v.votes ?? {});
296
+ const lines = [`参与人数:**${entries.length}**(可多选)`];
297
+ v.options.forEach((o, i) => {
298
+ const voters = entries
299
+ .filter(([, c]) => (Array.isArray(c) ? c : [c]).includes(i))
300
+ .map(([id]) => nameOf(id));
301
+ lines.push(`· ${LETTERS[i]}. ${o} —— **${voters.length}** 票${voters.length ? `:${voters.join('、')}` : ''}`);
302
+ });
303
+ if (entries.length) {
304
+ lines.push('', '**投票明细**');
305
+ for (const [id, c] of entries) {
306
+ const arr = (Array.isArray(c) ? c : [c])
307
+ .filter((x) => Number.isInteger(x) && v.options[x] !== undefined)
308
+ .map((x) => `${LETTERS[x]}`);
309
+ lines.push(`· ${nameOf(id)} → ${arr.join('、') || '(无)'}`);
310
+ }
311
+ }
312
+ if (v.endedBy) lines.push('', `✋ 由 **${nameOf(v.endedBy)}** 结束`);
313
+ return lines.join('\n');
314
+ }
315
+
316
+ /** 取投票 + 顺手做"到点自动结束"判定(键先原样、再大写,兼容旧卡片别名) */
317
+ function pick(data, voteId) {
318
+ const key = String(voteId ?? '').trim();
319
+ const v = data?.votes?.[key] ?? data?.votes?.[key.toUpperCase()];
320
+ if (!v) return null;
321
+ if (!v.closed && v.endsAt && Date.now() >= v.endsAt) v.closed = true;
322
+ return v;
323
+ }
324
+
325
+ async function sendCard(env, v, extra) {
326
+ return env.markdownCard(cardBody(v, extra), keyboardOf(v.id, v.options));
327
+ }
328
+
329
+ /**
330
+ * 结束收尾(结束按钮 / action=end 共用)。
331
+ *
332
+ * ★ 2026-10-16 主人要求(两轮澄清后的口径):
333
+ * · 「结束投票不报消息」= 不再往**群里**发那张结算表(POLL_GROUP_BROADCAST=false)
334
+ * · 但「结束统计的回执本身是要的」= 点结束的人仍收到一句回执(由 onInteraction 的返回值发)
335
+ * · appendWake 通知 AI 也一并关掉(AI 自己用 status 就能查)
336
+ * 想恢复"结束就往群里公示统计":把下面 POLL_GROUP_BROADCAST 改回 true。
337
+ */
338
+ const POLL_GROUP_BROADCAST = false;
339
+ /** 结束是否唤醒 AI 通报
340
+ * ★ 2026-10-16 主人反馈「按了结束投票,AI 没收到结果」→ 改回 true:
341
+ * 结束(含私聊里点卡片结束按钮 / action=end)都把结算表 appendWake 进会话。
342
+ * 群公示仍由上面的 POLL_GROUP_BROADCAST 单独控制。 */
343
+ const POLL_WAKE_AI = true;
344
+
345
+ async function finish(env, v, byWhom, byName) {
346
+ const first = !v.closed;
347
+ v.closed = true;
348
+ v.endedAt = Date.now();
349
+ // ★ 记录"谁点的结束"(openid + 昵称),结算表里会写出来
350
+ if (byWhom) v.endedBy = byWhom;
351
+ if (byWhom && byName) v.names = { ...(v.names ?? {}), [byWhom]: byName };
352
+ const block = tallyBlock(v);
353
+
354
+ // 群公示(默认关):只影响"往群里发那张表",不影响给点击者的回执
355
+ if (POLL_GROUP_BROADCAST) {
356
+ try {
357
+ await env.markdown([
358
+ `🗳 **投票结束:${v.topic}**(${v.id})`,
359
+ byWhom ? `由 ${byWhom} 点下了结束按钮。` : '',
360
+ '',
361
+ block,
362
+ ].filter(Boolean).join('\n'));
363
+ } catch { /* 公示失败不影响结算 */ }
364
+ }
365
+
366
+ // 通知 AI(默认关)
367
+ if (POLL_WAKE_AI) {
368
+ try {
369
+ if (typeof env.appendWake === 'function') {
370
+ await env.appendWake(`[投票结束] ${v.id}「${v.topic}」已结算:\n${block}`);
371
+ }
372
+ } catch { /* 通知失败不影响结算 */ }
373
+ }
374
+
375
+ return { first, block, woke: false };
376
+ }
377
+
378
+ export default {
379
+ name: TOOL,
380
+ description: '群投票(可多选):start=发起 / add=中途加选项 / status=看票数 / end=结束结算;卡片最后一行自带「结束投票」按钮;支持多投票并存、最多 20 个选项、可设定时结束;结束时结果会通知 AI。',
381
+ inputSchema: {
382
+ action: { type: 'string', required: true, description: 'start=发起投票;add=加选项;status=查票数;end=结束并结算' },
383
+ topic: { type: 'string', description: 'start 必填:投票题目' },
384
+ options: { type: 'string', description: 'start/add 必填:选项,用 | 分隔(每条建议 ≤12 字,合计最多 20 条)' },
385
+ voteId: { type: 'string', description: 'add/status/end 必填:投票号(start 时返回,如 V1)' },
386
+ minutes: { type: 'number', description: '可选(仅 start):多少分钟后自动结束;不填 = 手动/点结束按钮' },
387
+ },
388
+
389
+ async run(args, env) {
390
+ const action = String(args?.action ?? 'start').trim().toLowerCase();
391
+ const all = loadAll(env);
392
+ all.votes = all.votes && typeof all.votes === 'object' ? all.votes : {};
393
+ all.seq = Number(all.seq) || 0;
394
+
395
+ // ── 发起 ─────────────────────────────────────────────
396
+ if (action === 'start') {
397
+ const topic = String(args?.topic ?? '').trim();
398
+ const options = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
399
+ if (!topic) return { ok: false, msg: '缺 topic(投票题目)' };
400
+ if (options.length < 2) return { ok: false, msg: '至少 2 个选项(用 | 分隔)' };
401
+ if (options.length > MAX_OPTIONS) return { ok: false, msg: `选项太多:最多 ${MAX_OPTIONS} 个(卡片 4 行 × 5 列,第 5 行留给结束按钮)` };
402
+
403
+ all.seq += 1;
404
+ const id = `V${all.seq}`;
405
+ const minutes = Number(args?.minutes);
406
+ const v = {
407
+ id,
408
+ topic,
409
+ options,
410
+ votes: {},
411
+ createdAt: Date.now(),
412
+ ...(Number.isFinite(minutes) && minutes > 0 ? { endsAt: Date.now() + minutes * 60_000 } : {}),
413
+ };
414
+ all.votes[id] = v;
415
+ saveAll(env, all);
416
+
417
+ const ok = typeof env.registerInteractionCard === 'function'
418
+ ? env.registerInteractionCard({
419
+ cardId: id,
420
+ // ★ 结束按钮也要登记,否则点了没人认
421
+ buttonIds: [...options.slice(0, MAX_OPTIONS).map((_, i) => String(i)), END_BTN],
422
+ expireAt: Date.now() + (v.endsAt ? Math.max(24 * 3600_000, v.endsAt - Date.now() + 86400_000) : 3 * 86400_000),
423
+ })
424
+ : false;
425
+ const sent = await sendCard(env, v);
426
+ return {
427
+ ok: !!sent,
428
+ msg: sent
429
+ ? `投票已发起:${id}「${topic}」,${options.length} 个选项${v.endsAt ? `,${minutes} 分钟后自动结束` : '(结束时点卡片上的「✋ 结束投票」,或 action=end)'}${ok ? '' : ';⚠️ 卡片登记失败,按钮可能点不动'}`
430
+ : '发卡失败(确认在 QQ 会话里)',
431
+ };
432
+ }
433
+
434
+ // ── 中途加选项 ───────────────────────────────────────
435
+ if (action === 'add') {
436
+ const v = pick(all, args?.voteId);
437
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
438
+ if (v.closed) return { ok: false, msg: `投票 ${v.id} 已经结束了,不能再加选项` };
439
+ const added = String(args?.options ?? '').split(SEP).map((s) => s.trim()).filter(Boolean);
440
+ if (added.length === 0) return { ok: false, msg: '要加什么选项?(options 用 | 分隔)' };
441
+ if (v.options.length + added.length > MAX_OPTIONS) {
442
+ return { ok: false, msg: `加不下:还余 ${MAX_OPTIONS - v.options.length} 个位(含结束按钮共 5 行)` };
443
+ }
444
+ const startIdx = v.options.length;
445
+ v.options.push(...added);
446
+ saveAll(env, all);
447
+ if (typeof env.registerInteractionCard === 'function') {
448
+ env.registerInteractionCard({
449
+ cardId: v.id,
450
+ buttonIds: [...v.options.map((_, i) => String(i)), END_BTN],
451
+ expireAt: Date.now() + 3 * 86400_000,
452
+ });
453
+ }
454
+ const newOnes = added.map((a, k) => `**${LETTERS[startIdx + k]}**. ${a}`).join('、');
455
+ const sent = await sendCard(env, v, `🆕 新增选项:${newOnes}(旧卡片上的按钮同样有效)`);
456
+ return { ok: !!sent, msg: `已给 ${v.id} 加上:${added.join('、')}(现共 ${v.options.length} 个选项)` };
457
+ }
458
+
459
+ // ── 查票数 ───────────────────────────────────────────
460
+ if (action === 'status') {
461
+ const v = pick(all, args?.voteId);
462
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
463
+ saveAll(env, all);
464
+ return { ok: true, msg: `${v.closed ? '【已结束】' : '【进行中】'}${v.id}「${v.topic}」\n${tallyBlock(v)}` };
465
+ }
466
+
467
+ // ── 结束并结算(AI 手动)────────────────────────────
468
+ if (action === 'end') {
469
+ const v = pick(all, args?.voteId);
470
+ if (!v) return { ok: false, msg: `没找到投票 ${args?.voteId}` };
471
+ const { first, block } = await finish(env, v, '');
472
+ saveAll(env, all);
473
+ return { ok: true, msg: `${first ? '已结束并结算' : '该投票此前已结束,重新结算'}:${v.id}「${v.topic}」\n\n${block}` };
474
+ }
475
+
476
+ return { ok: false, msg: `未知 action:${action}(可用:start / add / status / end)` };
477
+ },
478
+
479
+ async onInteraction(ctx, info) {
480
+ const openid = String(ctx?.performer?.openid ?? '');
481
+ if (!openid) return '拿不到你的身份,稍后再试~';
482
+
483
+ const all = loadAll(ctx);
484
+ const v = pick(all, info.cardId);
485
+ if (!v) return '这次投票不存在或已被清理。';
486
+
487
+ // ★ 2026-10-16:取点击者真实昵称
488
+ // 官方 ctx.user.name → QQ 官方接口(只要 openid 就能查真名) → 群成员台账 → msg-index → openid 前缀
489
+ const names = loadNames(ctx);
490
+ const name = await resolveName(ctx, ctx, openid, names);
491
+ const who = `${name}(${openid.slice(0, 6)}…)`;
492
+
493
+ // ★ 2026-10-16 主人要求:投票一旦结束,任何人再点**都不回执**(静默吞掉)。
494
+ // 契约:onInteraction 返回空串 = 不发回执(见 ext-tool-interaction.js: `if (text) sendReceipt(...)`)。
495
+ if (v.closed) {
496
+ ctx.log?.(`[vote] ${v.id} 已结束,忽略 ${who} 的点击(静默)`);
497
+ return '';
498
+ }
499
+
500
+ // ── 结束按钮 ─────────────────────────────────────────
501
+ if (String(info.buttonId) === END_BTN) {
502
+ // ★ 2026-10-16 主人要求:只有主人的 openid 能结束;别人点了不结算
503
+ if (!canEnd(openid)) {
504
+ ctx.log?.(`[vote] ${v.id} 结束按钮被非主人点击:${who}(已拒绝)`);
505
+ return '这个按钮只有主人能点哦~想结算的话叫一声早饭哥哥。';
506
+ }
507
+ const { woke, block } = await finish(ctx, v, openid, name);
508
+ saveAll(ctx, all);
509
+ ctx.log?.(`[vote] ${v.id} 由 ${who} 点结束按钮 woke=${woke}`);
510
+ // ★ 2026-10-16 主人澄清:「结束统计的回执本身是要的」——
511
+ // 所以这里把结算表**作为回执**发给点击者(只有点的人看得到,不刷群)。
512
+ return `✅ 已结束,结算如下:\n\n${block}`;
513
+ }
514
+
515
+ const idx = Number(String(info.buttonId ?? ''));
516
+ if (!Number.isInteger(idx) || idx < 0 || idx >= v.options.length) return '这个选项不认识…';
517
+
518
+ // 同一按钮只能投一次(但**可以**继续投别的选项 = 多选)
519
+ if (hasChosen(v, openid, idx)) {
520
+ return `你已经投过「${LETTERS[idx]}. ${v.options[idx]}」了(不过还能投别的选项)`;
521
+ }
522
+ const mine = choicesOf(v, openid);
523
+ mine.push(idx);
524
+ v.votes[openid] = mine;
525
+ // ★ 记下这个人叫什么(结算表里要写"谁投了什么")
526
+ v.names = { ...(v.names ?? {}), [openid]: name };
527
+ saveAll(ctx, all);
528
+
529
+ const cnt = Object.values(v.votes).filter((c) => (Array.isArray(c) ? c : [c]).includes(idx)).length;
530
+ ctx.log?.(`[vote] ${v.id} by=${who} → ${LETTERS[idx]}(该项第 ${cnt} 票)`);
531
+ // ★ 中途回执只报这一票(主人要求:别甩整张表,完整结算只在结束时给)
532
+ return `✅ ${name} 「${LETTERS[idx]}. ${v.options[idx]}」+1 票(当前 ${cnt} 票)`;
533
+ },
534
+ };