@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.
- package/README.en.md +21 -9
- package/README.md +21 -9
- package/lib/client.js +108 -21
- package/lib/index.js +276 -276
- package/package.json +17 -1
- package/plugin-src/client/channel-logos.js +18 -5
- package/plugin-src/client/channels/imessage/styles.js +1 -1
- package/plugin-src/client/channels/slack/styles.js +1 -1
- package/plugin-src/client/channels/weixin/connection-error.js +4 -1
- package/plugin-src/client/i18n.js +8 -0
- package/plugin-src/client/model-setting.js +4 -2
- package/plugin-src/client/session-channel-logos.js +1 -2
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +2 -1
- package/plugin-src/host/modern-harness-api.mjs +91 -3
- package/scripts/verify-model-setting.mjs +4 -1
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +9 -1
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +9 -1
- package/src/channels/qq/qq-controller.mjs +11 -5
- package/src/channels/qq/state-error.mjs +17 -0
- package/src/channels/qq/state-store.mjs +35 -9
- package/src/channels/shared/batch-input.mjs +22 -2
- package/src/channels/shared/bot-workspace-store.mjs +46 -26
- package/src/channels/shared/config-read-error.mjs +24 -0
- package/src/channels/shared/harness-client.mjs +13 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/qq.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +198 -3
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +7 -1
- package/src/channels/slack/slack-runtime.mjs +3 -1
- package/src/channels/telegram/telegram-api.mjs +62 -2
- package/src/channels/telegram/telegram-bridge.mjs +66 -1
- package/src/channels/telegram/telegram-rich-message.mjs +6 -4
- package/src/channels/telegram/telegram-runtime.mjs +128 -6
- package/src/channels/wecom/wecom-bridge.mjs +4 -0
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
- package/src/channels/weixin/config-store.mjs +24 -15
- package/src/channels/weixin/connection-error.en.mjs +21 -0
- package/src/channels/weixin/connection-error.mjs +21 -6
- package/src/channels/weixin/diagnostic-details.mjs +24 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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 (!
|
|
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
|
-
|| !
|
|
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
|
-
.
|
|
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({
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
return Array.from(value, (character) => {
|
|
68
|
+
if (character === '&') return '&';
|
|
69
|
+
if (character === '<') return '<';
|
|
70
|
+
if (character === '>') return '>';
|
|
71
|
+
return character;
|
|
72
|
+
}).join('');
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function plainRichChunks(source, limit) {
|