@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.
- package/README.en.md +21 -9
- package/README.md +21 -9
- package/lib/client.js +116 -21
- package/lib/index.js +288 -287
- 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/index.mjs +7 -0
- package/plugin-src/host/injected-context.mjs +104 -0
- 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 +105 -27
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +44 -3
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +36 -3
- 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/context-enhancement.mjs +40 -3
- package/src/channels/shared/control-command.mjs +8 -1
- package/src/channels/shared/harness-client.mjs +20 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
- 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/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/im-source-guidance.mjs +65 -0
- package/src/channels/shared/injected-context.mjs +362 -0
- package/src/channels/shared/semantic/artifact.mjs +4 -4
- package/src/channels/shared/semantic/reply-reference.mjs +2 -1
- package/src/channels/shared/text-harness-bridge.mjs +221 -5
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +16 -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 +15 -1
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
- 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-api.mjs +52 -13
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
-
.
|
|
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) {
|