@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/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
- const normalized = normalizeLarkMessage(this.config, message, this.now());
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)) {