@xmanrui/dsh-im 4.20.2 → 4.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +116 -21
  4. package/lib/index.js +288 -287
  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/index.mjs +7 -0
  17. package/plugin-src/host/injected-context.mjs +104 -0
  18. package/plugin-src/host/modern-harness-api.mjs +91 -3
  19. package/scripts/verify-model-setting.mjs +4 -1
  20. package/scripts/verify-package.mjs +3 -1
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
  22. package/src/channels/discord/discord-runtime.mjs +4 -1
  23. package/src/channels/feishu/bridge.mjs +44 -3
  24. package/src/channels/feishu/feishu-channel.mjs +1 -1
  25. package/src/channels/qq/qq-bridge.mjs +36 -3
  26. package/src/channels/qq/qq-controller.mjs +11 -5
  27. package/src/channels/qq/state-error.mjs +17 -0
  28. package/src/channels/qq/state-store.mjs +35 -9
  29. package/src/channels/shared/batch-input.mjs +22 -2
  30. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  31. package/src/channels/shared/config-read-error.mjs +24 -0
  32. package/src/channels/shared/context-enhancement.mjs +40 -3
  33. package/src/channels/shared/control-command.mjs +8 -1
  34. package/src/channels/shared/harness-client.mjs +20 -12
  35. package/src/channels/shared/harness-question.mjs +10 -2
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  37. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  38. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  39. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  40. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  41. package/src/channels/shared/im-source-guidance.mjs +65 -0
  42. package/src/channels/shared/injected-context.mjs +362 -0
  43. package/src/channels/shared/semantic/artifact.mjs +4 -4
  44. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  45. package/src/channels/shared/text-harness-bridge.mjs +221 -5
  46. package/src/channels/shared/token-config-store.mjs +23 -8
  47. package/src/channels/shared/workspace-session.mjs +16 -1
  48. package/src/channels/slack/slack-runtime.mjs +3 -1
  49. package/src/channels/telegram/telegram-api.mjs +62 -2
  50. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  51. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  52. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  53. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  54. package/src/channels/wecom-app/config-store.mjs +3 -1
  55. package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
  56. package/src/channels/weixin/config-store.mjs +24 -15
  57. package/src/channels/weixin/connection-error.en.mjs +21 -0
  58. package/src/channels/weixin/connection-error.mjs +21 -6
  59. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  60. package/src/channels/weixin/weixin-api.mjs +52 -13
  61. package/src/channels/weixin/weixin-bridge.mjs +14 -1
@@ -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';
@@ -5,7 +7,11 @@ import {
5
7
  COMMAND_PERMISSION_DENIED_MESSAGE,
6
8
  evaluateInboundAccess,
7
9
  } from './inbound-access.mjs';
8
- import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
10
+ import {
11
+ captureContextEnhancement,
12
+ captureContextEnhancementSource,
13
+ enhanceContextContent,
14
+ } from './context-enhancement.mjs';
9
15
  import { runWorkspaceCommand } from './workspace-command.mjs';
10
16
  import { runCompactCommand } from './compact-command.mjs';
11
17
  import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
@@ -154,6 +160,7 @@ export class TextHarnessBridge {
154
160
  #commandTasks = new Set();
155
161
  #approvals;
156
162
  #batches = new BatchInputManager();
163
+ #interactionCard;
157
164
 
158
165
  constructor({
159
166
  descriptor,
@@ -167,6 +174,7 @@ export class TextHarnessBridge {
167
174
  replyTimeoutMs = 600_000,
168
175
  signal,
169
176
  keepaliveIntervalMs = 4_000,
177
+ interactionCard = null,
170
178
  }) {
171
179
  if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
172
180
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@@ -182,6 +190,7 @@ export class TextHarnessBridge {
182
190
  this.#replyTimeoutMs = replyTimeoutMs;
183
191
  this.#signal = signal;
184
192
  this.#keepaliveIntervalMs = keepaliveIntervalMs;
193
+ this.#interactionCard = interactionCard ?? null;
185
194
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
186
195
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
187
196
  });
@@ -303,7 +312,12 @@ export class TextHarnessBridge {
303
312
  return this.#enqueueMessage({
304
313
  ...normalized,
305
314
  content: batch.prompt,
306
- batchSubmission: { token: batch.token },
315
+ // The submission is exactly the collected text; a quote or an
316
+ // attachment on the command itself is not part of it.
317
+ replyTo: null,
318
+ images: [],
319
+ files: [],
320
+ batchSubmission: { token: batch.token, title: batch.title },
307
321
  }, messageId, senderId, key);
308
322
  }
309
323
  return this.#finishLocalMessage(normalized, messageId, batch.message);
@@ -336,6 +350,7 @@ export class TextHarnessBridge {
336
350
  messageId,
337
351
  key,
338
352
  commandRunner,
353
+ senderId,
339
354
  ).finally(() => {
340
355
  this.#acceptedMessageIds.delete(messageId);
341
356
  this.#commandTasks.delete(task);
@@ -477,7 +492,7 @@ export class TextHarnessBridge {
477
492
  await this.#deferred.whenIdle();
478
493
  }
479
494
 
480
- async #processFastCommand(message, messageId, key, runner) {
495
+ async #processFastCommand(message, messageId, key, runner, senderId) {
481
496
  if (this.#state.hasSeen(messageId)) return;
482
497
  await this.#state.markSeen(messageId);
483
498
  this.#status.messagesReceived += 1;
@@ -498,6 +513,21 @@ export class TextHarnessBridge {
498
513
  || this.#approvals.hasPending(key),
499
514
  control: { owner: this, key },
500
515
  deferredDelivery: this.#deferred,
516
+ enhancement: captureContextEnhancementSource(
517
+ this.#contextEnhancement,
518
+ message.kind,
519
+ () => {
520
+ const source = message.contextSource?.();
521
+ return {
522
+ channel: this.#descriptor.key,
523
+ senderId,
524
+ senderName: source?.senderName,
525
+ conversationTitle: source?.conversationTitle,
526
+ chatId: source?.chatId ?? message.conversationId,
527
+ threadId: source?.threadId,
528
+ };
529
+ },
530
+ ),
501
531
  },
502
532
  );
503
533
  if (result?.stopped) {
@@ -727,6 +757,8 @@ export class TextHarnessBridge {
727
757
  key: conversationKey,
728
758
  text,
729
759
  content,
760
+ titleText: batchSubmission?.title,
761
+ sourceGuidance: snapshot?.config?.guidance,
730
762
  contextEnhanced,
731
763
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
732
764
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
@@ -926,7 +958,22 @@ export class TextHarnessBridge {
926
958
  }
927
959
  }
928
960
 
929
- async #processInteractionReply(message, messageId, senderId, key, expected) {
961
+ /**
962
+ * Advance the pending interaction with one answer.
963
+ *
964
+ * `resolveAnswer` lets a caller that already knows the exact answer supply it
965
+ * directly. A button press uses this: replaying its label as reply text would
966
+ * re-parse a numeric label such as "2" as the second option, submitting a
967
+ * different option than the one pressed.
968
+ */
969
+ async #processInteractionReply(
970
+ message,
971
+ messageId,
972
+ senderId,
973
+ key,
974
+ expected,
975
+ { resolveAnswer } = {},
976
+ ) {
930
977
  if (this.#signal?.aborted) {
931
978
  message.statusReaction?.clear();
932
979
  return;
@@ -1013,7 +1060,15 @@ export class TextHarnessBridge {
1013
1060
 
1014
1061
  const question = pending.questions[pending.index];
1015
1062
  if (!question) return;
1016
- pending.answers.push(harnessAnswerForQuestion(question, text));
1063
+ const answer = typeof resolveAnswer === 'function'
1064
+ ? resolveAnswer(question)
1065
+ : harnessAnswerForQuestion(question, text);
1066
+ if (!answer) return;
1067
+ // Retire this question's keyboard before moving on: the answer is already in,
1068
+ // and a card left behind in the chat stays pressable after the batch advances.
1069
+ await this.#retireInteractionCard(pending.target, pending.cardMessageId);
1070
+ pending.cardMessageId = null;
1071
+ pending.answers.push(answer);
1017
1072
  pending.index += 1;
1018
1073
  if (pending.index < pending.questions.length) {
1019
1074
  if (pending.claimedReplyMessageId === messageId) {
@@ -1090,6 +1145,114 @@ export class TextHarnessBridge {
1090
1145
  }
1091
1146
  }
1092
1147
 
1148
+ /** Acknowledge a press so the client stops its spinner; never fails the answer. */
1149
+ async #answerInteractionCallback(callback, text) {
1150
+ if (typeof this.#bot.answerInteractionCallback !== 'function') return;
1151
+ try {
1152
+ await this.#bot.answerInteractionCallback(callback.callbackQueryId, text);
1153
+ } catch (error) {
1154
+ this.#logger.warn?.(
1155
+ `[dsh-im:${this.#descriptor.key}] could not acknowledge a button press:`,
1156
+ error?.message ?? error,
1157
+ );
1158
+ }
1159
+ }
1160
+
1161
+ /** Drop a handled card's keyboard so the same press cannot be submitted twice. */
1162
+ async #retireInteractionCard(target, providerMessageId) {
1163
+ if (!providerMessageId || typeof this.#bot.updateInteractionCard !== 'function') return;
1164
+ try {
1165
+ await this.#bot.updateInteractionCard(target, providerMessageId, {
1166
+ markup: { inline_keyboard: [] },
1167
+ });
1168
+ } catch (error) {
1169
+ this.#logger.warn?.(
1170
+ `[dsh-im:${this.#descriptor.key}] could not retire an interaction card:`,
1171
+ error?.message ?? error,
1172
+ );
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Accept an inline-keyboard press. The press is replayed as the option label the
1178
+ * text flow already understands, so a button and a typed reply share one
1179
+ * submission path and cannot drift apart.
1180
+ */
1181
+ async acceptCallback(callback) {
1182
+ if (this.#signal?.aborted) return;
1183
+ const conversationId = cleanText(callback?.conversationId);
1184
+ const senderId = cleanText(callback?.senderId);
1185
+ const messageId = cleanText(callback?.messageId);
1186
+ if (!conversationId || !senderId || !messageId || callback?.senderIsBot === true) return;
1187
+ const kind = callback.kind === 'group' ? 'group' : 'direct';
1188
+ const key = `${kind}:${conversationId}`;
1189
+ const pending = this.#pendingInteractions.get(key);
1190
+ const notice = (text) => this.#answerInteractionCallback(callback, text);
1191
+ if (!pending) return notice(t('该问题已处理,无需再次选择。'));
1192
+ if (pending.actor !== senderId) {
1193
+ return notice(t('只有发起当前任务的用户可以处理这条问题。'));
1194
+ }
1195
+ // A press can arrive while an earlier one is still being submitted: the
1196
+ // acknowledgement round-trip is a real window, and a user who sees no feedback
1197
+ // presses again. Claim synchronously, before the first await, so the second
1198
+ // press cannot advance the same question twice.
1199
+ if (pending.submitting || pending.callbackClaimed) {
1200
+ return notice(t('正在提交你的选择,请稍候。'));
1201
+ }
1202
+
1203
+ const question = pending.questions[pending.index];
1204
+ const parsed = this.#interactionCard?.parse?.(callback.data) ?? null;
1205
+ const option = parsed && Array.isArray(question?.options)
1206
+ ? question.options[parsed.optionIndex]
1207
+ : undefined;
1208
+ if (!question || !parsed
1209
+ || !pending.cardNonce || parsed.nonce !== pending.cardNonce
1210
+ || parsed.questionIndex !== pending.index
1211
+ || !option || typeof option.label !== 'string') {
1212
+ return notice(t('这个选项已失效,请使用最新一条问题。'));
1213
+ }
1214
+ // Multi-select needs a keyboard that accumulates choices and a submit action;
1215
+ // until that exists the request keeps the text answer it has always accepted.
1216
+ if (question.multiSelect === true) {
1217
+ return notice(t('多选问题请直接回复文字。'));
1218
+ }
1219
+
1220
+ pending.callbackClaimed = true;
1221
+ // A press can beat the card's own send promise — Telegram delivers the update
1222
+ // as soon as its API accepted the keyboard. Wait for delivery so the card id
1223
+ // exists and the shared submission path can retire its keyboard.
1224
+ await pending.presentationTask?.catch(() => undefined);
1225
+ await notice(t('已选择:{label}', { label: option.label }));
1226
+ await this.#processInteractionReply(
1227
+ {
1228
+ kind,
1229
+ conversationId,
1230
+ messageId,
1231
+ senderId,
1232
+ addressed: true,
1233
+ content: option.label,
1234
+ replyTarget: callback.replyTarget ?? pending.target,
1235
+ statusReaction: null,
1236
+ },
1237
+ messageId,
1238
+ senderId,
1239
+ key,
1240
+ pending,
1241
+ // The press already identifies its option by index, so answer with that
1242
+ // exact label. Replaying the label as reply text would re-parse a numeric
1243
+ // label such as "2" as the second option and submit a different one.
1244
+ { resolveAnswer: (current) => ({ id: current.id, selected: [option.label] }) },
1245
+ ).catch((error) => {
1246
+ this.#logger.error?.(
1247
+ `[dsh-im:${this.#descriptor.key}] failed to submit a card answer:`,
1248
+ error,
1249
+ );
1250
+ }).finally(() => {
1251
+ // A failed submission rolls the question back, so let the user press again.
1252
+ if (this.#pendingInteractions.get(key) === pending) pending.callbackClaimed = false;
1253
+ });
1254
+ }
1255
+
1093
1256
  async #handleInteraction(interaction, {
1094
1257
  key,
1095
1258
  actor,
@@ -1172,6 +1335,11 @@ export class TextHarnessBridge {
1172
1335
  submitting: false,
1173
1336
  needsPresentation: true,
1174
1337
  presentationTask: null,
1338
+ cardMessageId: null,
1339
+ // Identity of the keyboard currently on screen, and the guard that keeps two
1340
+ // presses of the same card from advancing one question twice.
1341
+ cardNonce: null,
1342
+ callbackClaimed: false,
1175
1343
  };
1176
1344
  this.#pendingInteractions.set(key, pending);
1177
1345
  this.#interactionKeys.set(interactionId, key);
@@ -1190,11 +1358,59 @@ export class TextHarnessBridge {
1190
1358
  this.#clearPendingInteraction(key, interactionId);
1191
1359
  }
1192
1360
 
1361
+ /** Build card content for the current question, or null to keep the text flow. */
1362
+ #interactionCardFor(pending, question) {
1363
+ if (!this.#interactionCard || typeof this.#interactionCard.render !== 'function') return null;
1364
+ if (typeof this.#bot.sendInteractionCard !== 'function') return null;
1365
+ if (pending.submitting) return null;
1366
+ let card = null;
1367
+ try {
1368
+ card = this.#interactionCard.render(question, {
1369
+ questionIndex: pending.index,
1370
+ total: pending.questions.length,
1371
+ requiresMention: pending.requiresMention,
1372
+ nonce: pending.cardNonce,
1373
+ });
1374
+ } catch (error) {
1375
+ this.#logger.warn?.(
1376
+ `[dsh-im:${this.#descriptor.key}] interaction card renderer failed:`,
1377
+ error,
1378
+ );
1379
+ return null;
1380
+ }
1381
+ if (!card || typeof card.text !== 'string' || !card.text || !card.markup) return null;
1382
+ return card;
1383
+ }
1384
+
1193
1385
  #presentInteraction(pending) {
1194
1386
  if (pending.presentationTask) return pending.presentationTask;
1195
1387
  const question = pending.questions[pending.index];
1196
1388
  if (!question) return Promise.resolve();
1197
1389
  const task = (async () => {
1390
+ // A fresh nonce per presentation: a later question restarts its indexes at
1391
+ // zero, so without one an old card's keyboard would answer the new question.
1392
+ pending.cardNonce = randomUUID().slice(0, 8);
1393
+ const card = this.#interactionCardFor(pending, question);
1394
+ if (card) {
1395
+ try {
1396
+ const sent = await this.#bot.sendInteractionCard(pending.target, {
1397
+ text: card.text,
1398
+ markup: card.markup,
1399
+ });
1400
+ pending.cardMessageId = sent?.providerMessageIds?.at(-1) ?? null;
1401
+ pending.needsPresentation = false;
1402
+ return;
1403
+ } catch (error) {
1404
+ // A platform that refuses the keyboard must not lose the question:
1405
+ // fall through to the plain-text flow this channel already had.
1406
+ pending.cardMessageId = null;
1407
+ this.#logger.warn?.(
1408
+ `[dsh-im:${this.#descriptor.key}] could not deliver an interaction card; `
1409
+ + 'falling back to plain text:',
1410
+ error?.message ?? error,
1411
+ );
1412
+ }
1413
+ }
1198
1414
  await this.#bot.sendText(
1199
1415
  pending.target,
1200
1416
  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,16 @@ 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.
85
+ *
86
+ * `sourceGuidance` is the guidance the channel's captured enhancement settings
87
+ * applied, carried to the Host out of band so it can materialize it as session
88
+ * prompt context. It is never re-derived from the prompt, which also carries
89
+ * whatever the user typed.
80
90
  */
81
91
  export async function askInWorkspaceSession({
82
92
  harness,
@@ -84,6 +94,8 @@ export async function askInWorkspaceSession({
84
94
  key,
85
95
  text,
86
96
  content,
97
+ titleText,
98
+ sourceGuidance,
87
99
  contextEnhanced = false,
88
100
  createOptions,
89
101
  existsOptions,
@@ -92,7 +104,7 @@ export async function askInWorkspaceSession({
92
104
  }) {
93
105
  const initialTitle = contextEnhanced
94
106
  ? initialSessionTitle({
95
- text,
107
+ text: titleText ?? text,
96
108
  content,
97
109
  files: typeof askOptions === 'object' ? askOptions?.files : undefined,
98
110
  })
@@ -145,6 +157,9 @@ export async function askInWorkspaceSession({
145
157
  const artifactOptions = typeof askOptions === 'number'
146
158
  ? { timeoutMs: askOptions }
147
159
  : { ...askOptions };
160
+ // The guidance the channel's captured settings applied, carried out of
161
+ // band so the Host never has to read configuration out of the prompt.
162
+ artifactOptions.sourceGuidance = sourceGuidance;
148
163
  artifactOptions.onArtifact = async (artifact) => {
149
164
  artifacts.push(artifact);
150
165
  await originalOnArtifact?.(artifact);
@@ -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) {