@dsh-enhanced/lark-channel 0.1.17 → 0.1.18
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.md +12 -1
- package/lib/adapter.d.ts +20 -1
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +529 -1
- package/lib/adapter.js.map +1 -1
- package/lib/config.d.ts +1 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +3 -0
- package/lib/config.js.map +1 -1
- package/lib/sdk.d.ts.map +1 -1
- package/lib/sdk.js +121 -0
- package/lib/sdk.js.map +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1 -0
- package/lib/service.js.map +1 -1
- package/lib/types.d.ts +60 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/user-question.d.ts +31 -0
- package/lib/user-question.d.ts.map +1 -0
- package/lib/user-question.js +161 -0
- package/lib/user-question.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +12 -12
package/lib/adapter.js
CHANGED
|
@@ -7,10 +7,14 @@ import { LarkPermissionPickerError, parseLarkPermissionPickerCallback, signLarkP
|
|
|
7
7
|
import { LarkProgressPresenter } from './progress.js';
|
|
8
8
|
import { LARK_APPROVAL_CARD_MAX_BYTES, renderLarkMessage } from './sdk.js';
|
|
9
9
|
import { LarkToolApprovalError, signLarkToolApprovalAction, verifyLarkToolApprovalAction, } from './tool-approval.js';
|
|
10
|
+
import { LarkUserQuestionError, parseLarkUserQuestionCallback, signLarkUserQuestionAction, verifyLarkUserQuestionAction, } from './user-question.js';
|
|
10
11
|
import { LarkTransportError, } from './types.js';
|
|
11
12
|
const LARK_TOOL_APPROVAL_REASON_MAX_BYTES = 2 * 1_024;
|
|
12
13
|
const LARK_TOOL_APPROVAL_ARGUMENTS_MAX_BYTES = 16 * 1_024;
|
|
13
14
|
const LARK_TOOL_APPROVAL_MAX_TIMER_MS = 2_147_483_647;
|
|
15
|
+
const LARK_USER_QUESTION_MAX_QUESTIONS = 16;
|
|
16
|
+
const LARK_USER_QUESTION_MAX_OPTIONS = 20;
|
|
17
|
+
const LARK_USER_QUESTION_MAX_TEXT_BYTES = 16 * 1_024;
|
|
14
18
|
const larkCallbackIdentifier = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,255}$/u;
|
|
15
19
|
const larkProviderMessageIdentifier = /^[A-Za-z0-9][A-Za-z0-9._@/-]{0,255}$/u;
|
|
16
20
|
function hasUsableApprovalSecret(secret) {
|
|
@@ -20,6 +24,61 @@ function boundedReviewText(value, maxBytes) {
|
|
|
20
24
|
return value === undefined || (Buffer.byteLength(value, 'utf8') <= maxBytes
|
|
21
25
|
&& !/(?:\p{Cc}|\p{Bidi_Control})/u.test(value.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', '')));
|
|
22
26
|
}
|
|
27
|
+
function samePrincipal(left, right) {
|
|
28
|
+
return left.channel === right.channel
|
|
29
|
+
&& left.account === right.account
|
|
30
|
+
&& left.tenant === right.tenant
|
|
31
|
+
&& left.user === right.user;
|
|
32
|
+
}
|
|
33
|
+
function sameConversation(left, right) {
|
|
34
|
+
return left.channel === right.channel
|
|
35
|
+
&& left.account === right.account
|
|
36
|
+
&& left.tenant === right.tenant
|
|
37
|
+
&& left.kind === right.kind
|
|
38
|
+
&& left.chat === right.chat
|
|
39
|
+
&& left.thread === right.thread;
|
|
40
|
+
}
|
|
41
|
+
function validUserQuestionRequest(input) {
|
|
42
|
+
if (!larkCallbackIdentifier.test(input.operationId)
|
|
43
|
+
|| !larkCallbackIdentifier.test(input.bindingId)
|
|
44
|
+
|| !larkCallbackIdentifier.test(input.sessionId)
|
|
45
|
+
|| !Number.isSafeInteger(input.bindingVersion) || input.bindingVersion < 1
|
|
46
|
+
|| !Number.isSafeInteger(input.bindingGeneration) || input.bindingGeneration < 1
|
|
47
|
+
|| input.questions.length < 1 || input.questions.length > LARK_USER_QUESTION_MAX_QUESTIONS) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const ids = new Set();
|
|
51
|
+
for (const question of input.questions) {
|
|
52
|
+
if (typeof question.id !== 'string' || question.id.length === 0 || ids.has(question.id)
|
|
53
|
+
|| typeof question.question !== 'string' || question.question.trim().length === 0
|
|
54
|
+
|| !boundedReviewText(question.question, LARK_USER_QUESTION_MAX_TEXT_BYTES)
|
|
55
|
+
|| !boundedReviewText(question.detail, LARK_USER_QUESTION_MAX_TEXT_BYTES)
|
|
56
|
+
|| !boundedReviewText(question.header, 1_024)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
ids.add(question.id);
|
|
60
|
+
const options = question.options ?? [];
|
|
61
|
+
if (options.length > LARK_USER_QUESTION_MAX_OPTIONS)
|
|
62
|
+
return false;
|
|
63
|
+
const labels = new Set();
|
|
64
|
+
for (const option of options) {
|
|
65
|
+
if (typeof option.label !== 'string' || option.label.trim().length === 0
|
|
66
|
+
|| labels.has(option.label)
|
|
67
|
+
|| !boundedReviewText(option.label, 1_024)
|
|
68
|
+
|| !boundedReviewText(option.description, 4_096)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
labels.add(option.label);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
function displayOptionLabel(label) {
|
|
77
|
+
const recommended = /(?:\s*\(Recommended\)|\s*(推荐))\s*$/u;
|
|
78
|
+
return recommended.test(label)
|
|
79
|
+
? { label: label.replace(recommended, '').trim() || label, recommended: true }
|
|
80
|
+
: { label, recommended: false };
|
|
81
|
+
}
|
|
23
82
|
function sameToolApprovalPayload(left, right) {
|
|
24
83
|
return left.version === right.version
|
|
25
84
|
&& left.channel === right.channel
|
|
@@ -246,8 +305,11 @@ export class LarkDeliveryAdapter {
|
|
|
246
305
|
loadModelPicker;
|
|
247
306
|
advanceModelPicker;
|
|
248
307
|
statusReactions;
|
|
308
|
+
userQuestionTtlMs;
|
|
249
309
|
progressPresenter;
|
|
250
310
|
pendingToolApprovals = new Map();
|
|
311
|
+
pendingUserQuestions = new Map();
|
|
312
|
+
userQuestionAnswerEvents = new Map();
|
|
251
313
|
toolApprovalTombstones = new Map();
|
|
252
314
|
modelSelectionUpdates = new Map();
|
|
253
315
|
state = 'disconnected';
|
|
@@ -267,6 +329,7 @@ export class LarkDeliveryAdapter {
|
|
|
267
329
|
: ['plain', 'markdown', 'approval', 'model-picker'],
|
|
268
330
|
inboundImages: typeof transport.downloadMessageImage === 'function',
|
|
269
331
|
toolApprovals: hasUsableApprovalSecret(options.approvalSecret),
|
|
332
|
+
userQuestions: hasUsableApprovalSecret(options.approvalSecret),
|
|
270
333
|
});
|
|
271
334
|
this.now = options.now ?? Date.now;
|
|
272
335
|
this.approvalSecret = options.approvalSecret;
|
|
@@ -278,15 +341,32 @@ export class LarkDeliveryAdapter {
|
|
|
278
341
|
this.loadModelPicker = options.loadModelPicker;
|
|
279
342
|
this.advanceModelPicker = options.advanceModelPicker;
|
|
280
343
|
this.statusReactions = options.statusReactions ?? true;
|
|
344
|
+
this.userQuestionTtlMs = options.userQuestionTtlMs ?? 24 * 60 * 60 * 1_000;
|
|
281
345
|
this.progressPresenter = new LarkProgressPresenter(transport, options.showProgress ?? true, options.progressDetails ?? 'direct', error => this.recordPresentationFailure(error));
|
|
282
346
|
}
|
|
283
347
|
async start(context) {
|
|
284
348
|
this.state = 'connecting';
|
|
285
349
|
const unsubscribe = this.transport.subscribe({
|
|
286
350
|
message: async (message) => {
|
|
287
|
-
|
|
351
|
+
// A reply to a live question card is already an explicit interaction
|
|
352
|
+
// with the bot. Lark does not necessarily mark that reply as an @mention,
|
|
353
|
+
// so let only this narrowly identified candidate pass normalization's
|
|
354
|
+
// group mention gate. If the full owner/route/message fences below do
|
|
355
|
+
// not accept it as an answer, ignore it instead of enqueueing a turn.
|
|
356
|
+
const unmentionedQuestionReply = message.chatType === 'group'
|
|
357
|
+
&& this.config.requireMentionInGroups
|
|
358
|
+
&& !message.mentionedBot
|
|
359
|
+
&& message.replyToMessageId !== undefined
|
|
360
|
+
&& [...this.pendingUserQuestions.values()].some(pending => pending.providerMessageId === message.replyToMessageId);
|
|
361
|
+
const normalized = normalizeLarkMessage(unmentionedQuestionReply
|
|
362
|
+
? { ...this.config, requireMentionInGroups: false }
|
|
363
|
+
: this.config, message, this.now());
|
|
288
364
|
if (normalized.outcome !== 'accept')
|
|
289
365
|
return;
|
|
366
|
+
if (await this.tryAnswerUserQuestion(normalized.envelope, message.messageId))
|
|
367
|
+
return;
|
|
368
|
+
if (unmentionedQuestionReply)
|
|
369
|
+
return;
|
|
290
370
|
const accepted = await context.accept(normalized.envelope);
|
|
291
371
|
if (this.statusReactions && !accepted.duplicate && accepted.status === 'queued') {
|
|
292
372
|
// The durable Inbox acknowledgement is the critical path. A slow or
|
|
@@ -317,6 +397,7 @@ export class LarkDeliveryAdapter {
|
|
|
317
397
|
unsubscribe();
|
|
318
398
|
this.state = 'disconnected';
|
|
319
399
|
this.cancelPendingToolApprovals('unavailable', true);
|
|
400
|
+
this.cancelPendingUserQuestions();
|
|
320
401
|
await this.transport.disconnect().catch(() => { });
|
|
321
402
|
throw error;
|
|
322
403
|
}
|
|
@@ -328,6 +409,8 @@ export class LarkDeliveryAdapter {
|
|
|
328
409
|
unsubscribe();
|
|
329
410
|
this.state = 'disconnected';
|
|
330
411
|
this.cancelPendingToolApprovals('unavailable', true);
|
|
412
|
+
this.cancelPendingUserQuestions();
|
|
413
|
+
this.clearUserQuestionAnswerEvents();
|
|
331
414
|
this.cancelModelSelectionUpdates();
|
|
332
415
|
await this.transport.disconnect();
|
|
333
416
|
};
|
|
@@ -458,6 +541,117 @@ export class LarkDeliveryAdapter {
|
|
|
458
541
|
});
|
|
459
542
|
return outcome;
|
|
460
543
|
}
|
|
544
|
+
async requestUserQuestion(input, signal) {
|
|
545
|
+
if (signal.aborted
|
|
546
|
+
|| !hasUsableApprovalSecret(this.approvalSecret)
|
|
547
|
+
|| (this.state !== 'connected' && this.state !== 'connected-with-gap')
|
|
548
|
+
|| !this.matchesTarget(input.target)
|
|
549
|
+
|| !larkCallbackIdentifier.test(input.target.conversation.chat)
|
|
550
|
+
|| !larkCallbackIdentifier.test(input.target.principal.user)
|
|
551
|
+
|| !validUserQuestionRequest(input)) {
|
|
552
|
+
return { outcome: 'unavailable' };
|
|
553
|
+
}
|
|
554
|
+
let now;
|
|
555
|
+
try {
|
|
556
|
+
now = this.now();
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return { outcome: 'unavailable' };
|
|
560
|
+
}
|
|
561
|
+
const expiresAt = now + this.userQuestionTtlMs;
|
|
562
|
+
if (!Number.isSafeInteger(now) || now < 0
|
|
563
|
+
|| !Number.isSafeInteger(this.userQuestionTtlMs) || this.userQuestionTtlMs < 1
|
|
564
|
+
|| !Number.isSafeInteger(expiresAt)) {
|
|
565
|
+
return { outcome: 'unavailable' };
|
|
566
|
+
}
|
|
567
|
+
const requestHash = createHash('sha256')
|
|
568
|
+
.update(JSON.stringify(input.questions), 'utf8')
|
|
569
|
+
.digest('hex');
|
|
570
|
+
const existing = this.pendingUserQuestions.get(input.operationId);
|
|
571
|
+
if (existing !== undefined) {
|
|
572
|
+
return existing.requestHash === requestHash
|
|
573
|
+
&& existing.input.sessionId === input.sessionId
|
|
574
|
+
&& existing.input.bindingId === input.bindingId
|
|
575
|
+
&& existing.input.bindingVersion === input.bindingVersion
|
|
576
|
+
&& existing.input.bindingGeneration === input.bindingGeneration
|
|
577
|
+
&& samePrincipal(existing.input.target.principal, input.target.principal)
|
|
578
|
+
&& sameConversation(existing.input.target.conversation, input.target.conversation)
|
|
579
|
+
? existing.outcome
|
|
580
|
+
: { outcome: 'unavailable' };
|
|
581
|
+
}
|
|
582
|
+
let resolve;
|
|
583
|
+
const outcome = new Promise(settle => { resolve = settle; });
|
|
584
|
+
let pending;
|
|
585
|
+
const abort = () => {
|
|
586
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
587
|
+
};
|
|
588
|
+
pending = {
|
|
589
|
+
input,
|
|
590
|
+
payload: {
|
|
591
|
+
version: 1,
|
|
592
|
+
channel: this.channel,
|
|
593
|
+
account: this.account,
|
|
594
|
+
tenant: this.config.tenant,
|
|
595
|
+
rpcId: input.operationId,
|
|
596
|
+
sessionId: input.sessionId,
|
|
597
|
+
bindingId: input.bindingId,
|
|
598
|
+
bindingVersion: input.bindingVersion,
|
|
599
|
+
bindingGeneration: input.bindingGeneration,
|
|
600
|
+
chatId: input.target.conversation.chat,
|
|
601
|
+
ownerUser: input.target.principal.user,
|
|
602
|
+
requestHash,
|
|
603
|
+
expiresAt,
|
|
604
|
+
},
|
|
605
|
+
requestHash,
|
|
606
|
+
signal,
|
|
607
|
+
abort,
|
|
608
|
+
resolve,
|
|
609
|
+
outcome,
|
|
610
|
+
answers: [],
|
|
611
|
+
selectedOptionIndexes: new Set(),
|
|
612
|
+
questionIndex: 0,
|
|
613
|
+
revision: 0,
|
|
614
|
+
};
|
|
615
|
+
let card;
|
|
616
|
+
try {
|
|
617
|
+
card = { userQuestion: this.userQuestionCard(pending) };
|
|
618
|
+
if (Buffer.byteLength(renderLarkMessage(card).content, 'utf8') > LARK_APPROVAL_CARD_MAX_BYTES) {
|
|
619
|
+
return { outcome: 'unavailable' };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return { outcome: 'unavailable' };
|
|
624
|
+
}
|
|
625
|
+
this.pendingUserQuestions.set(input.operationId, pending);
|
|
626
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
627
|
+
if (signal.aborted) {
|
|
628
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', false);
|
|
629
|
+
return outcome;
|
|
630
|
+
}
|
|
631
|
+
this.armUserQuestionExpiry(pending);
|
|
632
|
+
let sending;
|
|
633
|
+
try {
|
|
634
|
+
sending = Promise.resolve(this.transport.send(input.target.conversation.chat, card, this.userQuestionSendOptions(pending)));
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
this.recordPresentationFailure(error);
|
|
638
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', false);
|
|
639
|
+
return outcome;
|
|
640
|
+
}
|
|
641
|
+
void sending.then(result => {
|
|
642
|
+
if (this.pendingUserQuestions.get(input.operationId) !== pending)
|
|
643
|
+
return;
|
|
644
|
+
if (!larkProviderMessageIdentifier.test(result.messageId)) {
|
|
645
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', false);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
pending.providerMessageId = result.messageId;
|
|
649
|
+
}, error => {
|
|
650
|
+
this.recordPresentationFailure(error);
|
|
651
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', false);
|
|
652
|
+
});
|
|
653
|
+
return outcome;
|
|
654
|
+
}
|
|
461
655
|
async readInboundImage(input, signal) {
|
|
462
656
|
if (input.attachment.resourceType !== 'image') {
|
|
463
657
|
return { outcome: 'not-downloaded', failureCode: 'lark-image-type-unsupported', retryable: false };
|
|
@@ -676,6 +870,237 @@ export class LarkDeliveryAdapter {
|
|
|
676
870
|
&& conversation.tenant === this.config.tenant
|
|
677
871
|
&& principal.tenant === this.config.tenant;
|
|
678
872
|
}
|
|
873
|
+
userQuestionCard(pending) {
|
|
874
|
+
if (!hasUsableApprovalSecret(this.approvalSecret)) {
|
|
875
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question signing is unavailable');
|
|
876
|
+
}
|
|
877
|
+
const question = pending.input.questions[pending.questionIndex];
|
|
878
|
+
if (question === undefined) {
|
|
879
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question index is invalid');
|
|
880
|
+
}
|
|
881
|
+
const callbackValue = (action, optionIndex) => ({
|
|
882
|
+
userQuestion: signLarkUserQuestionAction(this.approvalSecret, {
|
|
883
|
+
...pending.payload,
|
|
884
|
+
questionIndex: pending.questionIndex,
|
|
885
|
+
optionIndex,
|
|
886
|
+
action,
|
|
887
|
+
revision: pending.revision,
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
const options = (question.options ?? []).map((option, optionIndex) => {
|
|
891
|
+
const display = displayOptionLabel(option.label);
|
|
892
|
+
return {
|
|
893
|
+
label: display.label,
|
|
894
|
+
...(option.description === undefined ? {} : { description: option.description }),
|
|
895
|
+
recommended: display.recommended,
|
|
896
|
+
selected: pending.selectedOptionIndexes.has(optionIndex),
|
|
897
|
+
value: callbackValue(question.multiSelect === true ? 'toggle' : 'select', optionIndex),
|
|
898
|
+
};
|
|
899
|
+
});
|
|
900
|
+
return {
|
|
901
|
+
title: question.header?.trim() || '需要你的回答',
|
|
902
|
+
question: question.question.trim(),
|
|
903
|
+
...(question.detail === undefined ? {} : { detail: question.detail }),
|
|
904
|
+
position: pending.questionIndex + 1,
|
|
905
|
+
total: pending.input.questions.length,
|
|
906
|
+
multiSelect: question.multiSelect === true,
|
|
907
|
+
expectsText: options.length === 0,
|
|
908
|
+
options,
|
|
909
|
+
...(question.multiSelect === true && pending.selectedOptionIndexes.size > 0
|
|
910
|
+
? { submitValue: callbackValue('submit', null) }
|
|
911
|
+
: {}),
|
|
912
|
+
cancelValue: callbackValue('cancel', null),
|
|
913
|
+
answered: pending.answers.map((answer, index) => ({
|
|
914
|
+
title: pending.input.questions[index]?.header?.trim() || `问题 ${index + 1}`,
|
|
915
|
+
answer: this.userQuestionAnswerSummary(answer),
|
|
916
|
+
})),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
userQuestionAnswerSummary(answer) {
|
|
920
|
+
const parts = [...answer.selected, ...(answer.custom === undefined ? [] : [answer.custom])];
|
|
921
|
+
const value = parts.join(';');
|
|
922
|
+
return value.length <= 240 ? value : `${value.slice(0, 237)}...`;
|
|
923
|
+
}
|
|
924
|
+
userQuestionSendOptions(pending) {
|
|
925
|
+
const conversation = pending.input.target.conversation;
|
|
926
|
+
const providerReplyTarget = conversation.kind === 'group'
|
|
927
|
+
&& !isLarkTopLevelSenderThread(conversation.thread)
|
|
928
|
+
? conversation.thread
|
|
929
|
+
: undefined;
|
|
930
|
+
return {
|
|
931
|
+
requestKey: `user-question:${pending.input.operationId}:${pending.requestHash}:${pending.revision}`,
|
|
932
|
+
...(providerReplyTarget === undefined ? {} : { replyTo: providerReplyTarget, replyInThread: true }),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
async refreshUserQuestionCard(pending) {
|
|
936
|
+
if (this.pendingUserQuestions.get(pending.input.operationId) !== pending)
|
|
937
|
+
return;
|
|
938
|
+
const card = { userQuestion: this.userQuestionCard(pending) };
|
|
939
|
+
if (Buffer.byteLength(renderLarkMessage(card).content, 'utf8') > LARK_APPROVAL_CARD_MAX_BYTES) {
|
|
940
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const update = this.transport.updateRawCard;
|
|
944
|
+
if (pending.providerMessageId !== undefined && update !== undefined) {
|
|
945
|
+
try {
|
|
946
|
+
await update.call(this.transport, pending.providerMessageId, rawCard(card), new AbortController().signal);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
this.recordPresentationFailure(error);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
const result = await this.transport.send(pending.input.target.conversation.chat, card, this.userQuestionSendOptions(pending));
|
|
955
|
+
if (this.pendingUserQuestions.get(pending.input.operationId) !== pending)
|
|
956
|
+
return;
|
|
957
|
+
if (!larkProviderMessageIdentifier.test(result.messageId)) {
|
|
958
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
pending.providerMessageId = result.messageId;
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
this.recordPresentationFailure(error);
|
|
965
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
userQuestionResult(status, answerCount) {
|
|
969
|
+
if (status === 'answered') {
|
|
970
|
+
return { status, summary: `已提交 ${answerCount} 个回答,智能体正在继续处理。` };
|
|
971
|
+
}
|
|
972
|
+
if (status === 'cancelled') {
|
|
973
|
+
return { status, summary: '问题已取消,智能体将停止等待本次回答。' };
|
|
974
|
+
}
|
|
975
|
+
return { status, summary: '问题已在其他界面处理,或当前会话已经结束。' };
|
|
976
|
+
}
|
|
977
|
+
updateUserQuestionResult(pending, status) {
|
|
978
|
+
const update = this.transport.updateRawCard;
|
|
979
|
+
if (pending.providerMessageId === undefined || update === undefined)
|
|
980
|
+
return;
|
|
981
|
+
const card = rawCard({ userQuestionResult: this.userQuestionResult(status, pending.answers.length) });
|
|
982
|
+
void update.call(this.transport, pending.providerMessageId, card, new AbortController().signal).catch(error => this.recordPresentationFailure(error));
|
|
983
|
+
}
|
|
984
|
+
settleUserQuestion(pending, outcome, status, updatePresentation) {
|
|
985
|
+
if (this.pendingUserQuestions.get(pending.input.operationId) !== pending)
|
|
986
|
+
return false;
|
|
987
|
+
this.pendingUserQuestions.delete(pending.input.operationId);
|
|
988
|
+
pending.signal.removeEventListener('abort', pending.abort);
|
|
989
|
+
if (pending.timer !== undefined)
|
|
990
|
+
clearTimeout(pending.timer);
|
|
991
|
+
pending.resolve(outcome);
|
|
992
|
+
if (updatePresentation)
|
|
993
|
+
this.updateUserQuestionResult(pending, status);
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
armUserQuestionExpiry(pending) {
|
|
997
|
+
const expire = () => {
|
|
998
|
+
if (this.pendingUserQuestions.get(pending.input.operationId) !== pending)
|
|
999
|
+
return;
|
|
1000
|
+
let remaining;
|
|
1001
|
+
try {
|
|
1002
|
+
remaining = pending.payload.expiresAt - this.now();
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (!Number.isSafeInteger(remaining)) {
|
|
1009
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (remaining <= 0) {
|
|
1013
|
+
this.settleUserQuestion(pending, { outcome: 'cancelled' }, 'cancelled', true);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
pending.timer = setTimeout(expire, Math.min(remaining, LARK_TOOL_APPROVAL_MAX_TIMER_MS));
|
|
1017
|
+
pending.timer.unref();
|
|
1018
|
+
};
|
|
1019
|
+
expire();
|
|
1020
|
+
}
|
|
1021
|
+
cancelPendingUserQuestions() {
|
|
1022
|
+
for (const pending of this.pendingUserQuestions.values()) {
|
|
1023
|
+
this.settleUserQuestion(pending, { outcome: 'unavailable' }, 'resolved', true);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
recordUserQuestionAnswer(pending, answer) {
|
|
1027
|
+
pending.answers.push(answer);
|
|
1028
|
+
pending.selectedOptionIndexes.clear();
|
|
1029
|
+
if (pending.questionIndex + 1 < pending.input.questions.length) {
|
|
1030
|
+
pending.questionIndex += 1;
|
|
1031
|
+
pending.revision += 1;
|
|
1032
|
+
return undefined;
|
|
1033
|
+
}
|
|
1034
|
+
return { outcome: 'answered', answer: { answers: [...pending.answers] } };
|
|
1035
|
+
}
|
|
1036
|
+
rememberUserQuestionAnswerEvent(eventId) {
|
|
1037
|
+
if (this.userQuestionAnswerEvents.has(eventId))
|
|
1038
|
+
return;
|
|
1039
|
+
const timer = setTimeout(() => {
|
|
1040
|
+
this.userQuestionAnswerEvents.delete(eventId);
|
|
1041
|
+
}, Math.max(this.config.staleAfterMs, 60_000));
|
|
1042
|
+
timer.unref();
|
|
1043
|
+
this.userQuestionAnswerEvents.set(eventId, timer);
|
|
1044
|
+
}
|
|
1045
|
+
clearUserQuestionAnswerEvents() {
|
|
1046
|
+
for (const timer of this.userQuestionAnswerEvents.values())
|
|
1047
|
+
clearTimeout(timer);
|
|
1048
|
+
this.userQuestionAnswerEvents.clear();
|
|
1049
|
+
}
|
|
1050
|
+
async tryAnswerUserQuestion(envelope, providerMessageId) {
|
|
1051
|
+
if (this.userQuestionAnswerEvents.has(providerMessageId))
|
|
1052
|
+
return true;
|
|
1053
|
+
if (envelope.kind !== 'text' || envelope.attachments !== undefined)
|
|
1054
|
+
return false;
|
|
1055
|
+
const replyTo = envelope.metadata?.replyToProviderMessageId;
|
|
1056
|
+
const routeCandidates = [...this.pendingUserQuestions.values()].filter(pending => samePrincipal(pending.input.target.principal, envelope.principal)
|
|
1057
|
+
&& sameConversation(pending.input.target.conversation, envelope.conversation));
|
|
1058
|
+
let pending;
|
|
1059
|
+
if (replyTo !== undefined) {
|
|
1060
|
+
pending = [...this.pendingUserQuestions.values()].find(candidate => candidate.providerMessageId === replyTo
|
|
1061
|
+
&& samePrincipal(candidate.input.target.principal, envelope.principal)
|
|
1062
|
+
&& candidate.input.target.conversation.channel === envelope.conversation.channel
|
|
1063
|
+
&& candidate.input.target.conversation.account === envelope.conversation.account
|
|
1064
|
+
&& candidate.input.target.conversation.tenant === envelope.conversation.tenant
|
|
1065
|
+
&& candidate.input.target.conversation.kind === envelope.conversation.kind
|
|
1066
|
+
&& candidate.input.target.conversation.chat === envelope.conversation.chat);
|
|
1067
|
+
}
|
|
1068
|
+
else if (routeCandidates.length === 1) {
|
|
1069
|
+
pending = routeCandidates[0];
|
|
1070
|
+
}
|
|
1071
|
+
if (pending === undefined || pending.providerMessageId === undefined)
|
|
1072
|
+
return false;
|
|
1073
|
+
const question = pending.input.questions[pending.questionIndex];
|
|
1074
|
+
if (question === undefined)
|
|
1075
|
+
return false;
|
|
1076
|
+
const typed = envelope.text.trim();
|
|
1077
|
+
if (typed.length === 0)
|
|
1078
|
+
return false;
|
|
1079
|
+
const exactOption = (question.options ?? []).find(option => option.label === typed);
|
|
1080
|
+
const staged = [...pending.selectedOptionIndexes].sort((left, right) => left - right)
|
|
1081
|
+
.flatMap(index => question.options?.[index]?.label ?? []);
|
|
1082
|
+
const selected = exactOption === undefined
|
|
1083
|
+
? staged
|
|
1084
|
+
: question.multiSelect === true
|
|
1085
|
+
? [...new Set([...staged, exactOption.label])]
|
|
1086
|
+
: [exactOption.label];
|
|
1087
|
+
const custom = exactOption === undefined ? typed : undefined;
|
|
1088
|
+
const result = this.recordUserQuestionAnswer(pending, {
|
|
1089
|
+
id: question.id,
|
|
1090
|
+
selected,
|
|
1091
|
+
...(custom === undefined ? {} : { custom }),
|
|
1092
|
+
});
|
|
1093
|
+
this.rememberUserQuestionAnswerEvent(providerMessageId);
|
|
1094
|
+
if (result === undefined) {
|
|
1095
|
+
await this.refreshUserQuestionCard(pending);
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
this.settleUserQuestion(pending, result, 'answered', true);
|
|
1099
|
+
}
|
|
1100
|
+
if (this.statusReactions)
|
|
1101
|
+
void this.addReaction(providerMessageId, 'DONE');
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
679
1104
|
armToolApprovalTombstone(operationId, tombstone) {
|
|
680
1105
|
const expire = () => {
|
|
681
1106
|
if (this.toolApprovalTombstones.get(operationId) !== tombstone)
|
|
@@ -825,6 +1250,9 @@ export class LarkDeliveryAdapter {
|
|
|
825
1250
|
}
|
|
826
1251
|
return this.handleToolApprovalAction(action, value.toolApproval);
|
|
827
1252
|
}
|
|
1253
|
+
if (Object.prototype.hasOwnProperty.call(value, 'userQuestion')) {
|
|
1254
|
+
return await this.handleUserQuestionAction(action, parseLarkUserQuestionCallback(value));
|
|
1255
|
+
}
|
|
828
1256
|
if (Object.prototype.hasOwnProperty.call(value, 'permissionPicker')) {
|
|
829
1257
|
return await this.handlePermissionPickerAction(action, parseLarkPermissionPickerCallback(value));
|
|
830
1258
|
}
|
|
@@ -895,6 +1323,11 @@ export class LarkDeliveryAdapter {
|
|
|
895
1323
|
? '权限卡片已过期,请重新发送 /permission'
|
|
896
1324
|
: '权限卡片无效或已失效,请重新发送 /permission' } };
|
|
897
1325
|
}
|
|
1326
|
+
if (error instanceof LarkUserQuestionError) {
|
|
1327
|
+
return { toast: { type: 'warning', content: error.code === 'expired'
|
|
1328
|
+
? '问题卡片已过期,请重新向智能体发起请求'
|
|
1329
|
+
: '问题卡片已更新或失效,请使用最新卡片' } };
|
|
1330
|
+
}
|
|
898
1331
|
if (error instanceof LarkApprovalError || error instanceof LarkModelPickerError
|
|
899
1332
|
|| error instanceof LarkToolApprovalError) {
|
|
900
1333
|
this.lastErrorCode = 'format_error';
|
|
@@ -979,6 +1412,101 @@ export class LarkDeliveryAdapter {
|
|
|
979
1412
|
return { toast: { type: payload.decision === 'allowed-once' ? 'success' : 'info',
|
|
980
1413
|
content: payload.decision === 'allowed-once' ? '已仅允许本次工具调用' : '已拒绝工具调用' } };
|
|
981
1414
|
}
|
|
1415
|
+
async handleUserQuestionAction(action, callback) {
|
|
1416
|
+
if (!hasUsableApprovalSecret(this.approvalSecret) || action.tag !== 'button'
|
|
1417
|
+
|| !larkProviderMessageIdentifier.test(action.messageId)) {
|
|
1418
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question callback is unavailable');
|
|
1419
|
+
}
|
|
1420
|
+
const payload = verifyLarkUserQuestionAction(this.approvalSecret, callback.userQuestion, this.now());
|
|
1421
|
+
if (payload.channel !== this.channel || payload.account !== this.account
|
|
1422
|
+
|| payload.tenant !== this.config.tenant || payload.chatId !== action.chatId
|
|
1423
|
+
|| payload.ownerUser !== action.operatorId) {
|
|
1424
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question route does not match');
|
|
1425
|
+
}
|
|
1426
|
+
const pending = this.pendingUserQuestions.get(payload.rpcId);
|
|
1427
|
+
if (pending === undefined
|
|
1428
|
+
|| pending.providerMessageId !== action.messageId
|
|
1429
|
+
|| pending.payload.version !== payload.version
|
|
1430
|
+
|| pending.payload.channel !== payload.channel
|
|
1431
|
+
|| pending.payload.account !== payload.account
|
|
1432
|
+
|| pending.payload.tenant !== payload.tenant
|
|
1433
|
+
|| pending.payload.rpcId !== payload.rpcId
|
|
1434
|
+
|| pending.payload.sessionId !== payload.sessionId
|
|
1435
|
+
|| pending.payload.bindingId !== payload.bindingId
|
|
1436
|
+
|| pending.payload.bindingVersion !== payload.bindingVersion
|
|
1437
|
+
|| pending.payload.bindingGeneration !== payload.bindingGeneration
|
|
1438
|
+
|| pending.payload.chatId !== payload.chatId
|
|
1439
|
+
|| pending.payload.ownerUser !== payload.ownerUser
|
|
1440
|
+
|| pending.payload.requestHash !== payload.requestHash
|
|
1441
|
+
|| pending.payload.expiresAt !== payload.expiresAt
|
|
1442
|
+
|| pending.questionIndex !== payload.questionIndex
|
|
1443
|
+
|| pending.revision !== payload.revision) {
|
|
1444
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question request does not match');
|
|
1445
|
+
}
|
|
1446
|
+
const question = pending.input.questions[pending.questionIndex];
|
|
1447
|
+
if (question === undefined) {
|
|
1448
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question index is invalid');
|
|
1449
|
+
}
|
|
1450
|
+
if (payload.action === 'cancel') {
|
|
1451
|
+
this.settleUserQuestion(pending, { outcome: 'cancelled' }, 'cancelled', false);
|
|
1452
|
+
return {
|
|
1453
|
+
toast: { type: 'info', content: '已取消本次问题' },
|
|
1454
|
+
...callbackCard({ userQuestionResult: this.userQuestionResult('cancelled', pending.answers.length) }),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
const options = question.options ?? [];
|
|
1458
|
+
if (payload.action === 'toggle') {
|
|
1459
|
+
if (question.multiSelect !== true || payload.optionIndex === null
|
|
1460
|
+
|| options[payload.optionIndex] === undefined) {
|
|
1461
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question option is invalid');
|
|
1462
|
+
}
|
|
1463
|
+
if (pending.selectedOptionIndexes.has(payload.optionIndex)) {
|
|
1464
|
+
pending.selectedOptionIndexes.delete(payload.optionIndex);
|
|
1465
|
+
}
|
|
1466
|
+
else {
|
|
1467
|
+
pending.selectedOptionIndexes.add(payload.optionIndex);
|
|
1468
|
+
}
|
|
1469
|
+
pending.revision += 1;
|
|
1470
|
+
return {
|
|
1471
|
+
toast: { type: 'info', content: pending.selectedOptionIndexes.size === 0
|
|
1472
|
+
? '已清空选择'
|
|
1473
|
+
: `已选择 ${pending.selectedOptionIndexes.size} 项` },
|
|
1474
|
+
...callbackCard({ userQuestion: this.userQuestionCard(pending) }),
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
let answer;
|
|
1478
|
+
if (payload.action === 'select') {
|
|
1479
|
+
if (question.multiSelect === true || payload.optionIndex === null
|
|
1480
|
+
|| options[payload.optionIndex] === undefined) {
|
|
1481
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question option is invalid');
|
|
1482
|
+
}
|
|
1483
|
+
answer = { id: question.id, selected: [options[payload.optionIndex].label] };
|
|
1484
|
+
}
|
|
1485
|
+
else {
|
|
1486
|
+
if (question.multiSelect !== true || payload.optionIndex !== null
|
|
1487
|
+
|| pending.selectedOptionIndexes.size === 0) {
|
|
1488
|
+
throw new LarkUserQuestionError('invalid', 'Lark user question selection is empty');
|
|
1489
|
+
}
|
|
1490
|
+
answer = {
|
|
1491
|
+
id: question.id,
|
|
1492
|
+
selected: [...pending.selectedOptionIndexes]
|
|
1493
|
+
.sort((left, right) => left - right)
|
|
1494
|
+
.map(index => options[index].label),
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
const result = this.recordUserQuestionAnswer(pending, answer);
|
|
1498
|
+
if (result === undefined) {
|
|
1499
|
+
return {
|
|
1500
|
+
toast: { type: 'success', content: '回答已记录,请继续下一题' },
|
|
1501
|
+
...callbackCard({ userQuestion: this.userQuestionCard(pending) }),
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
this.settleUserQuestion(pending, result, 'answered', false);
|
|
1505
|
+
return {
|
|
1506
|
+
toast: { type: 'success', content: '回答已提交,智能体将继续处理' },
|
|
1507
|
+
...callbackCard({ userQuestionResult: this.userQuestionResult('answered', pending.answers.length) }),
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
982
1510
|
async handleModelPickerAction(action, callback) {
|
|
983
1511
|
if (this.approvalSecret === undefined || this.settleModelSelection === undefined
|
|
984
1512
|
|| !larkProviderMessageIdentifier.test(action.messageId)) {
|