@xmanrui/dsh-im 4.20.2 → 4.21.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 (51) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +108 -21
  4. package/lib/index.js +276 -276
  5. package/package.json +17 -1
  6. package/plugin-src/client/channel-logos.js +18 -5
  7. package/plugin-src/client/channels/imessage/styles.js +1 -1
  8. package/plugin-src/client/channels/slack/styles.js +1 -1
  9. package/plugin-src/client/channels/weixin/connection-error.js +4 -1
  10. package/plugin-src/client/i18n.js +8 -0
  11. package/plugin-src/client/model-setting.js +4 -2
  12. package/plugin-src/client/session-channel-logos.js +1 -2
  13. package/plugin-src/client/styles.js +3 -2
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/qq/rpc.mjs +2 -1
  16. package/plugin-src/host/modern-harness-api.mjs +91 -3
  17. package/scripts/verify-model-setting.mjs +4 -1
  18. package/scripts/verify-package.mjs +3 -1
  19. package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
  20. package/src/channels/discord/discord-runtime.mjs +4 -1
  21. package/src/channels/feishu/bridge.mjs +9 -1
  22. package/src/channels/feishu/feishu-channel.mjs +1 -1
  23. package/src/channels/qq/qq-bridge.mjs +9 -1
  24. package/src/channels/qq/qq-controller.mjs +11 -5
  25. package/src/channels/qq/state-error.mjs +17 -0
  26. package/src/channels/qq/state-store.mjs +35 -9
  27. package/src/channels/shared/batch-input.mjs +22 -2
  28. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  29. package/src/channels/shared/config-read-error.mjs +24 -0
  30. package/src/channels/shared/harness-client.mjs +13 -12
  31. package/src/channels/shared/harness-question.mjs +10 -2
  32. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  33. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  34. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  35. package/src/channels/shared/semantic/artifact.mjs +1 -1
  36. package/src/channels/shared/text-harness-bridge.mjs +198 -3
  37. package/src/channels/shared/token-config-store.mjs +23 -8
  38. package/src/channels/shared/workspace-session.mjs +7 -1
  39. package/src/channels/slack/slack-runtime.mjs +3 -1
  40. package/src/channels/telegram/telegram-api.mjs +62 -2
  41. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  42. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  43. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  44. package/src/channels/wecom/wecom-bridge.mjs +4 -0
  45. package/src/channels/wecom-app/config-store.mjs +3 -1
  46. package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
  47. package/src/channels/weixin/config-store.mjs +24 -15
  48. package/src/channels/weixin/connection-error.en.mjs +21 -0
  49. package/src/channels/weixin/connection-error.mjs +21 -6
  50. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  51. package/src/channels/weixin/weixin-bridge.mjs +1 -0
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
2
4
  import { t } from './i18n.mjs';
3
5
  import { commandHelpLines } from './command-catalog.mjs';
@@ -154,6 +156,7 @@ export class TextHarnessBridge {
154
156
  #commandTasks = new Set();
155
157
  #approvals;
156
158
  #batches = new BatchInputManager();
159
+ #interactionCard;
157
160
 
158
161
  constructor({
159
162
  descriptor,
@@ -167,6 +170,7 @@ export class TextHarnessBridge {
167
170
  replyTimeoutMs = 600_000,
168
171
  signal,
169
172
  keepaliveIntervalMs = 4_000,
173
+ interactionCard = null,
170
174
  }) {
171
175
  if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
172
176
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@@ -182,6 +186,7 @@ export class TextHarnessBridge {
182
186
  this.#replyTimeoutMs = replyTimeoutMs;
183
187
  this.#signal = signal;
184
188
  this.#keepaliveIntervalMs = keepaliveIntervalMs;
189
+ this.#interactionCard = interactionCard ?? null;
185
190
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
186
191
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
187
192
  });
@@ -303,7 +308,12 @@ export class TextHarnessBridge {
303
308
  return this.#enqueueMessage({
304
309
  ...normalized,
305
310
  content: batch.prompt,
306
- batchSubmission: { token: batch.token },
311
+ // The submission is exactly the collected text; a quote or an
312
+ // attachment on the command itself is not part of it.
313
+ replyTo: null,
314
+ images: [],
315
+ files: [],
316
+ batchSubmission: { token: batch.token, title: batch.title },
307
317
  }, messageId, senderId, key);
308
318
  }
309
319
  return this.#finishLocalMessage(normalized, messageId, batch.message);
@@ -727,6 +737,7 @@ export class TextHarnessBridge {
727
737
  key: conversationKey,
728
738
  text,
729
739
  content,
740
+ titleText: batchSubmission?.title,
730
741
  contextEnhanced,
731
742
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
732
743
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
@@ -926,7 +937,22 @@ export class TextHarnessBridge {
926
937
  }
927
938
  }
928
939
 
929
- async #processInteractionReply(message, messageId, senderId, key, expected) {
940
+ /**
941
+ * Advance the pending interaction with one answer.
942
+ *
943
+ * `resolveAnswer` lets a caller that already knows the exact answer supply it
944
+ * directly. A button press uses this: replaying its label as reply text would
945
+ * re-parse a numeric label such as "2" as the second option, submitting a
946
+ * different option than the one pressed.
947
+ */
948
+ async #processInteractionReply(
949
+ message,
950
+ messageId,
951
+ senderId,
952
+ key,
953
+ expected,
954
+ { resolveAnswer } = {},
955
+ ) {
930
956
  if (this.#signal?.aborted) {
931
957
  message.statusReaction?.clear();
932
958
  return;
@@ -1013,7 +1039,15 @@ export class TextHarnessBridge {
1013
1039
 
1014
1040
  const question = pending.questions[pending.index];
1015
1041
  if (!question) return;
1016
- pending.answers.push(harnessAnswerForQuestion(question, text));
1042
+ const answer = typeof resolveAnswer === 'function'
1043
+ ? resolveAnswer(question)
1044
+ : harnessAnswerForQuestion(question, text);
1045
+ if (!answer) return;
1046
+ // Retire this question's keyboard before moving on: the answer is already in,
1047
+ // and a card left behind in the chat stays pressable after the batch advances.
1048
+ await this.#retireInteractionCard(pending.target, pending.cardMessageId);
1049
+ pending.cardMessageId = null;
1050
+ pending.answers.push(answer);
1017
1051
  pending.index += 1;
1018
1052
  if (pending.index < pending.questions.length) {
1019
1053
  if (pending.claimedReplyMessageId === messageId) {
@@ -1090,6 +1124,114 @@ export class TextHarnessBridge {
1090
1124
  }
1091
1125
  }
1092
1126
 
1127
+ /** Acknowledge a press so the client stops its spinner; never fails the answer. */
1128
+ async #answerInteractionCallback(callback, text) {
1129
+ if (typeof this.#bot.answerInteractionCallback !== 'function') return;
1130
+ try {
1131
+ await this.#bot.answerInteractionCallback(callback.callbackQueryId, text);
1132
+ } catch (error) {
1133
+ this.#logger.warn?.(
1134
+ `[dsh-im:${this.#descriptor.key}] could not acknowledge a button press:`,
1135
+ error?.message ?? error,
1136
+ );
1137
+ }
1138
+ }
1139
+
1140
+ /** Drop a handled card's keyboard so the same press cannot be submitted twice. */
1141
+ async #retireInteractionCard(target, providerMessageId) {
1142
+ if (!providerMessageId || typeof this.#bot.updateInteractionCard !== 'function') return;
1143
+ try {
1144
+ await this.#bot.updateInteractionCard(target, providerMessageId, {
1145
+ markup: { inline_keyboard: [] },
1146
+ });
1147
+ } catch (error) {
1148
+ this.#logger.warn?.(
1149
+ `[dsh-im:${this.#descriptor.key}] could not retire an interaction card:`,
1150
+ error?.message ?? error,
1151
+ );
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * Accept an inline-keyboard press. The press is replayed as the option label the
1157
+ * text flow already understands, so a button and a typed reply share one
1158
+ * submission path and cannot drift apart.
1159
+ */
1160
+ async acceptCallback(callback) {
1161
+ if (this.#signal?.aborted) return;
1162
+ const conversationId = cleanText(callback?.conversationId);
1163
+ const senderId = cleanText(callback?.senderId);
1164
+ const messageId = cleanText(callback?.messageId);
1165
+ if (!conversationId || !senderId || !messageId || callback?.senderIsBot === true) return;
1166
+ const kind = callback.kind === 'group' ? 'group' : 'direct';
1167
+ const key = `${kind}:${conversationId}`;
1168
+ const pending = this.#pendingInteractions.get(key);
1169
+ const notice = (text) => this.#answerInteractionCallback(callback, text);
1170
+ if (!pending) return notice(t('该问题已处理,无需再次选择。'));
1171
+ if (pending.actor !== senderId) {
1172
+ return notice(t('只有发起当前任务的用户可以处理这条问题。'));
1173
+ }
1174
+ // A press can arrive while an earlier one is still being submitted: the
1175
+ // acknowledgement round-trip is a real window, and a user who sees no feedback
1176
+ // presses again. Claim synchronously, before the first await, so the second
1177
+ // press cannot advance the same question twice.
1178
+ if (pending.submitting || pending.callbackClaimed) {
1179
+ return notice(t('正在提交你的选择,请稍候。'));
1180
+ }
1181
+
1182
+ const question = pending.questions[pending.index];
1183
+ const parsed = this.#interactionCard?.parse?.(callback.data) ?? null;
1184
+ const option = parsed && Array.isArray(question?.options)
1185
+ ? question.options[parsed.optionIndex]
1186
+ : undefined;
1187
+ if (!question || !parsed
1188
+ || !pending.cardNonce || parsed.nonce !== pending.cardNonce
1189
+ || parsed.questionIndex !== pending.index
1190
+ || !option || typeof option.label !== 'string') {
1191
+ return notice(t('这个选项已失效,请使用最新一条问题。'));
1192
+ }
1193
+ // Multi-select needs a keyboard that accumulates choices and a submit action;
1194
+ // until that exists the request keeps the text answer it has always accepted.
1195
+ if (question.multiSelect === true) {
1196
+ return notice(t('多选问题请直接回复文字。'));
1197
+ }
1198
+
1199
+ pending.callbackClaimed = true;
1200
+ // A press can beat the card's own send promise — Telegram delivers the update
1201
+ // as soon as its API accepted the keyboard. Wait for delivery so the card id
1202
+ // exists and the shared submission path can retire its keyboard.
1203
+ await pending.presentationTask?.catch(() => undefined);
1204
+ await notice(t('已选择:{label}', { label: option.label }));
1205
+ await this.#processInteractionReply(
1206
+ {
1207
+ kind,
1208
+ conversationId,
1209
+ messageId,
1210
+ senderId,
1211
+ addressed: true,
1212
+ content: option.label,
1213
+ replyTarget: callback.replyTarget ?? pending.target,
1214
+ statusReaction: null,
1215
+ },
1216
+ messageId,
1217
+ senderId,
1218
+ key,
1219
+ pending,
1220
+ // The press already identifies its option by index, so answer with that
1221
+ // exact label. Replaying the label as reply text would re-parse a numeric
1222
+ // label such as "2" as the second option and submit a different one.
1223
+ { resolveAnswer: (current) => ({ id: current.id, selected: [option.label] }) },
1224
+ ).catch((error) => {
1225
+ this.#logger.error?.(
1226
+ `[dsh-im:${this.#descriptor.key}] failed to submit a card answer:`,
1227
+ error,
1228
+ );
1229
+ }).finally(() => {
1230
+ // A failed submission rolls the question back, so let the user press again.
1231
+ if (this.#pendingInteractions.get(key) === pending) pending.callbackClaimed = false;
1232
+ });
1233
+ }
1234
+
1093
1235
  async #handleInteraction(interaction, {
1094
1236
  key,
1095
1237
  actor,
@@ -1172,6 +1314,11 @@ export class TextHarnessBridge {
1172
1314
  submitting: false,
1173
1315
  needsPresentation: true,
1174
1316
  presentationTask: null,
1317
+ cardMessageId: null,
1318
+ // Identity of the keyboard currently on screen, and the guard that keeps two
1319
+ // presses of the same card from advancing one question twice.
1320
+ cardNonce: null,
1321
+ callbackClaimed: false,
1175
1322
  };
1176
1323
  this.#pendingInteractions.set(key, pending);
1177
1324
  this.#interactionKeys.set(interactionId, key);
@@ -1190,11 +1337,59 @@ export class TextHarnessBridge {
1190
1337
  this.#clearPendingInteraction(key, interactionId);
1191
1338
  }
1192
1339
 
1340
+ /** Build card content for the current question, or null to keep the text flow. */
1341
+ #interactionCardFor(pending, question) {
1342
+ if (!this.#interactionCard || typeof this.#interactionCard.render !== 'function') return null;
1343
+ if (typeof this.#bot.sendInteractionCard !== 'function') return null;
1344
+ if (pending.submitting) return null;
1345
+ let card = null;
1346
+ try {
1347
+ card = this.#interactionCard.render(question, {
1348
+ questionIndex: pending.index,
1349
+ total: pending.questions.length,
1350
+ requiresMention: pending.requiresMention,
1351
+ nonce: pending.cardNonce,
1352
+ });
1353
+ } catch (error) {
1354
+ this.#logger.warn?.(
1355
+ `[dsh-im:${this.#descriptor.key}] interaction card renderer failed:`,
1356
+ error,
1357
+ );
1358
+ return null;
1359
+ }
1360
+ if (!card || typeof card.text !== 'string' || !card.text || !card.markup) return null;
1361
+ return card;
1362
+ }
1363
+
1193
1364
  #presentInteraction(pending) {
1194
1365
  if (pending.presentationTask) return pending.presentationTask;
1195
1366
  const question = pending.questions[pending.index];
1196
1367
  if (!question) return Promise.resolve();
1197
1368
  const task = (async () => {
1369
+ // A fresh nonce per presentation: a later question restarts its indexes at
1370
+ // zero, so without one an old card's keyboard would answer the new question.
1371
+ pending.cardNonce = randomUUID().slice(0, 8);
1372
+ const card = this.#interactionCardFor(pending, question);
1373
+ if (card) {
1374
+ try {
1375
+ const sent = await this.#bot.sendInteractionCard(pending.target, {
1376
+ text: card.text,
1377
+ markup: card.markup,
1378
+ });
1379
+ pending.cardMessageId = sent?.providerMessageIds?.at(-1) ?? null;
1380
+ pending.needsPresentation = false;
1381
+ return;
1382
+ } catch (error) {
1383
+ // A platform that refuses the keyboard must not lose the question:
1384
+ // fall through to the plain-text flow this channel already had.
1385
+ pending.cardMessageId = null;
1386
+ this.#logger.warn?.(
1387
+ `[dsh-im:${this.#descriptor.key}] could not deliver an interaction card; `
1388
+ + 'falling back to plain text:',
1389
+ error?.message ?? error,
1390
+ );
1391
+ }
1392
+ }
1198
1393
  await this.#bot.sendText(
1199
1394
  pending.target,
1200
1395
  harnessQuestionText(
@@ -8,8 +8,24 @@ function cleanString(value) {
8
8
  return typeof value === 'string' && value.trim() ? value.trim() : null;
9
9
  }
10
10
 
11
- function escapePattern(value) {
12
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+ function isHexString(value, uppercase) {
12
+ if (typeof value !== 'string' || value.length !== 24) return false;
13
+ for (const character of value) {
14
+ const code = character.charCodeAt(0);
15
+ const digit = code >= 48 && code <= 57;
16
+ const letter = uppercase
17
+ ? code >= 65 && code <= 70
18
+ : code >= 97 && code <= 102;
19
+ if (!digit && !letter) return false;
20
+ }
21
+ return true;
22
+ }
23
+
24
+ function matchesTokenIdentity(value, prefix, uppercaseHex) {
25
+ if (typeof value !== 'string') return false;
26
+ const marker = `${prefix}_`;
27
+ if (!value.startsWith(marker)) return false;
28
+ return isHexString(value.slice(marker.length), uppercaseHex);
13
29
  }
14
30
 
15
31
  export function deriveTokenBotIdentity(platformId, { botPrefix, tokenRefPrefix }) {
@@ -34,8 +50,6 @@ export class TokenBotConfigStore {
34
50
  #botPrefix;
35
51
  #tokenRefPrefix;
36
52
  #normalizeBotExtension;
37
- #botIdPattern;
38
- #tokenRefPattern;
39
53
  #value = EMPTY_DOCUMENT;
40
54
  #writeQueue = Promise.resolve();
41
55
 
@@ -53,8 +67,6 @@ export class TokenBotConfigStore {
53
67
  this.#botPrefix = botPrefix;
54
68
  this.#tokenRefPrefix = tokenRefPrefix;
55
69
  this.#normalizeBotExtension = normalizeBotExtension;
56
- this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`);
57
- this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`);
58
70
  }
59
71
 
60
72
  async load() {
@@ -102,7 +114,9 @@ export class TokenBotConfigStore {
102
114
  }
103
115
 
104
116
  async remove(botId) {
105
- if (!this.#botIdPattern.test(botId)) throw new TypeError(`Invalid ${this.#channel} bot id`);
117
+ if (!matchesTokenIdentity(botId, this.#botPrefix, false)) {
118
+ throw new TypeError(`Invalid ${this.#channel} bot id`);
119
+ }
106
120
  return this.#mutate((bots) => {
107
121
  const index = bots.findIndex((bot) => bot.botId === botId);
108
122
  if (index === -1) return null;
@@ -131,7 +145,8 @@ export class TokenBotConfigStore {
131
145
  const tokenRef = cleanString(value.tokenRef);
132
146
  const name = cleanString(value.name);
133
147
  if (!platformId || !botId || !tokenRef || !name
134
- || !this.#botIdPattern.test(botId) || !this.#tokenRefPattern.test(tokenRef)) return null;
148
+ || !matchesTokenIdentity(botId, this.#botPrefix, false)
149
+ || !matchesTokenIdentity(tokenRef, this.#tokenRefPrefix, true)) return null;
135
150
  const derived = deriveTokenBotIdentity(platformId, {
136
151
  botPrefix: this.#botPrefix,
137
152
  tokenRefPrefix: this.#tokenRefPrefix,
@@ -77,6 +77,11 @@ async function createSession(harness, options) {
77
77
  * Resolve, persist, and ask through a session that belongs to the bot's
78
78
  * current workspace. A concurrent workspace switch invalidates the scoped
79
79
  * session and retries before any prompt is sent to the stale session.
80
+ *
81
+ * `titleText` names the conversation title when the prompt itself is not the
82
+ * user's own words -- a batch submission composes dsh-im's framing sentence and
83
+ * message labels into one prompt, and only the collected text may name the
84
+ * conversation.
80
85
  */
81
86
  export async function askInWorkspaceSession({
82
87
  harness,
@@ -84,6 +89,7 @@ export async function askInWorkspaceSession({
84
89
  key,
85
90
  text,
86
91
  content,
92
+ titleText,
87
93
  contextEnhanced = false,
88
94
  createOptions,
89
95
  existsOptions,
@@ -92,7 +98,7 @@ export async function askInWorkspaceSession({
92
98
  }) {
93
99
  const initialTitle = contextEnhanced
94
100
  ? initialSessionTitle({
95
- text,
101
+ text: titleText ?? text,
96
102
  content,
97
103
  files: typeof askOptions === 'object' ? askOptions?.files : undefined,
98
104
  })
@@ -40,7 +40,9 @@ function decodeSlackText(value) {
40
40
 
41
41
  function stripBotMention(value, botUserId) {
42
42
  return decodeSlackText(value)
43
- .replace(new RegExp(`<@${botUserId}>`, 'gi'), '')
43
+ .split(`<@${botUserId}>`).join('')
44
+ .split(`<@${String(botUserId).toLowerCase()}>`).join('')
45
+ .split(`<@${String(botUserId).toUpperCase()}>`).join('')
44
46
  .trim();
45
47
  }
46
48
 
@@ -55,6 +55,42 @@ function inputRichMessage(value) {
55
55
  return value;
56
56
  }
57
57
 
58
+ /** Telegram rejects callback_data longer than 64 bytes. */
59
+ const CALLBACK_DATA_MAX_BYTES = 64;
60
+
61
+ /** Validate an inline keyboard so no oversized or malformed payload is dispatched.
62
+ * An empty `inline_keyboard` is accepted: that is how Telegram removes a keyboard.
63
+ */
64
+ function inputReplyMarkup(value) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
+ throw new TypeError('A Telegram reply markup is required');
67
+ }
68
+ const rows = value.inline_keyboard;
69
+ if (!Array.isArray(rows)) {
70
+ throw new TypeError('Telegram reply markup requires inline_keyboard rows');
71
+ }
72
+ return {
73
+ inline_keyboard: rows.map((row) => {
74
+ if (!Array.isArray(row) || row.length === 0) {
75
+ throw new TypeError('Telegram inline keyboard rows must be non-empty arrays');
76
+ }
77
+ return row.map((button) => {
78
+ const text = cleanString(button?.text);
79
+ const data = cleanString(button?.callback_data);
80
+ if (!text || !data) {
81
+ throw new TypeError('Telegram inline keyboard buttons require text and callback_data');
82
+ }
83
+ if (Buffer.byteLength(data, 'utf8') > CALLBACK_DATA_MAX_BYTES) {
84
+ throw new TypeError(
85
+ `Telegram callback_data must be at most ${CALLBACK_DATA_MAX_BYTES} bytes`,
86
+ );
87
+ }
88
+ return { text, callback_data: data };
89
+ });
90
+ }),
91
+ };
92
+ }
93
+
58
94
  function telegramArtifactProviderError(cause, mediaLabel = 'document') {
59
95
  const providerCode = Number(cause?.providerCode);
60
96
  const status = Number(cause?.status);
@@ -146,7 +182,7 @@ export class TelegramApi {
146
182
  const payload = {
147
183
  timeout,
148
184
  limit: 100,
149
- allowed_updates: ['message'],
185
+ allowed_updates: ['message', 'callback_query'],
150
186
  ...(Number.isSafeInteger(offset) ? { offset } : {}),
151
187
  };
152
188
  return this.#call('getUpdates', payload, {
@@ -203,7 +239,7 @@ export class TelegramApi {
203
239
  return url;
204
240
  }
205
241
 
206
- async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
242
+ async sendMessage({ chatId, text, replyToMessageId, messageThreadId, replyMarkup, signal }) {
207
243
  return this.#call('sendMessage', {
208
244
  chat_id: chatId,
209
245
  text,
@@ -212,6 +248,30 @@ export class TelegramApi {
212
248
  reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
213
249
  } : {}),
214
250
  ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
251
+ ...(replyMarkup === undefined ? {} : { reply_markup: inputReplyMarkup(replyMarkup) }),
252
+ }, { signal });
253
+ }
254
+
255
+ /** Acknowledge a button press so the client stops showing its progress spinner. */
256
+ async answerCallbackQuery({ callbackQueryId, text, signal }) {
257
+ const queryId = cleanString(callbackQueryId);
258
+ if (!queryId) throw new TypeError('Telegram callback query id is required');
259
+ const notice = cleanString(text);
260
+ return this.#call('answerCallbackQuery', {
261
+ callback_query_id: queryId,
262
+ ...(notice ? { text: notice } : {}),
263
+ }, { signal });
264
+ }
265
+
266
+ /** Replace only the keyboard of an existing message, keeping its text intact. */
267
+ async editMessageReplyMarkup({ chatId, messageId, replyMarkup, signal }) {
268
+ if (!Number.isSafeInteger(messageId)) {
269
+ throw new TypeError('Telegram message id must be a safe integer');
270
+ }
271
+ return this.#call('editMessageReplyMarkup', {
272
+ chat_id: chatId,
273
+ message_id: messageId,
274
+ ...(replyMarkup === undefined ? {} : { reply_markup: inputReplyMarkup(replyMarkup) }),
215
275
  }, { signal });
216
276
  }
217
277
 
@@ -1,3 +1,4 @@
1
+ import { harnessQuestionText } from '../shared/harness-question.mjs';
1
2
  import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
2
3
 
3
4
  export const TELEGRAM_DESCRIPTOR = Object.freeze({
@@ -7,9 +8,73 @@ export const TELEGRAM_DESCRIPTOR = Object.freeze({
7
8
  reactions: Object.freeze({ processing: '👀', success: '👍', error: '👎' }),
8
9
  });
9
10
 
11
+ /** One keyboard row per option; beyond this the list stops being scannable. */
12
+ const MAX_CARD_OPTIONS = 8;
13
+
14
+ /**
15
+ * Encode a press. Carries the presentation nonce so a keyboard left over from an
16
+ * earlier question cannot answer a later one — a new request restarts its indexes
17
+ * at zero, so the index alone would collide. Still far under Telegram's 64-byte
18
+ * callback_data budget, so no server-side id table is needed.
19
+ */
20
+ function cardCallbackData(nonce, questionIndex, optionIndex) {
21
+ return `q|${nonce}|${questionIndex}|${optionIndex}`;
22
+ }
23
+
24
+ /** Parse a press produced by {@link cardCallbackData}; null for foreign payloads. */
25
+ export function parseTelegramCardCallback(data) {
26
+ const match = typeof data === 'string'
27
+ ? /^q\|([A-Za-z0-9_-]{1,16})\|(\d{1,4})\|(\d{1,4})$/u.exec(data)
28
+ : null;
29
+ if (!match) return null;
30
+ return {
31
+ nonce: match[1],
32
+ questionIndex: Number(match[2]),
33
+ optionIndex: Number(match[3]),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Inline-keyboard presentation for single-choice questions.
39
+ *
40
+ * Returns null — leaving the plain-text flow in charge — when a keyboard cannot
41
+ * express every answer the request allows: no options, an unusable button label,
42
+ * more options than fit, or multi-select (which needs an accumulating keyboard
43
+ * plus a submit action this channel does not have yet).
44
+ */
45
+ export const TELEGRAM_INTERACTION_CARD = Object.freeze({
46
+ render(question, { questionIndex = 0, total = 1, requiresMention = false, nonce } = {}) {
47
+ // Without an identity the press could not be attributed to this presentation.
48
+ if (typeof nonce !== 'string' || !nonce) return null;
49
+ const options = Array.isArray(question?.options) ? question.options : [];
50
+ if (options.length === 0 || options.length > MAX_CARD_OPTIONS) return null;
51
+ if (question.multiSelect === true) return null;
52
+ if (options.some((option) => typeof option?.label !== 'string' || !option.label.trim())) {
53
+ return null;
54
+ }
55
+ return {
56
+ text: harnessQuestionText(question, questionIndex, total, {
57
+ requiresMention,
58
+ hasButtons: true,
59
+ }),
60
+ markup: {
61
+ inline_keyboard: options.map((option, optionIndex) => [{
62
+ text: option.label,
63
+ callback_data: cardCallbackData(nonce, questionIndex, optionIndex),
64
+ }]),
65
+ },
66
+ };
67
+ },
68
+ parse: parseTelegramCardCallback,
69
+ });
70
+
10
71
  export class TelegramHarnessBridge extends TextHarnessBridge {
11
72
  constructor(options) {
12
- super({ descriptor: TELEGRAM_DESCRIPTOR, ...options });
73
+ super({
74
+ descriptor: TELEGRAM_DESCRIPTOR,
75
+ interactionCard: TELEGRAM_INTERACTION_CARD,
76
+ ...options,
77
+ });
13
78
  }
14
79
  }
15
80
 
@@ -64,10 +64,12 @@ function assertCompleteFences(markdown) {
64
64
  }
65
65
 
66
66
  function escapedMarkdown(value) {
67
- return value
68
- .replaceAll('&', '&amp;')
69
- .replaceAll('<', '&lt;')
70
- .replaceAll('>', '&gt;');
67
+ return Array.from(value, (character) => {
68
+ if (character === '&') return '&amp;';
69
+ if (character === '<') return '&lt;';
70
+ if (character === '>') return '&gt;';
71
+ return character;
72
+ }).join('');
71
73
  }
72
74
 
73
75
  function plainRichChunks(source, limit) {