@jerryliang122/openclaw-qqbot 1.0.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/LICENSE +22 -0
- package/README.md +967 -0
- package/README.zh.md +790 -0
- package/dist/index.cjs +18072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1222 -0
- package/index.ts +92 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +69 -0
- package/preload.cjs +19 -0
- package/scripts/link-sdk-core.cjs +268 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/skills/qqbot-channel/SKILL.md +285 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/skills/qqbot-upgrade/SKILL.md +56 -0
- package/src/adapter/contract.ts +63 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +40 -0
- package/src/adapter/pairing.ts +95 -0
- package/src/adapter/resolve.ts +255 -0
- package/src/adapter/setup.ts +13 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +21 -0
- package/src/agent-prompt-adapter.ts +26 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +230 -0
- package/src/commands/bot-approve.ts +143 -0
- package/src/commands/bot-clear-storage.ts +114 -0
- package/src/commands/bot-group-always.ts +62 -0
- package/src/commands/bot-group-info.ts +48 -0
- package/src/commands/bot-help.ts +40 -0
- package/src/commands/bot-logs.ts +248 -0
- package/src/commands/bot-me.ts +18 -0
- package/src/commands/bot-pairing.ts +50 -0
- package/src/commands/bot-ping.ts +33 -0
- package/src/commands/bot-streaming.ts +55 -0
- package/src/commands/bot-upgrade.ts +56 -0
- package/src/commands/bot-version.ts +41 -0
- package/src/commands/config-util.ts +96 -0
- package/src/commands/index.ts +51 -0
- package/src/config.ts +403 -0
- package/src/constants.ts +6 -0
- package/src/dispatch/body-assembler.ts +308 -0
- package/src/dispatch/ctx-builder.ts +127 -0
- package/src/dispatch/dispatch.ts +667 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/features/approval-capability.ts +302 -0
- package/src/features/approval-helpers.ts +271 -0
- package/src/features/approval-utils.ts +21 -0
- package/src/features/command-panel.ts +301 -0
- package/src/features/credential-backup.ts +74 -0
- package/src/features/group-mode-store.ts +79 -0
- package/src/features/history-store.ts +75 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/outbound-echo-store.ts +46 -0
- package/src/features/proactive-budget.ts +57 -0
- package/src/features/proactive.ts +549 -0
- package/src/features/question-helpers.ts +771 -0
- package/src/features/quota-manager.ts +173 -0
- package/src/features/ref-index-store.ts +289 -0
- package/src/features/secret-input-store.ts +118 -0
- package/src/features/secret-store-cli.ts +324 -0
- package/src/features/typing-refresh.ts +51 -0
- package/src/features/update-checker.ts +166 -0
- package/src/gateway/event-handlers.ts +456 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +236 -0
- package/src/gateway/middleware-setup.ts +173 -0
- package/src/gateway/qqbot-gateway.ts +458 -0
- package/src/gateway-adapter.ts +44 -0
- package/src/heartbeat-adapter.ts +57 -0
- package/src/message-adapter.ts +40 -0
- package/src/messaging-adapter.ts +78 -0
- package/src/middleware/access-control.ts +125 -0
- package/src/middleware/attachment.ts +373 -0
- package/src/middleware/inbound-guard.ts +102 -0
- package/src/middleware/policy-injector.ts +71 -0
- package/src/middleware/secret-capture.ts +161 -0
- package/src/middleware/typing.ts +110 -0
- package/src/openclaw-plugin-sdk.d.ts +543 -0
- package/src/outbound/chunker.ts +80 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +408 -0
- package/src/outbound/outbound-service.ts +298 -0
- package/src/outbound/reply-limiter.ts +139 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +332 -0
- package/src/outbound/target.ts +109 -0
- package/src/outbound-adapter.ts +323 -0
- package/src/plugin-base.ts +42 -0
- package/src/request-context.ts +50 -0
- package/src/runtime.ts +42 -0
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +110 -0
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +40 -0
- package/src/status-adapter.ts +56 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +308 -0
- package/src/tools/secret-input.ts +185 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types-plugin.ts +82 -0
- package/src/types.ts +620 -0
- package/src/typing-lifecycle.ts +182 -0
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +23 -0
- package/src/utils/platform.ts +459 -0
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +132 -0
- package/src/utils/stt.ts +150 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +64 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user 按钮辅助函数
|
|
3
|
+
*
|
|
4
|
+
* 与 approval-helpers.ts 模式一致:
|
|
5
|
+
* - buildQuestionKeyboard: 构建 inline keyboard(单问题)
|
|
6
|
+
* - buildMultiQuestionKeyboard: 构建多问题场景单题的 inline keyboard
|
|
7
|
+
* - parseQuestionButtonData / parseMultiQuestionButtonData: 解析 INTERACTION_CREATE 的 button_data
|
|
8
|
+
* - isAskUserPayload / isNonSingleAskUserPayload: 判断 payload 是否为 ask_user(单/非单问题)
|
|
9
|
+
* - parseMultiQuestionPrompt: 从多问题投递文本反解题目结构
|
|
10
|
+
* - registerPendingMultiQuestion 等: 多问题答案暂存 store
|
|
11
|
+
* - buildMultiQuestionConfirmKeyboard 等: 集齐后发"确认卡"——指令按钮
|
|
12
|
+
* (type=2, enter=true) 携带完整答案文本,由客户端以真实用户消息发出,
|
|
13
|
+
* 走框架原生的 keyed 文本认领通道(不程序化提交,不合成入站消息)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { InlineKeyboard, KeyboardButton } from '../types.js';
|
|
17
|
+
|
|
18
|
+
// ============ Constants ============
|
|
19
|
+
|
|
20
|
+
const QUESTION_CALLBACK_PREFIX = 'qqbot:q:';
|
|
21
|
+
const MULTI_QUESTION_CALLBACK_PREFIX = 'qqbot:qm:';
|
|
22
|
+
|
|
23
|
+
/** questionId 格式: ask_<32位hex> */
|
|
24
|
+
const QUESTION_ID_PATTERN = /^ask_[a-f0-9]{32}$/;
|
|
25
|
+
|
|
26
|
+
/** gateway 协议上限:单次 ask_user 最多 3 个问题,每题最多 4 个选项 */
|
|
27
|
+
const MAX_MULTI_QUESTIONS = 3;
|
|
28
|
+
const MAX_OPTIONS_PER_QUESTION = 4;
|
|
29
|
+
|
|
30
|
+
/** gateway 默认问题超时 15min,这里留 10min 余量兜住投递延迟 */
|
|
31
|
+
const DEFAULT_MULTI_QUESTION_TTL_MS = 25 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
// ============ Types ============
|
|
34
|
+
|
|
35
|
+
export interface ParsedQuestionAction {
|
|
36
|
+
questionId: string;
|
|
37
|
+
optionIndex: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ask_user 多问题场景中从投递文本解析出的单题结构。
|
|
42
|
+
* 框架的多问题 payload 只有 questionId + 格式化文本(无结构化选项数据),
|
|
43
|
+
* 题目结构需从文本反解。
|
|
44
|
+
*/
|
|
45
|
+
export interface MultiQuestionDef {
|
|
46
|
+
header: string;
|
|
47
|
+
question: string;
|
|
48
|
+
options: string[];
|
|
49
|
+
/** 题目声明了 "Other: reply with your own answer."(允许自由文本作答) */
|
|
50
|
+
isOther?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ParsedMultiQuestionAction {
|
|
54
|
+
questionId: string;
|
|
55
|
+
questionIndex: number;
|
|
56
|
+
optionIndex: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 一次多问题整单的答案(题号 -> 按钮选项序号或自由文本) */
|
|
60
|
+
export type MultiQuestionAnswer = { optionIndex: number } | { text: string };
|
|
61
|
+
|
|
62
|
+
export interface QuestionGatewayRuntime {
|
|
63
|
+
resolveOption: (params: {
|
|
64
|
+
cfg: unknown;
|
|
65
|
+
questionId: string;
|
|
66
|
+
optionIndex: number;
|
|
67
|
+
senderId?: string;
|
|
68
|
+
gatewayUrl?: string;
|
|
69
|
+
clientDisplayName?: string;
|
|
70
|
+
}) => Promise<{ status: string }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let questionRuntimePromise: Promise<QuestionGatewayRuntime> | undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* question gateway runtime(openclaw/plugin-sdk/question-gateway-runtime,
|
|
77
|
+
* 2026.8.1+ 稳定导出,peer 已要求 >=2026.9.2)。
|
|
78
|
+
*/
|
|
79
|
+
export function getQuestionGatewayRuntime(): Promise<QuestionGatewayRuntime> {
|
|
80
|
+
questionRuntimePromise ??= import('openclaw/plugin-sdk/question-gateway-runtime')
|
|
81
|
+
.then((mod) => mod.questionGatewayRuntime as QuestionGatewayRuntime);
|
|
82
|
+
return questionRuntimePromise;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============ Payload Detection ============
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 判断 DeliverPayload 是否为 ask_user 单问题单选场景。
|
|
89
|
+
* 要求:有 channelData.askUser.questionId,且 optionValues 长度 2-4。
|
|
90
|
+
*/
|
|
91
|
+
export function isAskUserPayload(payload: {
|
|
92
|
+
channelData?: { askUser?: { questionId?: unknown; optionValues?: unknown } };
|
|
93
|
+
}): payload is { channelData: { askUser: { questionId: string; optionValues: string[] } } } {
|
|
94
|
+
const askUser = payload.channelData?.askUser;
|
|
95
|
+
if (!askUser || typeof askUser !== 'object') return false;
|
|
96
|
+
const { questionId, optionValues } = askUser as { questionId?: unknown; optionValues?: unknown };
|
|
97
|
+
return (
|
|
98
|
+
typeof questionId === 'string' &&
|
|
99
|
+
QUESTION_ID_PATTERN.test(questionId) &&
|
|
100
|
+
Array.isArray(optionValues) &&
|
|
101
|
+
optionValues.length >= 2 &&
|
|
102
|
+
optionValues.length <= 4 &&
|
|
103
|
+
optionValues.every((v: unknown) => typeof v === 'string')
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 判断 DeliverPayload 是否为 ask_user「非单问题」场景。
|
|
109
|
+
*
|
|
110
|
+
* 框架只在恰好 1 个问题(且单选、非 secret、2-4 个唯一选项)时附带
|
|
111
|
+
* optionValues;其余情况 payload 只有 questionId + 纯文本。多问题结构
|
|
112
|
+
* 需要调用 parseMultiQuestionPrompt 从文本反解。
|
|
113
|
+
*/
|
|
114
|
+
export function isNonSingleAskUserPayload(payload: {
|
|
115
|
+
channelData?: { askUser?: { questionId?: unknown; optionValues?: unknown } };
|
|
116
|
+
}): payload is { channelData: { askUser: { questionId: string } } } {
|
|
117
|
+
const askUser = payload.channelData?.askUser;
|
|
118
|
+
if (!askUser || typeof askUser !== 'object') return false;
|
|
119
|
+
const { questionId, optionValues } = askUser as { questionId?: unknown; optionValues?: unknown };
|
|
120
|
+
if (typeof questionId !== 'string' || !QUESTION_ID_PATTERN.test(questionId)) return false;
|
|
121
|
+
// 单问题场景由 isAskUserPayload 优先认领
|
|
122
|
+
return !Array.isArray(optionValues);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============ 多问题投递文本解析 ============
|
|
126
|
+
|
|
127
|
+
const NUMBERED_LINE_PATTERN = /^(\d+)\.\s+(.+)$/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 从 ask_user 多问题投递文本中解析题目结构。
|
|
131
|
+
*
|
|
132
|
+
* 文本由框架 formatAgentHarnessUserInputPrompt 生成(多问题格式):
|
|
133
|
+
* ```
|
|
134
|
+
* [intro]
|
|
135
|
+
* <空行>
|
|
136
|
+
* 1. <header>
|
|
137
|
+
* <题干>
|
|
138
|
+
* 1. <label> - <description>
|
|
139
|
+
* ...
|
|
140
|
+
* <空行>
|
|
141
|
+
* 2. <header>
|
|
142
|
+
* ...
|
|
143
|
+
* <空行>
|
|
144
|
+
* Reply by number or question id...
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* 判别依赖生成器的两个不变量:
|
|
148
|
+
* - header 行总是紧跟空行之后(每个 question 前都有空行)
|
|
149
|
+
* - 选项行紧跟题干/上一选项(块内无空行)
|
|
150
|
+
*
|
|
151
|
+
* 解析失败返回 null(secret 警告行 / isOther 行 / 结构异常都会失败),
|
|
152
|
+
* 调用方回退纯文本展示。multiSelect 题无法从文本区分,按钮单选属可接受降级。
|
|
153
|
+
*/
|
|
154
|
+
export function parseMultiQuestionPrompt(text: string): MultiQuestionDef[] | null {
|
|
155
|
+
const lines = text.split(/\r?\n/);
|
|
156
|
+
|
|
157
|
+
// 定位第一个 "1. " header(其后必须紧跟空行或行首,排除 intro 干扰)
|
|
158
|
+
let start = -1;
|
|
159
|
+
for (let i = 0; i < lines.length; i++) {
|
|
160
|
+
const m = lines[i]!.match(NUMBERED_LINE_PATTERN);
|
|
161
|
+
if (m && Number(m[1]) === 1 && (i === 0 || !lines[i - 1]!.trim())) {
|
|
162
|
+
start = i;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (start < 0) return null;
|
|
167
|
+
|
|
168
|
+
const questions: MultiQuestionDef[] = [];
|
|
169
|
+
let header: string | undefined;
|
|
170
|
+
let questionText: string | undefined;
|
|
171
|
+
let options: string[] = [];
|
|
172
|
+
let isOther = false;
|
|
173
|
+
let afterBlank = true; // start 位置的 header 已由扫描保证满足"空行在前"
|
|
174
|
+
|
|
175
|
+
const finishQuestion = (): boolean => {
|
|
176
|
+
if (header === undefined || questionText === undefined) return false;
|
|
177
|
+
if (options.length < 2 || options.length > MAX_OPTIONS_PER_QUESTION) return false;
|
|
178
|
+
questions.push({ header, question: questionText, options, ...(isOther ? { isOther: true } : {}) });
|
|
179
|
+
header = undefined;
|
|
180
|
+
questionText = undefined;
|
|
181
|
+
options = [];
|
|
182
|
+
isOther = false;
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (let i = start; i < lines.length; i++) {
|
|
187
|
+
const trimmed = lines[i]!.trim();
|
|
188
|
+
if (!trimmed) {
|
|
189
|
+
afterBlank = true;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const m = trimmed.match(NUMBERED_LINE_PATTERN);
|
|
193
|
+
const num = m ? Number(m[1]) : NaN;
|
|
194
|
+
|
|
195
|
+
if (header === undefined) {
|
|
196
|
+
// 期待下一题 header:序号连续且前面是空行
|
|
197
|
+
if (!m || num !== questions.length + 1 || !afterBlank) return null;
|
|
198
|
+
header = m[2];
|
|
199
|
+
afterBlank = false;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (questionText === undefined) {
|
|
203
|
+
// 期待题干:编号行说明结构异常
|
|
204
|
+
if (m) return null;
|
|
205
|
+
questionText = trimmed;
|
|
206
|
+
afterBlank = false;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// 选项后出现 isOther 声明行(框架默认文案 "Other: reply with your own answer.")
|
|
210
|
+
if (trimmed.startsWith('Other:') && options.length >= 2) {
|
|
211
|
+
isOther = true;
|
|
212
|
+
afterBlank = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// 期待选项 / 下一题 header / 终止(引导语等尾部内容)
|
|
216
|
+
if (m && num === 1 && options.length === 0 && !afterBlank) {
|
|
217
|
+
options.push(m[2]);
|
|
218
|
+
afterBlank = false;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
m &&
|
|
223
|
+
num === options.length + 1 &&
|
|
224
|
+
options.length > 0 &&
|
|
225
|
+
options.length < MAX_OPTIONS_PER_QUESTION &&
|
|
226
|
+
!afterBlank
|
|
227
|
+
) {
|
|
228
|
+
options.push(m[2]);
|
|
229
|
+
afterBlank = false;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// 当前题尚未入列,下一题 header 的序号 = 已入列数 + 当前题 + 1
|
|
233
|
+
if (m && num === questions.length + 2 && afterBlank) {
|
|
234
|
+
if (!finishQuestion()) return null;
|
|
235
|
+
header = m[2];
|
|
236
|
+
afterBlank = false;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// 其余内容:收尾并停止(正常情况是尾部引导语)
|
|
240
|
+
if (!finishQuestion()) return null;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (header !== undefined || questionText !== undefined || options.length > 0) {
|
|
244
|
+
// 循环耗尽时仍有未收尾的题目
|
|
245
|
+
if (!finishQuestion()) return null;
|
|
246
|
+
}
|
|
247
|
+
if (questions.length < 2 || questions.length > MAX_MULTI_QUESTIONS) return null;
|
|
248
|
+
return questions;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============ Keyboard Builder ============
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 为 ask_user 构建 inline keyboard。
|
|
255
|
+
*
|
|
256
|
+
* 与审批按钮结构完全一致:
|
|
257
|
+
* - action.type=1 (回调型)
|
|
258
|
+
* - permission.type=2 (所有人可点)
|
|
259
|
+
* - click_limit=1 (每人一次)
|
|
260
|
+
* - group_id="question" (互斥)
|
|
261
|
+
*
|
|
262
|
+
* button_data 格式: `qqbot:q:<questionId>:<optionIndex>`
|
|
263
|
+
*/
|
|
264
|
+
export function buildQuestionKeyboard(
|
|
265
|
+
questionId: string,
|
|
266
|
+
optionLabels: string[],
|
|
267
|
+
): InlineKeyboard {
|
|
268
|
+
const buttons: KeyboardButton[] = optionLabels.map((label, index) => ({
|
|
269
|
+
id: `q${index}`,
|
|
270
|
+
render_data: {
|
|
271
|
+
label,
|
|
272
|
+
visited_label: '已回答',
|
|
273
|
+
style: 1 as const,
|
|
274
|
+
},
|
|
275
|
+
action: {
|
|
276
|
+
type: 1 as const,
|
|
277
|
+
data: `${QUESTION_CALLBACK_PREFIX}${questionId}:${index}`,
|
|
278
|
+
permission: { type: 2 as const },
|
|
279
|
+
click_limit: 1,
|
|
280
|
+
},
|
|
281
|
+
group_id: 'question',
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
content: {
|
|
286
|
+
rows: [{ buttons }],
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 选项按钮的序号前缀,与正文里的加粗序号 1./2./3./4. 一一对应 */
|
|
292
|
+
const OPTION_NUMBER_PREFIXES = ['①', '②', '③', '④'] as const;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 为 ask_user 多问题场景构建第 questionIndex 题的 inline keyboard。
|
|
296
|
+
*
|
|
297
|
+
* - 选项按钮:action.type=1(回调),button_data `qqbot:qm:<id>:<题号>:<选项号>`,
|
|
298
|
+
* group_id 按题隔离(`question-<i>`)保证同题互斥、跨题不互斥;
|
|
299
|
+
* 标签带 ①②③ 序号前缀(截断时也能与正文选项对上号)
|
|
300
|
+
* - isOther 题追加「✍️ 其他」指令按钮(action.type=2):点击后客户端在
|
|
301
|
+
* 输入框预填 `<题号>: `,用户续写自由答案后发送,天然就是 keyed 答案格式
|
|
302
|
+
* - 按钮标签超过宽度预算时自动改为每行 2 个,避免文字显示不全
|
|
303
|
+
*/
|
|
304
|
+
export function buildMultiQuestionKeyboard(
|
|
305
|
+
questionId: string,
|
|
306
|
+
questionIndex: number,
|
|
307
|
+
question: MultiQuestionDef,
|
|
308
|
+
): InlineKeyboard {
|
|
309
|
+
const buttons: KeyboardButton[] = question.options.map((optionLine, optionIndex) => {
|
|
310
|
+
const prefix = OPTION_NUMBER_PREFIXES[optionIndex] ?? '';
|
|
311
|
+
// 前缀 + 空格的显示宽度(全角序号 2 + 空格 1)
|
|
312
|
+
const reserve = estimateLabelWidth(`${prefix} `);
|
|
313
|
+
return {
|
|
314
|
+
id: `q${questionIndex}_${optionIndex}`,
|
|
315
|
+
render_data: {
|
|
316
|
+
label: `${prefix} ${buildButtonLabel(optionLine, reserve)}`,
|
|
317
|
+
visited_label: '已选',
|
|
318
|
+
style: 1 as const,
|
|
319
|
+
},
|
|
320
|
+
action: {
|
|
321
|
+
type: 1 as const,
|
|
322
|
+
data: `${MULTI_QUESTION_CALLBACK_PREFIX}${questionId}:${questionIndex}:${optionIndex}`,
|
|
323
|
+
permission: { type: 2 as const },
|
|
324
|
+
click_limit: 1,
|
|
325
|
+
},
|
|
326
|
+
group_id: `question-${questionIndex}`,
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (question.isOther) {
|
|
331
|
+
buttons.push({
|
|
332
|
+
id: `q${questionIndex}_other`,
|
|
333
|
+
render_data: {
|
|
334
|
+
label: '✍️ 其他',
|
|
335
|
+
visited_label: '✍️ 其他',
|
|
336
|
+
style: 0 as const,
|
|
337
|
+
},
|
|
338
|
+
action: {
|
|
339
|
+
type: 2 as const,
|
|
340
|
+
// 点击后输入框预填 "题号: ",用户续写后手动发送
|
|
341
|
+
data: `${questionIndex + 1}: `,
|
|
342
|
+
enter: false,
|
|
343
|
+
permission: { type: 2 as const },
|
|
344
|
+
unsupport_tips: '当前客户端版本不支持,请直接回复「题号: 答案」',
|
|
345
|
+
},
|
|
346
|
+
// 指令按钮不属于互斥选项组
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
content: {
|
|
352
|
+
rows: layoutButtonRows(buttons),
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 自适应分行:标签全部较短时一行放满,否则每行 2 个 */
|
|
358
|
+
function layoutButtonRows(buttons: KeyboardButton[]): Array<{ buttons: KeyboardButton[] }> {
|
|
359
|
+
const widest = buttons.reduce(
|
|
360
|
+
(max, b) => Math.max(max, estimateLabelWidth(b.render_data?.label ?? '')),
|
|
361
|
+
0,
|
|
362
|
+
);
|
|
363
|
+
if (widest <= SINGLE_ROW_MAX_LABEL_WIDTH || buttons.length <= 2) {
|
|
364
|
+
return [{ buttons }];
|
|
365
|
+
}
|
|
366
|
+
const rows: Array<{ buttons: KeyboardButton[] }> = [];
|
|
367
|
+
for (let i = 0; i < buttons.length; i += 2) {
|
|
368
|
+
rows.push({ buttons: buttons.slice(i, i + 2) });
|
|
369
|
+
}
|
|
370
|
+
return rows;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 选项整行形如 "调研/方案 (Recommended) - 搜索、分析、对比、出方案。",
|
|
375
|
+
* 按第一个 " - " 拆出标签部分(拆不准时退回整行,不影响答案正确性——
|
|
376
|
+
* 答案靠题号/选项号定位,不依赖标签文本)。
|
|
377
|
+
*/
|
|
378
|
+
export function splitOptionLabel(optionLine: string): string {
|
|
379
|
+
const sep = optionLine.indexOf(' - ');
|
|
380
|
+
if (sep > 0) return optionLine.slice(0, sep).trim();
|
|
381
|
+
return optionLine.trim();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function splitOptionDescription(optionLine: string): string {
|
|
385
|
+
const label = splitOptionLabel(optionLine);
|
|
386
|
+
if (optionLine.length > label.length && optionLine.startsWith(label)) {
|
|
387
|
+
return optionLine.slice(label.length).replace(/^\s*-\s*/, '').trim();
|
|
388
|
+
}
|
|
389
|
+
return '';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 估算文字显示宽度:CJK/全角/emoji 记 2,其余记 1 */
|
|
393
|
+
export function estimateLabelWidth(text: string): number {
|
|
394
|
+
let width = 0;
|
|
395
|
+
for (const ch of text) {
|
|
396
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
397
|
+
const wide =
|
|
398
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
399
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
400
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
401
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
402
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
403
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
404
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
405
|
+
code >= 0x1f300;
|
|
406
|
+
width += wide ? 2 : 1;
|
|
407
|
+
}
|
|
408
|
+
return width;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 按钮标签安全宽度(约 12 个汉字),超出截断加省略号 */
|
|
412
|
+
const MAX_BUTTON_LABEL_WIDTH = 24;
|
|
413
|
+
/** 一行放满时允许的最大标签宽度,超过改为每行 2 个 */
|
|
414
|
+
const SINGLE_ROW_MAX_LABEL_WIDTH = 20;
|
|
415
|
+
|
|
416
|
+
/** 从选项整行生成按钮短标签:剥描述 → 剥 "(Recommended)" 后缀 → 超宽截断。
|
|
417
|
+
* reserveWidth 为前缀(如选项序号)预留的显示宽度。 */
|
|
418
|
+
export function buildButtonLabel(optionLine: string, reserveWidth = 0): string {
|
|
419
|
+
let label = stripRecommendedSuffix(splitOptionLabel(optionLine));
|
|
420
|
+
const max = MAX_BUTTON_LABEL_WIDTH - reserveWidth;
|
|
421
|
+
if (estimateLabelWidth(label) <= max) return label;
|
|
422
|
+
let truncated = '';
|
|
423
|
+
let width = 0;
|
|
424
|
+
for (const ch of label) {
|
|
425
|
+
const w = estimateLabelWidth(ch);
|
|
426
|
+
if (width + w > max - 2) break;
|
|
427
|
+
truncated += ch;
|
|
428
|
+
width += w;
|
|
429
|
+
}
|
|
430
|
+
return `${truncated.trimEnd()}…`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 剥掉框架渲染在选项标签尾部的 "(Recommended)" 标记 */
|
|
434
|
+
export function stripRecommendedSuffix(label: string): string {
|
|
435
|
+
return label.replace(/\s*\(\s*recommended\s*\)\s*$/i, '').trim();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 格式化多问题卡片中单题的文本部分(QQ 客户端原生 Markdown 渲染)。
|
|
440
|
+
* 完整选项(含描述)放在正文里,按钮只放截断后的短标签。
|
|
441
|
+
* 正文选项用加粗序号,与按钮上的 ①②③ 前缀一一对应。
|
|
442
|
+
*/
|
|
443
|
+
export function formatMultiQuestionCard(
|
|
444
|
+
question: MultiQuestionDef,
|
|
445
|
+
questionIndex: number,
|
|
446
|
+
total: number,
|
|
447
|
+
): string {
|
|
448
|
+
const lines = [`**第 ${questionIndex + 1}/${total} 题 · ${question.header}**`];
|
|
449
|
+
if (question.question.trim()) {
|
|
450
|
+
lines.push('', question.question.trim());
|
|
451
|
+
}
|
|
452
|
+
for (const [optionIndex, optionLine] of question.options.entries()) {
|
|
453
|
+
const label = stripRecommendedSuffix(splitOptionLabel(optionLine));
|
|
454
|
+
const desc = splitOptionDescription(optionLine);
|
|
455
|
+
lines.push('', `**${optionIndex + 1}.** ${label}${desc ? ` —— ${desc}` : ''}`);
|
|
456
|
+
}
|
|
457
|
+
lines.push('', '👇 点下方按钮作答');
|
|
458
|
+
if (question.isOther) {
|
|
459
|
+
lines.push('', `选项不合适?点「✍️ 其他」后输入,或直接回复 "${questionIndex + 1}: 你的答案"`);
|
|
460
|
+
}
|
|
461
|
+
return lines.join('\n');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============ Button Data Parser ============
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 解析 INTERACTION_CREATE 事件中的 button_data。
|
|
468
|
+
*
|
|
469
|
+
* 格式: `qqbot:q:<questionId>:<optionIndex>`
|
|
470
|
+
* 返回 null 表示不是 question 按钮。
|
|
471
|
+
*/
|
|
472
|
+
export function parseQuestionButtonData(buttonData: string): ParsedQuestionAction | null {
|
|
473
|
+
if (!buttonData.startsWith(QUESTION_CALLBACK_PREFIX)) return null;
|
|
474
|
+
|
|
475
|
+
const payload = buttonData.slice(QUESTION_CALLBACK_PREFIX.length);
|
|
476
|
+
const sepIndex = payload.lastIndexOf(':');
|
|
477
|
+
if (sepIndex <= 0) return null;
|
|
478
|
+
|
|
479
|
+
const questionId = payload.slice(0, sepIndex);
|
|
480
|
+
const indexStr = payload.slice(sepIndex + 1);
|
|
481
|
+
|
|
482
|
+
if (!QUESTION_ID_PATTERN.test(questionId)) return null;
|
|
483
|
+
if (!indexStr) return null; // 防止 'qqbot:q:ask_xxx:' 被解析为 index 0
|
|
484
|
+
|
|
485
|
+
const optionIndex = Number(indexStr);
|
|
486
|
+
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex > 3) return null;
|
|
487
|
+
|
|
488
|
+
return { questionId, optionIndex };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 解析多问题按钮的 button_data。
|
|
493
|
+
*
|
|
494
|
+
* 格式: `qqbot:qm:<questionId>:<questionIndex>:<optionIndex>`
|
|
495
|
+
* 返回 null 表示不是多问题按钮。
|
|
496
|
+
*/
|
|
497
|
+
export function parseMultiQuestionButtonData(buttonData: string): ParsedMultiQuestionAction | null {
|
|
498
|
+
if (!buttonData.startsWith(MULTI_QUESTION_CALLBACK_PREFIX)) return null;
|
|
499
|
+
|
|
500
|
+
const payload = buttonData.slice(MULTI_QUESTION_CALLBACK_PREFIX.length);
|
|
501
|
+
const parts = payload.split(':');
|
|
502
|
+
if (parts.length !== 3) return null;
|
|
503
|
+
|
|
504
|
+
const [questionId, questionIndexStr, optionIndexStr] = parts;
|
|
505
|
+
if (!QUESTION_ID_PATTERN.test(questionId)) return null;
|
|
506
|
+
if (!questionIndexStr || !optionIndexStr) return null;
|
|
507
|
+
|
|
508
|
+
const questionIndex = Number(questionIndexStr);
|
|
509
|
+
const optionIndex = Number(optionIndexStr);
|
|
510
|
+
if (!Number.isInteger(questionIndex) || questionIndex < 0 || questionIndex >= MAX_MULTI_QUESTIONS) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= MAX_OPTIONS_PER_QUESTION) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { questionId, questionIndex, optionIndex };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ============ 多问题答案暂存 Store ============
|
|
521
|
+
|
|
522
|
+
interface PendingMultiQuestion {
|
|
523
|
+
scope: 'c2c' | 'group';
|
|
524
|
+
targetId: string;
|
|
525
|
+
questions: MultiQuestionDef[];
|
|
526
|
+
/** questionIndex -> 答案(同题重复作答,最后一次为准) */
|
|
527
|
+
answers: Map<number, MultiQuestionAnswer>;
|
|
528
|
+
terminal: boolean;
|
|
529
|
+
/** 正在合成入站提交(防并发重复提交) */
|
|
530
|
+
resolving: boolean;
|
|
531
|
+
expiresAtMs: number;
|
|
532
|
+
cleanupTimer: ReturnType<typeof setTimeout>;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const pendingMultiQuestions = new Map<string, PendingMultiQuestion>();
|
|
536
|
+
/** `${scope}:${targetId}` -> questionId,供入站文字答案按会话定位 */
|
|
537
|
+
const pendingByConversation = new Map<string, string>();
|
|
538
|
+
|
|
539
|
+
function conversationKey(scope: 'c2c' | 'group', targetId: string): string {
|
|
540
|
+
return `${scope}:${targetId}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 登记一次多问题 ask_user 投递,供按钮回调 / 入站文字按题缓冲答案。
|
|
545
|
+
* 必须在发送按钮消息之前调用,避免「先点后登记」竞态。
|
|
546
|
+
*/
|
|
547
|
+
export function registerPendingMultiQuestion(
|
|
548
|
+
questionId: string,
|
|
549
|
+
scope: 'c2c' | 'group',
|
|
550
|
+
targetId: string,
|
|
551
|
+
questions: MultiQuestionDef[],
|
|
552
|
+
ttlMs: number = DEFAULT_MULTI_QUESTION_TTL_MS,
|
|
553
|
+
): void {
|
|
554
|
+
const existing = pendingMultiQuestions.get(questionId);
|
|
555
|
+
if (existing) clearTimeout(existing.cleanupTimer);
|
|
556
|
+
|
|
557
|
+
const entry: PendingMultiQuestion = {
|
|
558
|
+
scope,
|
|
559
|
+
targetId,
|
|
560
|
+
questions,
|
|
561
|
+
answers: new Map(),
|
|
562
|
+
terminal: false,
|
|
563
|
+
resolving: false,
|
|
564
|
+
expiresAtMs: Date.now() + ttlMs,
|
|
565
|
+
cleanupTimer: setTimeout(() => {
|
|
566
|
+
if (pendingMultiQuestions.get(questionId) === entry) {
|
|
567
|
+
pendingMultiQuestions.delete(questionId);
|
|
568
|
+
}
|
|
569
|
+
if (pendingByConversation.get(conversationKey(scope, targetId)) === questionId) {
|
|
570
|
+
pendingByConversation.delete(conversationKey(scope, targetId));
|
|
571
|
+
}
|
|
572
|
+
}, ttlMs),
|
|
573
|
+
};
|
|
574
|
+
entry.cleanupTimer.unref?.();
|
|
575
|
+
pendingMultiQuestions.set(questionId, entry);
|
|
576
|
+
pendingByConversation.set(conversationKey(scope, targetId), questionId);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** 按会话查找进行中的多问题单(终态/提交中/已过期不算) */
|
|
580
|
+
export function findPendingMultiQuestionByConversation(
|
|
581
|
+
scope: 'c2c' | 'group',
|
|
582
|
+
targetId: string,
|
|
583
|
+
): { questionId: string } | undefined {
|
|
584
|
+
const questionId = pendingByConversation.get(conversationKey(scope, targetId));
|
|
585
|
+
if (!questionId) return undefined;
|
|
586
|
+
const entry = pendingMultiQuestions.get(questionId);
|
|
587
|
+
if (!entry || entry.terminal || entry.resolving || entry.expiresAtMs <= Date.now()) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
return { questionId };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export type MultiQuestionTapResult =
|
|
594
|
+
| { status: 'unknown' }
|
|
595
|
+
| { status: 'terminal' }
|
|
596
|
+
| { status: 'resolving' }
|
|
597
|
+
| { status: 'nomatch' }
|
|
598
|
+
| {
|
|
599
|
+
status: 'buffered';
|
|
600
|
+
total: number;
|
|
601
|
+
answeredCount: number;
|
|
602
|
+
/** 尚未作答的题目 */
|
|
603
|
+
pendingQuestions: MultiQuestionDef[];
|
|
604
|
+
}
|
|
605
|
+
| {
|
|
606
|
+
status: 'complete';
|
|
607
|
+
total: number;
|
|
608
|
+
/**
|
|
609
|
+
* 题号 -> 答案 的快照(complete 时刻的拷贝)。
|
|
610
|
+
* 最终提交直接走 questionGatewayRuntime.resolveAnswers 整单 resolve,
|
|
611
|
+
* 不再合成入站文本——合成消息会被框架当成 steering 注入挂起的 run,
|
|
612
|
+
* 永远到不了 pending ask_user 的 claim(生产 2026-08-24 事故)。
|
|
613
|
+
*/
|
|
614
|
+
answers: ReadonlyMap<number, MultiQuestionAnswer>;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
function bufferedOrComplete(entry: PendingMultiQuestion): MultiQuestionTapResult {
|
|
618
|
+
if (entry.answers.size < entry.questions.length) {
|
|
619
|
+
const pendingQuestions = entry.questions.filter((_, index) => !entry.answers.has(index));
|
|
620
|
+
return {
|
|
621
|
+
status: 'buffered',
|
|
622
|
+
total: entry.questions.length,
|
|
623
|
+
answeredCount: entry.answers.size,
|
|
624
|
+
pendingQuestions,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
entry.resolving = true;
|
|
628
|
+
return {
|
|
629
|
+
status: 'complete',
|
|
630
|
+
total: entry.questions.length,
|
|
631
|
+
answers: new Map(entry.answers),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 记录一次多问题按钮点选。
|
|
637
|
+
* - 未登记/已过期 -> unknown
|
|
638
|
+
* - 已终态(已提交/已在框架侧结束)-> terminal
|
|
639
|
+
* - 正在提交中 -> resolving
|
|
640
|
+
* - 还有题目未作答 -> buffered(附带剩余题目)
|
|
641
|
+
* - 全部作答 -> complete(同步置 resolving,调用方拿到回复文本后走入站派发)
|
|
642
|
+
*/
|
|
643
|
+
export function recordMultiQuestionTap(
|
|
644
|
+
questionId: string,
|
|
645
|
+
questionIndex: number,
|
|
646
|
+
optionIndex: number,
|
|
647
|
+
): MultiQuestionTapResult {
|
|
648
|
+
const entry = pendingMultiQuestions.get(questionId);
|
|
649
|
+
if (!entry) return { status: 'unknown' };
|
|
650
|
+
if (entry.expiresAtMs <= Date.now()) {
|
|
651
|
+
if (pendingMultiQuestions.get(questionId) === entry) {
|
|
652
|
+
pendingMultiQuestions.delete(questionId);
|
|
653
|
+
}
|
|
654
|
+
clearTimeout(entry.cleanupTimer);
|
|
655
|
+
return { status: 'unknown' };
|
|
656
|
+
}
|
|
657
|
+
if (entry.terminal) return { status: 'terminal' };
|
|
658
|
+
if (entry.resolving) return { status: 'resolving' };
|
|
659
|
+
|
|
660
|
+
const question = entry.questions[questionIndex];
|
|
661
|
+
if (!question) return { status: 'unknown' };
|
|
662
|
+
if (typeof question.options[optionIndex] !== 'string') return { status: 'unknown' };
|
|
663
|
+
|
|
664
|
+
entry.answers.set(questionIndex, { optionIndex });
|
|
665
|
+
return bufferedOrComplete(entry);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** 入站派发已受理(或问题已在框架侧结束)后标记终态,后续点选按 terminal 处理 */
|
|
669
|
+
export function markMultiQuestionResolved(questionId: string): void {
|
|
670
|
+
const entry = pendingMultiQuestions.get(questionId);
|
|
671
|
+
if (entry) {
|
|
672
|
+
entry.terminal = true;
|
|
673
|
+
entry.resolving = false;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 复位 complete 时置上的 resolving 锁(确认卡已发出或发送失败后调用),
|
|
679
|
+
* 允许用户继续点选修改答案并重新拿到确认卡。
|
|
680
|
+
*/
|
|
681
|
+
export function markMultiQuestionResolveFailed(questionId: string): void {
|
|
682
|
+
const entry = pendingMultiQuestions.get(questionId);
|
|
683
|
+
if (entry) {
|
|
684
|
+
entry.resolving = false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 读取登记的题目定义(确认卡正文按题展示已选项) */
|
|
689
|
+
export function getPendingMultiQuestions(questionId: string): MultiQuestionDef[] | null {
|
|
690
|
+
const entry = pendingMultiQuestions.get(questionId);
|
|
691
|
+
if (!entry || entry.expiresAtMs <= Date.now()) return null;
|
|
692
|
+
return entry.questions;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* 合成整单答案文本:"1: 3\n2: 1"(数字选项走框架原生 keyed 文本解析,
|
|
697
|
+
* 由客户端以真实用户消息发出后,框架认领并 resolve 挂起的 ask_user)。
|
|
698
|
+
*/
|
|
699
|
+
export function buildMultiQuestionAnswerText(
|
|
700
|
+
answers: ReadonlyMap<number, MultiQuestionAnswer>,
|
|
701
|
+
): string {
|
|
702
|
+
return [...answers.entries()]
|
|
703
|
+
.sort((a, b) => a[0] - b[0])
|
|
704
|
+
.map(([questionIdx, answer]) =>
|
|
705
|
+
`${questionIdx + 1}: ${'optionIndex' in answer ? answer.optionIndex + 1 : answer.text}`)
|
|
706
|
+
.join('\n');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** 确认卡正文:按题展示人话版的已选内容 */
|
|
710
|
+
export function formatMultiQuestionConfirmCard(
|
|
711
|
+
questions: readonly MultiQuestionDef[],
|
|
712
|
+
answers: ReadonlyMap<number, MultiQuestionAnswer>,
|
|
713
|
+
): string {
|
|
714
|
+
const lines = ['**✅ 答案确认**'];
|
|
715
|
+
for (const [index, question] of questions.entries()) {
|
|
716
|
+
const answer = answers.get(index);
|
|
717
|
+
const display = !answer
|
|
718
|
+
? '(未作答)'
|
|
719
|
+
: 'optionIndex' in answer
|
|
720
|
+
? stripRecommendedSuffix(splitOptionLabel(question.options[answer.optionIndex] ?? ''))
|
|
721
|
+
: answer.text;
|
|
722
|
+
lines.push('', `**${index + 1}.** ${question.header}:${display}`);
|
|
723
|
+
}
|
|
724
|
+
lines.push('', '点「✅ 提交」自动发送;想改动就点「✏️ 改一改」编辑后发送。');
|
|
725
|
+
return lines.join('\n');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* 确认卡键盘:两个指令按钮(action.type=2)携带完整答案文本。
|
|
730
|
+
* - 提交:enter=true,客户端点击后自动以用户身份发出(真实消息,框架可认领)
|
|
731
|
+
* - 改一改:enter=false,答案填入输入框,用户编辑后手动发送
|
|
732
|
+
*/
|
|
733
|
+
export function buildMultiQuestionConfirmKeyboard(answerText: string): InlineKeyboard {
|
|
734
|
+
const buttons: KeyboardButton[] = [
|
|
735
|
+
{
|
|
736
|
+
id: 'confirm_submit',
|
|
737
|
+
render_data: {
|
|
738
|
+
label: '✅ 提交',
|
|
739
|
+
visited_label: '已提交',
|
|
740
|
+
style: 1 as const,
|
|
741
|
+
},
|
|
742
|
+
action: {
|
|
743
|
+
type: 2 as const,
|
|
744
|
+
data: answerText,
|
|
745
|
+
enter: true,
|
|
746
|
+
permission: { type: 2 as const },
|
|
747
|
+
unsupport_tips: '当前客户端不支持,请手动回复上方答案文本',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: 'confirm_edit',
|
|
752
|
+
render_data: {
|
|
753
|
+
label: '✏️ 改一改',
|
|
754
|
+
visited_label: '✏️ 改一改',
|
|
755
|
+
style: 0 as const,
|
|
756
|
+
},
|
|
757
|
+
action: {
|
|
758
|
+
type: 2 as const,
|
|
759
|
+
data: answerText,
|
|
760
|
+
enter: false,
|
|
761
|
+
permission: { type: 2 as const },
|
|
762
|
+
unsupport_tips: '当前客户端不支持,请手动回复上方答案文本',
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
];
|
|
766
|
+
return {
|
|
767
|
+
content: {
|
|
768
|
+
rows: [{ buttons }],
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|