@xmanrui/dsh-im 4.20.1 → 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 +210 -9
  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 +139 -8
  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,
@@ -744,12 +755,18 @@ export class TextHarnessBridge {
744
755
  : progress);
745
756
  }
746
757
  } : undefined,
747
- onInteraction: (interaction) => this.#handleInteraction(interaction, {
748
- key: conversationKey,
749
- actor: senderId,
750
- target,
751
- requiresMention: message.kind === 'group' && message.requiresMention !== false,
752
- }),
758
+ onInteraction: async (interaction) => {
759
+ if (stream?.keepalive === true && typeof stream.stopPreview === 'function') {
760
+ stopKeepalive();
761
+ await stream.stopPreview();
762
+ }
763
+ return this.#handleInteraction(interaction, {
764
+ key: conversationKey,
765
+ actor: senderId,
766
+ target,
767
+ requiresMention: message.kind === 'group' && message.requiresMention !== false,
768
+ });
769
+ },
753
770
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
754
771
  files: message.files,
755
772
  },
@@ -920,7 +937,22 @@ export class TextHarnessBridge {
920
937
  }
921
938
  }
922
939
 
923
- 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
+ ) {
924
956
  if (this.#signal?.aborted) {
925
957
  message.statusReaction?.clear();
926
958
  return;
@@ -1007,7 +1039,15 @@ export class TextHarnessBridge {
1007
1039
 
1008
1040
  const question = pending.questions[pending.index];
1009
1041
  if (!question) return;
1010
- 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);
1011
1051
  pending.index += 1;
1012
1052
  if (pending.index < pending.questions.length) {
1013
1053
  if (pending.claimedReplyMessageId === messageId) {
@@ -1084,6 +1124,114 @@ export class TextHarnessBridge {
1084
1124
  }
1085
1125
  }
1086
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
+
1087
1235
  async #handleInteraction(interaction, {
1088
1236
  key,
1089
1237
  actor,
@@ -1166,6 +1314,11 @@ export class TextHarnessBridge {
1166
1314
  submitting: false,
1167
1315
  needsPresentation: true,
1168
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,
1169
1322
  };
1170
1323
  this.#pendingInteractions.set(key, pending);
1171
1324
  this.#interactionKeys.set(interactionId, key);
@@ -1184,11 +1337,59 @@ export class TextHarnessBridge {
1184
1337
  this.#clearPendingInteraction(key, interactionId);
1185
1338
  }
1186
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
+
1187
1364
  #presentInteraction(pending) {
1188
1365
  if (pending.presentationTask) return pending.presentationTask;
1189
1366
  const question = pending.questions[pending.index];
1190
1367
  if (!question) return Promise.resolve();
1191
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
+ }
1192
1393
  await this.#bot.sendText(
1193
1394
  pending.target,
1194
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) {