@zaofan/dsh-qqbot 1.1.6 → 1.2.0

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 (61) hide show
  1. package/README.md +23 -6
  2. package/README_EN.md +10 -0
  3. package/client/qqbot-settings.js +114 -26
  4. package/dist/api/group-admin.d.ts +13 -3
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +49 -5
  7. package/dist/api/group-admin.js.map +1 -1
  8. package/dist/channel-tools.d.ts.map +1 -1
  9. package/dist/channel-tools.js +386 -14
  10. package/dist/channel-tools.js.map +1 -1
  11. package/dist/commands/answer.d.ts +29 -0
  12. package/dist/commands/answer.d.ts.map +1 -0
  13. package/dist/commands/answer.js +29 -0
  14. package/dist/commands/answer.js.map +1 -0
  15. package/dist/commands/index.d.ts.map +1 -1
  16. package/dist/commands/index.js +5 -0
  17. package/dist/commands/index.js.map +1 -1
  18. package/dist/commands/misc.d.ts.map +1 -1
  19. package/dist/commands/misc.js +15 -1
  20. package/dist/commands/misc.js.map +1 -1
  21. package/dist/commands/outmode.d.ts.map +1 -1
  22. package/dist/commands/outmode.js +3 -2
  23. package/dist/commands/outmode.js.map +1 -1
  24. package/dist/config.d.ts +4 -4
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +2 -2
  27. package/dist/config.js.map +1 -1
  28. package/dist/features/broadcast.d.ts.map +1 -1
  29. package/dist/features/broadcast.js +3 -1
  30. package/dist/features/broadcast.js.map +1 -1
  31. package/dist/features/group-hub.d.ts +3 -1
  32. package/dist/features/group-hub.d.ts.map +1 -1
  33. package/dist/features/group-hub.js +5 -3
  34. package/dist/features/group-hub.js.map +1 -1
  35. package/dist/features/group-join-request.d.ts.map +1 -1
  36. package/dist/features/group-join-request.js +6 -5
  37. package/dist/features/group-join-request.js.map +1 -1
  38. package/dist/features/outbound-mode-switch.d.ts +8 -2
  39. package/dist/features/outbound-mode-switch.d.ts.map +1 -1
  40. package/dist/features/outbound-mode-switch.js +2 -2
  41. package/dist/features/outbound-mode-switch.js.map +1 -1
  42. package/dist/features/poll-join-requests.js +2 -2
  43. package/dist/features/poll-join-requests.js.map +1 -1
  44. package/dist/features/qq-user-questions.d.ts +127 -4
  45. package/dist/features/qq-user-questions.d.ts.map +1 -1
  46. package/dist/features/qq-user-questions.js +314 -74
  47. package/dist/features/qq-user-questions.js.map +1 -1
  48. package/dist/gateway/bootstrap.d.ts.map +1 -1
  49. package/dist/gateway/bootstrap.js +17 -0
  50. package/dist/gateway/bootstrap.js.map +1 -1
  51. package/dist/gateway/data-root.d.ts +13 -4
  52. package/dist/gateway/data-root.d.ts.map +1 -1
  53. package/dist/gateway/data-root.js +22 -12
  54. package/dist/gateway/data-root.js.map +1 -1
  55. package/dist/index.js +2 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/transport/outbound.d.ts.map +1 -1
  58. package/dist/transport/outbound.js +60 -5
  59. package/dist/transport/outbound.js.map +1 -1
  60. package/package.json +1 -1
  61. package/settings-host.js +75 -6
@@ -9,11 +9,60 @@
9
9
  * 形状(@deepseek-ai/dsh-user-questions/types):
10
10
  * request = { questions: [{ id, question, detail?, header?, options?:[{label,...}], multiSelect? }], agent?, signal? }
11
11
  * answer = { answers: [{ id, selected: string[], custom? }] }
12
+ *
13
+ * 2026-09-11 增强(主人需求, 本轮新增):
14
+ * ① 按钮 label 带字母前缀(A. / B. / …), 卡片正文列出**全部**选项字母表;
15
+ * ② **文字选项兜底**: 待答期间直接回文字也算回答 ——
16
+ * 回 `A` / `2` → 选第 1 / 第 2 个选项(字母与序号都认);
17
+ * 回 `A,C` / `1 3` / `选 1 3` → 多选;
18
+ * 回选项原文(与某个 label 完全一致) → 选它;
19
+ * 回**其它任意文字** → 作为自由回答(custom)透传给 AI(answers[].custom);
20
+ * ③ 多问题(questions > 1): 依次发卡, 回答时可用 `#2 B` 指定第 2 问;
21
+ * ④ 选项超过按钮上限(QQ 卡片最多 5 行 → 5 个可点按钮)时, 卡片正文照样列全, 靠文字兜底补齐;
22
+ * ⑤ 按钮卡片发送失败时不再放弃提问 —— 退化成纯文字选项清单, pending 保留(文字兜底能收答案)。
12
23
  */
13
24
  import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
14
25
  import type { QQBotSender } from '../transport/outbound-buffer.js';
15
26
  import type { SessionManager } from '../session/index.js';
16
27
  import type { Logger } from '../types.js';
28
+ interface PendingItem {
29
+ qid: string;
30
+ question: string;
31
+ header: string;
32
+ detail: string;
33
+ opts: Array<{
34
+ label: string;
35
+ }>;
36
+ /** 发起者 openid(群=member_openid, c2c=user_openid); 回调/文字兜底都校验, 防止别人代答 */
37
+ ownerId: string;
38
+ /** 是否多选(宿主给的 multiSelect) */
39
+ multiSelect: boolean;
40
+ /** 会话键 `scope:peerId`: 文字兜底据此判断"这条消息是不是在回答本问题" */
41
+ peerKey: string;
42
+ /** 同批问题共享的组(多问题时按组收集全部答案后统一 resolve) */
43
+ group: PendingGroup;
44
+ /** 本问题在该批中的序号(1 起; 文字兜底用 `#2 B` 指定) */
45
+ index: number;
46
+ /** 该批问题总数 */
47
+ total: number;
48
+ deadlineAt: number;
49
+ }
50
+ /** 一批提问(一次 ask_user_question 的全部问题) —— 全部答完/超时后统一 resolve 宿主 */
51
+ interface PendingGroup {
52
+ /** 该批问题的 qid 顺序(resolve 时按此顺序出答案) */
53
+ qids: string[];
54
+ /** qid → 已收答案 */
55
+ answers: Map<string, {
56
+ selected: string[];
57
+ custom?: string;
58
+ }>;
59
+ resolve: (ans: unknown) => void;
60
+ timer: ReturnType<typeof setTimeout>;
61
+ /** 该批占用的 pending key(收尾清理用) */
62
+ keys: string[];
63
+ signal?: AbortSignal;
64
+ onAbort?: () => void;
65
+ }
17
66
  /** Web 浮层看到的待办提问(去敏) */
18
67
  export interface WebPendingQuestion {
19
68
  /** 唯一键 = qid:token(web 结算回传用) */
@@ -26,6 +75,26 @@ export interface WebPendingQuestion {
26
75
  }>;
27
76
  deadlineAt: number;
28
77
  }
78
+ /** 文字兜底的解析结果: 命中选项(可能多选) 或 自由文本 */
79
+ export type ParsedQuestionAnswer = {
80
+ kind: 'option';
81
+ indices: number[];
82
+ labels: string[];
83
+ } | {
84
+ kind: 'custom';
85
+ text: string;
86
+ };
87
+ /**
88
+ * 把一条**文字回复**解析成对某问题的答案(2026-09-11 主人定的文字兜底):
89
+ * - 先去口语前缀(`选`/`选择`/`选:` 等);
90
+ * - 用 `,`/`,`/空格/`、`/`;` 切 token;
91
+ * - token 命中规则: ①单个字母 A-Z → 第 N 个选项 ②纯数字 1-26 → 第 N 个选项
92
+ * ③与该问题某个选项 label 完全相同(去空白/忽略大小写) → 那个选项;
93
+ * - 所有 token 都命中 → option(非多选时只取第一个);
94
+ * - 否则(含"自定义:xxx"这类) → custom, 原文透传给 AI。
95
+ * @returns null = 这条不该当成答案(空文本)
96
+ */
97
+ export declare function parseQuestionAnswer(raw: string, item: Pick<PendingItem, 'opts' | 'multiSelect'>): ParsedQuestionAnswer | null;
29
98
  export declare class QqUserQuestionsController {
30
99
  private readonly manager;
31
100
  private readonly sender;
@@ -34,18 +103,56 @@ export declare class QqUserQuestionsController {
34
103
  private readonly pending;
35
104
  constructor(manager: SessionManager, sender: QQBotSender, logger: Logger, timeoutMsProvider: () => number);
36
105
  /**
37
- * 宿主 user-questions/request 处理器。只 claim"会话可定位 + 第一个问题带选项"的场景;
38
- * 其余(无会话/无选项/多问题) → next() 交回宿主(Web UI 或原样)。
106
+ * 宿主 user-questions/request 处理器。只 claim"会话可定位 + 每个问题都带选项"的场景;
107
+ * 其余(无会话/无选项/选项过多/问题过多) → next() 交回宿主(Web UI 或原样)。
108
+ * 多问题时依次发卡, 回答用 `#2 B` 指定; 全部答完或超时后一起回给宿主。
39
109
  */
40
110
  request(req: {
41
111
  questions?: Array<Record<string, unknown>>;
42
112
  agent?: unknown;
43
113
  signal?: AbortSignal;
44
114
  }, next: () => Promise<unknown>): Promise<unknown>;
115
+ /**
116
+ * 入站文字兜底(2026-09-11 主人需求): 待答提问期间, 发起者发来的文字也算回答 ——
117
+ * 命中选项(字母/序号/选项原文, 支持多选) → 选中结算;
118
+ * 其它文字 → 作为自由回答(custom)透传给 AI。
119
+ * 非本会话/非发起者/斜杠命令 → 不消费(交回正常入站链)。
120
+ * @returns true = 消息已被提问逻辑消费(调用方不要再派发给 agent)
121
+ */
122
+ handleInbound(msg: {
123
+ kind?: string;
124
+ senderId?: string;
125
+ content?: string;
126
+ groupOpenid?: string;
127
+ }, replyTarget: ReplyTarget): Promise<boolean>;
128
+ /**
129
+ * 斜杠命令兜底入口(`/答` / `/ans`, 2026-09-11 主人定): 与 handleInbound 同一套解析与结算,
130
+ * 但**不依赖消息在链上的位置** —— 命令层在 @门控之后、延迟聚合之上就执行完并 ctx.stop。
131
+ * 为什么必须有它: 裸文字作答在群里被 mentionGate 拦下, 私聊/群消息又被 debounce 聚合层
132
+ * 直接吞进 agent(那一层自己调 transport.handleInbound, 绕过 message 处理器), 实测无效;
133
+ * 而以 `/` 开头的消息被 debounce 直放行(见 gateway/debounce.ts), 命令层随即处理。
134
+ * @returns ok=false 时 msg 是给用户看的提示(命令层原样回执)
135
+ */
136
+ answerByText(args: {
137
+ scope: 'c2c' | 'group';
138
+ peerId: string;
139
+ senderId?: string;
140
+ text: string;
141
+ }): {
142
+ ok: boolean;
143
+ msg: string;
144
+ };
45
145
  /** interaction 回调: data = q:<qid>:<token>:<idx> → 按选项 label 组 answer; 仅发起者本人可答 */
46
146
  handleInteraction(event: unknown, replyTarget: ReplyTarget): Promise<boolean>;
47
- /** 结算: timedOut/abort → 空答案(selected:[]); answered → 携带选择 */
48
- private settle;
147
+ /** 回执(QQ 直发, 失败不影响结算) */
148
+ private receipt;
149
+ /** 结算单个问题: 写进组, 该批全部答完 → 一次交回宿主 */
150
+ private settleAnswer;
151
+ /**
152
+ * 该批提问收尾: 清定时器/监听 + 清理未答项的 pending + 按 qid 顺序组装宿主 answer。
153
+ * 超时/取消时未答的问题给空答案(selected: []) —— 与宿主原语义一致。
154
+ */
155
+ private finishGroup;
49
156
  /** Web 浮层拉取待办问题列表 */
50
157
  listPendingForWeb(): WebPendingQuestion[];
51
158
  /** Web 浮层点选项: key + 选项下标 → 与 QQ 按钮同源结算(先到先得) */
@@ -66,4 +173,20 @@ export declare function decideQuestionByWebAny(key: string, optIdx: number): {
66
173
  ns?: string;
67
174
  msg: string;
68
175
  };
176
+ /**
177
+ * 斜杠命令(`/答` / `/ans`)的结算入口: 按实例 ns 找到提问控制器 → 把文字当答案结算(2026-09-11 主人定)。
178
+ * 命令层在 @门控之后、延迟聚合之上执行, 是**唯一可靠**的用户文字作答通道
179
+ * (裸文字在群里被 mentionGate 拦、私聊被 debounce 聚合层直接吞进 agent, 实测无效)。
180
+ */
181
+ export declare function answerPendingQuestionByText(args: {
182
+ ns?: string;
183
+ scope: 'c2c' | 'group';
184
+ peerId: string;
185
+ senderId?: string;
186
+ text: string;
187
+ }): {
188
+ ok: boolean;
189
+ msg: string;
190
+ };
191
+ export {};
69
192
  //# sourceMappingURL=qq-user-questions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"qq-user-questions.d.ts","sourceRoot":"","sources":["../../src/features/qq-user-questions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAmB1C,wBAAwB;AACxB,MAAM,WAAW,kBAAkB;IACjC,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;CACpB;AA0ED,qBAAa,yBAAyB;IAIlC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IANpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;gBAGvC,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,iBAAiB,EAAE,MAAM,MAAM;IAGlD;;;OAGG;IACG,OAAO,CACX,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,EAC1F,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAC3B,OAAO,CAAC,OAAO,CAAC;IAkEnB,kFAAkF;IAC5E,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IAiCnF,6DAA6D;IAC7D,OAAO,CAAC,MAAM;IAWd,qBAAqB;IACrB,iBAAiB,IAAI,kBAAkB,EAAE;IAezC,gDAAgD;IAChD,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE;IAStE,OAAO,IAAI,IAAI;CAGhB;AAKD,wBAAgB,0BAA0B,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,yBAAyB,GAAG,SAAS,GAAG,IAAI,CAGrG;AAED,kBAAkB;AAClB,wBAAgB,0BAA0B,IAAI,KAAK,CAAC,kBAAkB,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAMvF;AAED,cAAc;AACd,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAM7G"}
1
+ {"version":3,"file":"qq-user-questions.d.ts","sourceRoot":"","sources":["../../src/features/qq-user-questions.ts"],"names":[],"mappings":"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"}
@@ -1,30 +1,59 @@
1
1
  const BTN_PREFIX = 'q:';
2
- /** 选项按钮 keyboard(每行1个=竖排, 字宽不截断; QQ 最多5行 → 最多5个选项可见)
2
+ /** 选项字母表(文字兜底按它映射) */
3
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
4
+ /** QQ 卡片按钮上限: 最多 5 行, 每行 1 个选项 → 最多 5 个可点按钮 */
5
+ const CARD_MAX_OPTIONS = 5;
6
+ /** 单个问题的选项上限(超出交回宿主, 不接管) */
7
+ const MAX_OPTIONS = 26;
8
+ /** 单次提问最多接管几个问题(超出交回宿主) */
9
+ const MAX_QUESTIONS = 5;
10
+ /** 选项按钮 keyboard(每行1个=竖排, 字宽不截断; QQ 最多5行 → 最多5个按钮)
11
+ * label 带字母前缀(A. xxx) —— 与文字兜底/卡片正文的字母表对齐。
3
12
  * permission: 群聊限定"发起对话者本人"可点(specify_user_ids=ownerId), 防群里其他人替你选 */
4
- function optionsKeyboard(qid, token, opts, ownerId) {
5
- const maxRows = 5;
6
- const shown = opts.slice(0, maxRows);
13
+ function optionsKeyboard(item, token, ownerId) {
14
+ const shown = item.opts.slice(0, CARD_MAX_OPTIONS);
7
15
  const permission = ownerId
8
16
  ? { type: 0, specify_user_ids: [ownerId] }
9
17
  : { type: 2 };
10
- const rows = shown.map((opt, idx) => ({
11
- buttons: [{
12
- id: `${BTN_PREFIX}${qid}:${token}:${idx}`,
13
- render_data: {
14
- label: String(opt.label ?? '').slice(0, 24),
15
- visited_label: String(opt.label ?? '').slice(0, 24),
16
- style: 1,
17
- },
18
- action: {
19
- type: 1,
20
- permission,
21
- data: `${BTN_PREFIX}${qid}:${token}:${idx}`,
22
- unsupport_tips: '请直接回复选项文字',
23
- },
24
- }],
25
- }));
18
+ const rows = shown.map((opt, idx) => {
19
+ const label = `${LETTERS[idx] ?? String(idx + 1)}. ${String(opt.label ?? '')}`.slice(0, 24);
20
+ return {
21
+ buttons: [{
22
+ id: `${BTN_PREFIX}${item.qid}:${token}:${idx}`,
23
+ render_data: { label, visited_label: label, style: 1 },
24
+ action: {
25
+ type: 1,
26
+ permission,
27
+ data: `${BTN_PREFIX}${item.qid}:${token}:${idx}`,
28
+ unsupport_tips: '按钮不可用时, 发 /答 A (或 /答 你的回答)',
29
+ },
30
+ }],
31
+ };
32
+ });
26
33
  return { content: { rows } };
27
34
  }
35
+ /** 选项字母表(卡片正文用): `A. xxx` 一行一个, 列全 */
36
+ function optionsLines(opts, from = 0) {
37
+ return opts.map((o, i) => `${LETTERS[i] ?? String(i + 1)}. ${String(o?.label ?? '')}`).slice(from);
38
+ }
39
+ /** 卡片/文字提示的正文 */
40
+ function promptOf(item, timeoutMs, forCard) {
41
+ const header = item.header ? `**${item.header}**\n\n` : '';
42
+ const detail = item.detail ? `\n\n${item.detail}` : '';
43
+ const seq = item.total > 1 ? `(第 ${item.index}/${item.total} 问)` : '';
44
+ const ask = `${header}❓ **${item.question}**${seq}${detail}`;
45
+ const list = optionsLines(item.opts).map((l) => ` ${l}`).join('\n');
46
+ const point = forCard && item.opts.length <= CARD_MAX_OPTIONS
47
+ ? `⏱ ${Math.ceil(timeoutMs / 1000)} 秒内点下方按钮回答:`
48
+ : `⏱ ${Math.ceil(timeoutMs / 1000)} 秒内回答:`;
49
+ const tips = [
50
+ `按钮点不动或想自己打字时, 用斜杠回答: \`/答 ${LETTERS[0] ?? 'A'}\`` + (item.multiSelect ? '(多选 `/答 A,C`)' : ''),
51
+ item.total > 1 ? `多个提问并存用 \`/答 #${item.index} 你的选择\`` : '',
52
+ item.opts.length > CARD_MAX_OPTIONS ? `选项超过 ${CARD_MAX_OPTIONS} 个, 第 ${CARD_MAX_OPTIONS + 1} 个起请用 \`/答 字母\` 选择` : '',
53
+ '想自由回答: `/答 你的话`(原样转给 AI)',
54
+ ].filter(Boolean).join(';');
55
+ return `${ask}\n\n${list}\n\n${point}\n(${tips})`;
56
+ }
28
57
  /**
29
58
  * 解析"这张提问卡片该谁回答"(按钮 permission.type=0 + specify_user_ids 显式指定人):
30
59
  * ① 本次提问的 question/header 文本里的 <@openid> —— 工具输入直接写明 id 指定(优先级最高)
@@ -77,6 +106,54 @@ function resolveCardOwner(record, inlineText) {
77
106
  catch { /* 解析失败回落 */ }
78
107
  return record.senderId;
79
108
  }
109
+ /**
110
+ * 把一条**文字回复**解析成对某问题的答案(2026-09-11 主人定的文字兜底):
111
+ * - 先去口语前缀(`选`/`选择`/`选:` 等);
112
+ * - 用 `,`/`,`/空格/`、`/`;` 切 token;
113
+ * - token 命中规则: ①单个字母 A-Z → 第 N 个选项 ②纯数字 1-26 → 第 N 个选项
114
+ * ③与该问题某个选项 label 完全相同(去空白/忽略大小写) → 那个选项;
115
+ * - 所有 token 都命中 → option(非多选时只取第一个);
116
+ * - 否则(含"自定义:xxx"这类) → custom, 原文透传给 AI。
117
+ * @returns null = 这条不该当成答案(空文本)
118
+ */
119
+ export function parseQuestionAnswer(raw, item) {
120
+ const text = String(raw ?? '').trim();
121
+ if (text === '')
122
+ return null;
123
+ const body = text.replace(/^(选|选择|选择:|选:|答|答案[::]?)\s*/u, '').trim();
124
+ const tokens = body.split(/[,,、;;\s]+/u).filter((t) => t !== '');
125
+ if (tokens.length === 0)
126
+ return null;
127
+ const idxOf = (tok) => {
128
+ const up = tok.toUpperCase();
129
+ // ① 单个字母: A=0, B=1 ...
130
+ if (/^[A-Z]$/u.test(up)) {
131
+ const i = LETTERS.indexOf(up);
132
+ return i >= 0 && i < item.opts.length ? i : null;
133
+ }
134
+ // ② 纯数字: 1=0, 2=1 ...
135
+ if (/^\d{1,2}$/u.test(tok)) {
136
+ const n = Number(tok);
137
+ return n >= 1 && n <= item.opts.length ? n - 1 : null;
138
+ }
139
+ // ③ 与选项文字完全相同
140
+ const norm = (s) => s.replace(/\s+/gu, '').toLowerCase();
141
+ const i = item.opts.findIndex((o) => norm(String(o?.label ?? '')) === norm(tok));
142
+ return i >= 0 ? i : null;
143
+ };
144
+ const indices = [];
145
+ for (const tok of tokens) {
146
+ const i = idxOf(tok);
147
+ if (i === null)
148
+ return { kind: 'custom', text }; // 有任何一个 token 不命中 → 整条当自由文本
149
+ if (!indices.includes(i))
150
+ indices.push(i);
151
+ }
152
+ if (indices.length === 0)
153
+ return { kind: 'custom', text };
154
+ const picked = item.multiSelect ? indices : [indices[0]];
155
+ return { kind: 'option', indices: picked, labels: picked.map((i) => String(item.opts[i]?.label ?? '')) };
156
+ }
80
157
  export class QqUserQuestionsController {
81
158
  manager;
82
159
  sender;
@@ -90,73 +167,190 @@ export class QqUserQuestionsController {
90
167
  this.timeoutMsProvider = timeoutMsProvider;
91
168
  }
92
169
  /**
93
- * 宿主 user-questions/request 处理器。只 claim"会话可定位 + 第一个问题带选项"的场景;
94
- * 其余(无会话/无选项/多问题) → next() 交回宿主(Web UI 或原样)。
170
+ * 宿主 user-questions/request 处理器。只 claim"会话可定位 + 每个问题都带选项"的场景;
171
+ * 其余(无会话/无选项/选项过多/问题过多) → next() 交回宿主(Web UI 或原样)。
172
+ * 多问题时依次发卡, 回答用 `#2 B` 指定; 全部答完或超时后一起回给宿主。
95
173
  */
96
174
  async request(req, next) {
97
175
  const record = this.manager.findByAgent(req.agent);
98
176
  if (!record)
99
177
  return next();
100
178
  const questions = Array.isArray(req.questions) ? req.questions : [];
101
- if (questions.length === 0)
179
+ if (questions.length === 0 || questions.length > MAX_QUESTIONS)
102
180
  return next();
103
- const q = questions[0];
104
- const qid = String(q?.id ?? '');
105
- const opts = Array.isArray(q?.options) ? q.options : [];
106
- if (!qid || opts.length === 0 || opts.length > 25)
181
+ const parsed = questions.map((q) => {
182
+ const raw = q;
183
+ return {
184
+ qid: String(raw?.id ?? ''),
185
+ question: String(raw?.question ?? ''),
186
+ header: String(raw?.header ?? ''),
187
+ detail: String(raw?.detail ?? ''),
188
+ multiSelect: raw?.multiSelect === true,
189
+ opts: (Array.isArray(raw?.options) ? raw.options : []).map((o) => ({ label: String(o?.label ?? '') })),
190
+ };
191
+ });
192
+ // 只有"每个问题都有选项且不超过上限"才接管 —— 否则答案形状与宿主期望不符, 交回 Web UI 更稳
193
+ if (parsed.some((q) => q.qid === '' || q.opts.length === 0 || q.opts.length > MAX_OPTIONS))
107
194
  return next();
108
195
  const timeoutMs = Math.max(5000, this.timeoutMsProvider() || 120000);
109
196
  const token = Math.random().toString(36).slice(2, 8);
110
- const key = `${qid}:${token}`;
111
- const headerRaw = String(q?.header ?? '');
112
- const detailRaw = String(q?.detail ?? '');
113
- const questionRaw = String(q?.question ?? '');
197
+ const peerKey = `${record.replyTarget.scope}:${record.replyTarget.targetId}`;
114
198
  // 卡片可点人: 从最近真人消息解析——被点名的最后一人(@)优先, 其次消息发送者壳, 回落会话发起者
115
199
  // (修复: 主人让 bot 问群友时, 卡片应绑被问者而不是 bot/主人, 否则被问者点卡片=无权限)
116
- const owner = resolveCardOwner(record, headerRaw + ' ' + questionRaw);
117
- // 先注册 pending(防按钮回调先于 Promise 建立到达)
200
+ const owner = resolveCardOwner(record, parsed.map((q) => `${q.header} ${q.question}`).join(' '));
201
+ // 先注册 pending(防按钮回调/文字回复先于 Promise 建立到达)
118
202
  let resolver = () => undefined;
119
203
  const answer = new Promise((resolve) => { resolver = resolve; });
120
- const item = {
121
- qid, question: questionRaw, header: headerRaw, detail: detailRaw,
122
- opts: opts.map((o) => ({ label: String(o?.label ?? '') })),
123
- ownerId: owner,
124
- deadlineAt: Date.now() + timeoutMs,
125
- resolve: resolver, timer: setTimeout(() => this.settle(key, true), timeoutMs),
204
+ const group = {
205
+ qids: parsed.map((q) => q.qid),
206
+ answers: new Map(),
207
+ resolve: resolver,
208
+ timer: setTimeout(() => this.finishGroup(group), timeoutMs),
209
+ keys: [],
126
210
  signal: req.signal,
127
211
  };
128
212
  if (req.signal) {
129
- item.onAbort = () => this.settle(key, true);
213
+ group.onAbort = () => this.finishGroup(group);
130
214
  try {
131
- req.signal.addEventListener('abort', item.onAbort, { once: true });
215
+ req.signal.addEventListener('abort', group.onAbort, { once: true });
132
216
  }
133
217
  catch { /* ignore */ }
134
218
  }
135
- this.pending.set(key, item);
136
- const header = headerRaw ? `**${headerRaw}**\n\n` : '';
137
- const detail = detailRaw ? `\n\n${detailRaw}` : '';
138
- const prompt = `${header}❓ **${questionRaw}**${detail}\n\n⏱ ${Math.ceil(timeoutMs / 1000)} 秒内点下方按钮回答:`;
139
- try {
140
- await this.sender.sendMarkdownWithKeyboard(record.replyTarget, prompt, optionsKeyboard(qid, token, opts, owner));
141
- this.logger.info(`QQ question sent: qid=${qid} opts=${opts.length}`);
142
- }
143
- catch (err) {
144
- this.logger.warn(`QQ question card failed: ${err instanceof Error ? err.message : String(err)}`);
145
- // 卡片不可用 → 文本列选项, 不 claim(回复走正常入站, agent 自己取答案)
146
- const lines = [`❓ ${questionRaw}`, '', ...opts.map((o, i) => `${i + 1}. ${o?.label ?? ''}`), '', '(直接回复序号或内容)'];
219
+ const items = parsed.map((q, i) => {
220
+ const key = `${q.qid}:${token}`;
221
+ const item = {
222
+ qid: q.qid, question: q.question, header: q.header, detail: q.detail,
223
+ opts: q.opts, ownerId: owner, multiSelect: q.multiSelect,
224
+ peerKey, group, index: i + 1, total: parsed.length,
225
+ deadlineAt: Date.now() + timeoutMs,
226
+ };
227
+ group.keys.push(key);
228
+ this.pending.set(key, item);
229
+ return item;
230
+ });
231
+ // 依次发卡(多问题 = 多张卡, 各自带序号与字母表)
232
+ for (const item of items) {
147
233
  try {
148
- await this.sender.sendMarkdown(record.replyTarget, lines.join('\n'));
234
+ await this.sender.sendMarkdownWithKeyboard(record.replyTarget, promptOf(item, timeoutMs, true), optionsKeyboard(item, token, owner));
235
+ this.logger.info(`QQ question sent: qid=${item.qid} opts=${item.opts.length} (${item.index}/${item.total})`);
149
236
  }
150
- catch (err2) {
151
- // QQ 文本降级也失败(限频/网络)≠ 提问结束: 保留 pending 供 Web 浮层兜底,
152
- // 不要 settle —— 否则 QQ 没送达时 web 也看不到。
153
- this.logger.error(`QQ question fallback text failed: ${err2 instanceof Error ? err2.message : String(err2)} (qid=${qid}, web 浮层可兜底)`);
154
- return answer;
237
+ catch (err) {
238
+ this.logger.warn(`QQ question card failed: ${err instanceof Error ? err.message : String(err)}`);
239
+ // 卡片不可用 → 发纯文字选项清单; **保留 pending**(文字兜底能收答案, 不再像以前那样直接放弃)
240
+ try {
241
+ await this.sender.sendMarkdown(record.replyTarget, promptOf(item, timeoutMs, false));
242
+ }
243
+ catch (err2) {
244
+ // 连文字都发不出去(限频/网络) ≠ 提问结束: 保留 pending 供 Web 浮层兜底
245
+ this.logger.error(`QQ question fallback text failed: ${err2 instanceof Error ? err2.message : String(err2)} (qid=${item.qid}, web 浮层可兜底)`);
246
+ }
155
247
  }
156
- this.settle(key, true);
157
248
  }
158
249
  return answer;
159
250
  }
251
+ /**
252
+ * 入站文字兜底(2026-09-11 主人需求): 待答提问期间, 发起者发来的文字也算回答 ——
253
+ * 命中选项(字母/序号/选项原文, 支持多选) → 选中结算;
254
+ * 其它文字 → 作为自由回答(custom)透传给 AI。
255
+ * 非本会话/非发起者/斜杠命令 → 不消费(交回正常入站链)。
256
+ * @returns true = 消息已被提问逻辑消费(调用方不要再派发给 agent)
257
+ */
258
+ async handleInbound(msg, replyTarget) {
259
+ const scope = msg.kind === 'group' ? 'group' : 'c2c';
260
+ const peerId = scope === 'group' ? (msg.groupOpenid ?? msg.senderId ?? '') : (msg.senderId ?? '');
261
+ const peerKey = `${scope}:${peerId}`;
262
+ const text = String(msg.content ?? '').trim();
263
+ if (text === '' || text.startsWith('/'))
264
+ return false;
265
+ const items = [...this.pending.values()].filter((p) => p.peerKey === peerKey);
266
+ if (items.length === 0)
267
+ return false;
268
+ // 只认"卡片指定的那个人"的回答(与按钮回调同一判据); 别人说话照常进 agent
269
+ const sender = String(msg.senderId ?? '');
270
+ const owner = items[0].ownerId;
271
+ if (owner && sender && sender !== owner)
272
+ return false;
273
+ // 目标是哪一问: `#2 xxx` 指定第 2 问; 否则取序号最小的未答问题
274
+ let target;
275
+ let body = text;
276
+ const seq = /^#\s*(\d{1,2})\s*([\s\S]*)$/u.exec(text);
277
+ if (seq) {
278
+ const want = Number(seq[1]);
279
+ body = String(seq[2] ?? '').trim();
280
+ target = items.find((p) => p.index === want);
281
+ if (!target)
282
+ return false;
283
+ if (body === '')
284
+ return false;
285
+ }
286
+ else {
287
+ target = items.slice().sort((a, b) => a.index - b.index)[0];
288
+ }
289
+ if (!target)
290
+ return false;
291
+ const parsedAnswer = parseQuestionAnswer(body, target);
292
+ if (!parsedAnswer)
293
+ return false;
294
+ if (parsedAnswer.kind === 'option') {
295
+ await this.receipt(replyTarget, `已选择:${parsedAnswer.labels.join(' / ')}`);
296
+ this.settleAnswer(target, { selected: parsedAnswer.labels });
297
+ }
298
+ else {
299
+ await this.receipt(replyTarget, `已回复:${parsedAnswer.text}`);
300
+ this.settleAnswer(target, { selected: [], custom: parsedAnswer.text });
301
+ }
302
+ return true;
303
+ }
304
+ /**
305
+ * 斜杠命令兜底入口(`/答` / `/ans`, 2026-09-11 主人定): 与 handleInbound 同一套解析与结算,
306
+ * 但**不依赖消息在链上的位置** —— 命令层在 @门控之后、延迟聚合之上就执行完并 ctx.stop。
307
+ * 为什么必须有它: 裸文字作答在群里被 mentionGate 拦下, 私聊/群消息又被 debounce 聚合层
308
+ * 直接吞进 agent(那一层自己调 transport.handleInbound, 绕过 message 处理器), 实测无效;
309
+ * 而以 `/` 开头的消息被 debounce 直放行(见 gateway/debounce.ts), 命令层随即处理。
310
+ * @returns ok=false 时 msg 是给用户看的提示(命令层原样回执)
311
+ */
312
+ answerByText(args) {
313
+ const peerKey = `${args.scope}:${args.peerId}`;
314
+ const raw = String(args.text ?? '').trim();
315
+ if (raw === '')
316
+ return { ok: false, msg: '没看到回答内容' };
317
+ const items = [...this.pending.values()].filter((p) => p.peerKey === peerKey);
318
+ if (items.length === 0)
319
+ return { ok: false, msg: '当前没有待回答的提问(可能已超时或被撤回)。' };
320
+ // 只认卡片指定的那个人(与按钮回调、文字兜底同一判据)
321
+ const sender = String(args.senderId ?? '');
322
+ const owner = items[0].ownerId;
323
+ if (owner && sender && sender !== owner)
324
+ return { ok: false, msg: '这个问题不是问你的哦~' };
325
+ // `#2 xxx` 指定第 2 问; 否则取序号最小的未答项
326
+ let target;
327
+ let body = raw;
328
+ const seq = /^#\s*(\d{1,2})\s*([\s\S]*)$/u.exec(raw);
329
+ if (seq) {
330
+ const want = Number(seq[1]);
331
+ body = String(seq[2] ?? '').trim();
332
+ target = items.find((p) => p.index === want);
333
+ if (!target)
334
+ return { ok: false, msg: `没有第 ${want} 个待答提问(当前共 ${items.length} 个)。` };
335
+ if (body === '')
336
+ return { ok: false, msg: '请在 #N 后面写上你的选择' };
337
+ }
338
+ else {
339
+ target = items.slice().sort((a, b) => a.index - b.index)[0];
340
+ }
341
+ if (!target)
342
+ return { ok: false, msg: '当前没有待回答的提问。' };
343
+ const parsedAnswer = parseQuestionAnswer(body, target);
344
+ if (!parsedAnswer)
345
+ return { ok: false, msg: '没看到回答内容' };
346
+ const head = target.total > 1 ? `第 ${target.index}/${target.total} 问:` : '';
347
+ if (parsedAnswer.kind === 'option') {
348
+ this.settleAnswer(target, { selected: parsedAnswer.labels });
349
+ return { ok: true, msg: `${head}已选择:${parsedAnswer.labels.join(' / ')}` };
350
+ }
351
+ this.settleAnswer(target, { selected: [], custom: parsedAnswer.text });
352
+ return { ok: true, msg: `${head}已回复:${parsedAnswer.text}` };
353
+ }
160
354
  /** interaction 回调: data = q:<qid>:<token>:<idx> → 按选项 label 组 answer; 仅发起者本人可答 */
161
355
  async handleInteraction(event, replyTarget) {
162
356
  const e = event;
@@ -191,23 +385,54 @@ export class QqUserQuestionsController {
191
385
  this.sender.sendMarkdown(replyTarget, `已选择:${label}`).catch(() => undefined);
192
386
  }
193
387
  catch { /* ignore */ }
194
- this.settle(key, false, { answers: [{ id: item.qid, selected: [label] }] });
388
+ this.settleAnswer(item, { selected: [label] });
195
389
  return true;
196
390
  }
197
- /** 结算: timedOut/abort → 空答案(selected:[]); answered → 携带选择 */
198
- settle(key, empty, value) {
199
- const item = this.pending.get(key);
200
- if (!item)
201
- return;
202
- this.pending.delete(key);
203
- clearTimeout(item.timer);
204
- if (item.signal && item.onAbort) {
391
+ /** 回执(QQ 直发, 失败不影响结算) */
392
+ async receipt(replyTarget, text) {
393
+ try {
394
+ await this.sender.sendMarkdown(replyTarget, text);
395
+ }
396
+ catch { /* ignore */ }
397
+ }
398
+ /** 结算单个问题: 写进组, 该批全部答完 → 一次交回宿主 */
399
+ settleAnswer(item, ans) {
400
+ for (const [k, v] of this.pending) {
401
+ if (v === item) {
402
+ this.pending.delete(k);
403
+ break;
404
+ }
405
+ }
406
+ const group = item.group;
407
+ group.answers.set(item.qid, ans);
408
+ if (group.answers.size >= group.qids.length)
409
+ this.finishGroup(group);
410
+ }
411
+ /**
412
+ * 该批提问收尾: 清定时器/监听 + 清理未答项的 pending + 按 qid 顺序组装宿主 answer。
413
+ * 超时/取消时未答的问题给空答案(selected: []) —— 与宿主原语义一致。
414
+ */
415
+ finishGroup(group) {
416
+ clearTimeout(group.timer);
417
+ if (group.signal && group.onAbort) {
205
418
  try {
206
- item.signal.removeEventListener('abort', item.onAbort);
419
+ group.signal.removeEventListener('abort', group.onAbort);
207
420
  }
208
421
  catch { /* ignore */ }
209
422
  }
210
- item.resolve(empty ? { answers: [{ id: item.qid, selected: [] }] } : value);
423
+ for (const [k, v] of [...this.pending]) {
424
+ if (v.group === group)
425
+ this.pending.delete(k);
426
+ }
427
+ const answers = group.qids.map((qid) => {
428
+ const a = group.answers.get(qid);
429
+ if (!a)
430
+ return { id: qid, selected: [] };
431
+ return a.custom === undefined
432
+ ? { id: qid, selected: a.selected }
433
+ : { id: qid, selected: a.selected, custom: a.custom };
434
+ });
435
+ group.resolve({ answers });
211
436
  }
212
437
  /** Web 浮层拉取待办问题列表 */
213
438
  listPendingForWeb() {
@@ -232,12 +457,13 @@ export class QqUserQuestionsController {
232
457
  const label = item.opts[optIdx]?.label;
233
458
  if (!label)
234
459
  return { ok: false, msg: '无效选项' };
235
- this.settle(key, false, { answers: [{ id: item.qid, selected: [label] }] });
460
+ this.settleAnswer(item, { selected: [label] });
236
461
  return { ok: true, msg: `已选择:${label}` };
237
462
  }
238
463
  dispose() {
239
- for (const key of [...this.pending.keys()])
240
- this.settle(key, true);
464
+ for (const group of new Set([...this.pending.values()].map((p) => p.group))) {
465
+ this.finishGroup(group);
466
+ }
241
467
  }
242
468
  }
243
469
  // ── 按实例(ns)注册的提问控制器注册表 —— Web 提问浮层(settings-host 同源路由)经此读写 ──
@@ -266,4 +492,18 @@ export function decideQuestionByWebAny(key, optIdx) {
266
492
  }
267
493
  return { ok: false, msg: '该问题不存在或已过期' };
268
494
  }
495
+ /**
496
+ * 斜杠命令(`/答` / `/ans`)的结算入口: 按实例 ns 找到提问控制器 → 把文字当答案结算(2026-09-11 主人定)。
497
+ * 命令层在 @门控之后、延迟聚合之上执行, 是**唯一可靠**的用户文字作答通道
498
+ * (裸文字在群里被 mentionGate 拦、私聊被 debounce 聚合层直接吞进 agent, 实测无效)。
499
+ */
500
+ export function answerPendingQuestionByText(args) {
501
+ const ns = String(args.ns ?? '').trim();
502
+ const c = ns !== ''
503
+ ? questionControllers.get(ns)
504
+ : (questionControllers.size === 1 ? [...questionControllers.values()][0] : undefined);
505
+ if (!c)
506
+ return { ok: false, msg: `提问控制器未注册(ns=${ns || '(未指定)'})` };
507
+ return c.answerByText({ scope: args.scope, peerId: args.peerId, senderId: args.senderId, text: args.text });
508
+ }
269
509
  //# sourceMappingURL=qq-user-questions.js.map