@zaofan/dsh-qqbot 1.1.7 → 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.
- package/README.md +10 -4
- package/README_EN.md +1 -0
- package/client/qqbot-settings.js +114 -26
- package/dist/api/group-admin.d.ts +13 -3
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +49 -5
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +386 -14
- package/dist/channel-tools.js.map +1 -1
- package/dist/commands/answer.d.ts +29 -0
- package/dist/commands/answer.d.ts.map +1 -0
- package/dist/commands/answer.js +29 -0
- package/dist/commands/answer.js.map +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +5 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +15 -1
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/outmode.d.ts.map +1 -1
- package/dist/commands/outmode.js +3 -2
- package/dist/commands/outmode.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/features/broadcast.d.ts.map +1 -1
- package/dist/features/broadcast.js +3 -1
- package/dist/features/broadcast.js.map +1 -1
- package/dist/features/group-hub.d.ts +3 -1
- package/dist/features/group-hub.d.ts.map +1 -1
- package/dist/features/group-hub.js +5 -3
- package/dist/features/group-hub.js.map +1 -1
- package/dist/features/group-join-request.d.ts.map +1 -1
- package/dist/features/group-join-request.js +6 -5
- package/dist/features/group-join-request.js.map +1 -1
- package/dist/features/outbound-mode-switch.d.ts +8 -2
- package/dist/features/outbound-mode-switch.d.ts.map +1 -1
- package/dist/features/outbound-mode-switch.js +2 -2
- package/dist/features/outbound-mode-switch.js.map +1 -1
- package/dist/features/poll-join-requests.js +2 -2
- package/dist/features/poll-join-requests.js.map +1 -1
- package/dist/features/qq-user-questions.d.ts +127 -4
- package/dist/features/qq-user-questions.d.ts.map +1 -1
- package/dist/features/qq-user-questions.js +314 -74
- package/dist/features/qq-user-questions.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +17 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/data-root.d.ts +13 -4
- package/dist/gateway/data-root.d.ts.map +1 -1
- package/dist/gateway/data-root.js +22 -12
- package/dist/gateway/data-root.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +60 -5
- package/dist/transport/outbound.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
* 其余(
|
|
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
|
-
/**
|
|
48
|
-
private
|
|
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
|
|
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
|
-
/**
|
|
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(
|
|
5
|
-
const
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
* 其余(
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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,
|
|
117
|
-
// 先注册 pending(
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
213
|
+
group.onAbort = () => this.finishGroup(group);
|
|
130
214
|
try {
|
|
131
|
-
req.signal.addEventListener('abort',
|
|
215
|
+
req.signal.addEventListener('abort', group.onAbort, { once: true });
|
|
132
216
|
}
|
|
133
217
|
catch { /* ignore */ }
|
|
134
218
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
this.
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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 (
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
388
|
+
this.settleAnswer(item, { selected: [label] });
|
|
195
389
|
return true;
|
|
196
390
|
}
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
419
|
+
group.signal.removeEventListener('abort', group.onAbort);
|
|
207
420
|
}
|
|
208
421
|
catch { /* ignore */ }
|
|
209
422
|
}
|
|
210
|
-
|
|
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.
|
|
460
|
+
this.settleAnswer(item, { selected: [label] });
|
|
236
461
|
return { ok: true, msg: `已选择:${label}` };
|
|
237
462
|
}
|
|
238
463
|
dispose() {
|
|
239
|
-
for (const
|
|
240
|
-
this.
|
|
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
|