@soimy/dingtalk 3.6.6 → 3.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.js +1433 -344
  2. package/dist/index.js.map +4 -4
  3. package/dist/src/card/ask-user-question-context.d.ts +5 -2
  4. package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
  5. package/dist/src/card/ask-user-question-store.d.ts +42 -0
  6. package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
  7. package/dist/src/card/ask-user-question.d.ts +20 -0
  8. package/dist/src/card/ask-user-question.d.ts.map +1 -1
  9. package/dist/src/card/card-action-handler.d.ts +1 -0
  10. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  11. package/dist/src/card-service.d.ts +4 -1
  12. package/dist/src/card-service.d.ts.map +1 -1
  13. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  14. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +23 -0
  15. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts.map +1 -0
  16. package/dist/src/gateway/inbound-session-queue.d.ts +46 -0
  17. package/dist/src/gateway/inbound-session-queue.d.ts.map +1 -0
  18. package/dist/src/gateway/reply-session-conflict.d.ts +22 -0
  19. package/dist/src/gateway/reply-session-conflict.d.ts.map +1 -0
  20. package/dist/src/inbound-handler.d.ts.map +1 -1
  21. package/dist/src/onboarding.d.ts.map +1 -1
  22. package/dist/src/targeting/agent-routing.d.ts +17 -1
  23. package/dist/src/targeting/agent-routing.d.ts.map +1 -1
  24. package/dist/src/types.d.ts +35 -0
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/access-control.ts +1 -1
  28. package/src/card/ask-user-question-context.ts +9 -1
  29. package/src/card/ask-user-question-store.ts +294 -0
  30. package/src/card/ask-user-question.ts +398 -37
  31. package/src/card/card-action-handler.ts +2 -0
  32. package/src/card-service.ts +55 -3
  33. package/src/gateway/channel-gateway.ts +51 -30
  34. package/src/gateway/inbound-session-queue-dispatcher.ts +304 -0
  35. package/src/gateway/inbound-session-queue.ts +244 -0
  36. package/src/gateway/reply-session-conflict.ts +82 -0
  37. package/src/inbound-handler.ts +254 -57
  38. package/src/onboarding.ts +6 -2
  39. package/src/targeting/agent-routing.ts +84 -19
  40. package/src/types.ts +36 -0
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
3
3
  import { getAccessToken } from "../auth";
4
4
  import { updateCardVariables } from "../card-callback-service";
5
5
  import { resolveRobotCode } from "../config";
@@ -11,6 +11,18 @@ import {
11
11
  getDingTalkQuestionContext,
12
12
  type DingTalkQuestionContext,
13
13
  } from "./ask-user-question-context";
14
+ import {
15
+ activateAskUserQuestion,
16
+ claimAskUserQuestion,
17
+ invalidateAskUserQuestionsInScope as invalidateAskUserQuestionsInStore,
18
+ recoverAskUserQuestionsAfterRestart,
19
+ reserveAskUserQuestion,
20
+ resolveAskUserQuestion,
21
+ terminateAskUserQuestion,
22
+ type AskUserLifecycleRecord,
23
+ type AskUserStoreOptions,
24
+ type AskUserTerminalReason,
25
+ } from "./ask-user-question-store";
14
26
  import { DINGTALK_ASK_USER_CARD_TEMPLATE } from "./card-template";
15
27
 
16
28
  const DINGTALK_API = "https://api.dingtalk.com";
@@ -439,9 +451,14 @@ function supersedePendingQuestionsInScope(ctx: PendingQuestion): void {
439
451
  }
440
452
  }
441
453
 
442
- function storePendingQuestion(ctx: PendingQuestion): void {
454
+ function storePendingQuestion(
455
+ ctx: PendingQuestion,
456
+ options: { supersedeExisting?: boolean } = {},
457
+ ): void {
443
458
  ctx.ownerUserId = resolvePendingQuestionOwner(ctx);
444
- supersedePendingQuestionsInScope(ctx);
459
+ if (options.supersedeExisting !== false) {
460
+ supersedePendingQuestionsInScope(ctx);
461
+ }
445
462
  pendingQuestionsByTrackId.set(ctx.outTrackId, ctx);
446
463
  pendingQuestionsByQuestionId.set(ctx.questionId, ctx);
447
464
  addScopeIndex(ctx);
@@ -449,7 +466,9 @@ function storePendingQuestion(ctx: PendingQuestion): void {
449
466
  if (!pendingQuestionsByTrackId.has(ctx.outTrackId) || ctx.submitted) {
450
467
  return;
451
468
  }
452
- ctx.submitted = true;
469
+ if (!claimPendingQuestionForDispatch(ctx)) {
470
+ return;
471
+ }
453
472
  consumePendingQuestion(ctx);
454
473
  addHandledQuestionTombstone(ctx, "expired");
455
474
  void updateQuestionCardBestEffort(ctx, {
@@ -457,14 +476,12 @@ function storePendingQuestion(ctx: PendingQuestion): void {
457
476
  question_desc: "问题已失效,请重新发起。",
458
477
  form_btn_text: "已失效",
459
478
  });
460
- setImmediate(() => {
461
- void injectAnswerSyntheticMessage(ctx, buildExpiredAnswerMessage(ctx), "expired").catch(
462
- (err) => {
463
- ctx.log?.error?.(
464
- `[DingTalk][AskUser] Failed to inject expired answer message: ${String(err)}`,
465
- );
466
- },
467
- );
479
+ dispatchSyntheticAnswer({
480
+ ctx,
481
+ text: buildExpiredAnswerMessage(ctx),
482
+ suffix: "expired",
483
+ successReason: "expired",
484
+ log: ctx.log,
468
485
  });
469
486
  }, PENDING_QUESTION_TTL_MS);
470
487
  }
@@ -499,6 +516,177 @@ async function updateQuestionCardBestEffort(
499
516
  }
500
517
  }
501
518
 
519
+ function getAskUserStoreOptions(
520
+ params: Pick<DingTalkQuestionContext, "storePath" | "accountId" | "log">,
521
+ ): AskUserStoreOptions | undefined {
522
+ if (!params.storePath) {
523
+ return undefined;
524
+ }
525
+ return {
526
+ storePath: params.storePath,
527
+ accountId: params.accountId,
528
+ log: params.log,
529
+ };
530
+ }
531
+
532
+ function terminalCardVariables(reason: AskUserTerminalReason): Record<string, unknown> {
533
+ const descriptions: Record<AskUserTerminalReason, string> = {
534
+ delivery_failed: "问题卡片发送失败。",
535
+ superseded_by_question: "已有新的问题卡片,请回答最新卡片。",
536
+ superseded_by_message: "你在问题卡片发出后发送了新消息,此卡已失效。请重新发起需要填写的问题。",
537
+ expired: "问题已失效,请重新发起。",
538
+ cancelled: "已取消。",
539
+ empty: "已提交,未填写任何内容。",
540
+ submitted: "已提交。",
541
+ pause_failed: "当前任务未能暂停,此卡已失效,请重新发起。",
542
+ restart_invalidated: "服务已重启,原问题上下文已失效,请重新发起。",
543
+ restart_during_dispatch: "服务在处理回答期间重启,本次处理结果可能未完成,请发送新消息继续。",
544
+ dispatch_failed: "回答已收到,但未能继续会话,请发送一条普通消息继续。",
545
+ };
546
+ return {
547
+ card_status:
548
+ reason === "cancelled"
549
+ ? "cancelled"
550
+ : reason === "submitted" || reason === "empty"
551
+ ? "submitted"
552
+ : "expired",
553
+ question_desc: descriptions[reason],
554
+ form_btn_text:
555
+ reason === "cancelled"
556
+ ? "已取消"
557
+ : reason === "submitted" || reason === "empty"
558
+ ? "已提交"
559
+ : "已失效",
560
+ };
561
+ }
562
+
563
+ async function updateLifecycleRecordCardBestEffort(params: {
564
+ record: AskUserLifecycleRecord;
565
+ config: DingTalkConfig;
566
+ log?: Logger;
567
+ }): Promise<void> {
568
+ const reason = params.record.terminalReason;
569
+ if (!reason || reason === "delivery_failed") {
570
+ return;
571
+ }
572
+ try {
573
+ const token = await getAccessToken(params.config, params.log);
574
+ await updateCardVariables(
575
+ params.record.outTrackId,
576
+ terminalCardVariables(reason),
577
+ token,
578
+ params.config,
579
+ );
580
+ } catch (err) {
581
+ params.log?.warn?.(
582
+ `[DingTalk][AskUser] Failed to update lifecycle card ${params.record.questionId}: ${String(err)}`,
583
+ );
584
+ }
585
+ }
586
+
587
+ function consumeLifecyclePendingContext(
588
+ record: AskUserLifecycleRecord,
589
+ ): PendingQuestion | undefined {
590
+ const ctx =
591
+ pendingQuestionsByQuestionId.get(record.questionId) ??
592
+ pendingQuestionsByTrackId.get(record.outTrackId);
593
+ if (!ctx) {
594
+ return undefined;
595
+ }
596
+ ctx.submitted = true;
597
+ consumePendingQuestion(ctx);
598
+ addHandledQuestionTombstone(ctx, record.terminalReason === "expired" ? "expired" : "superseded");
599
+ return ctx;
600
+ }
601
+
602
+ export function invalidateAskUserQuestionsForScope(params: {
603
+ storePath: string;
604
+ accountId: string;
605
+ questionScopeKey: string;
606
+ reason: "superseded_by_message";
607
+ log?: Logger;
608
+ }): AskUserLifecycleRecord[] {
609
+ const invalidated = invalidateAskUserQuestionsInStore(
610
+ {
611
+ storePath: params.storePath,
612
+ accountId: params.accountId,
613
+ log: params.log,
614
+ },
615
+ params.questionScopeKey,
616
+ params.reason,
617
+ );
618
+ for (const record of invalidated) {
619
+ consumeLifecyclePendingContext(record);
620
+ }
621
+ return invalidated;
622
+ }
623
+
624
+ export async function syncInvalidatedAskUserQuestionCards(params: {
625
+ records: AskUserLifecycleRecord[];
626
+ config: DingTalkConfig;
627
+ log?: Logger;
628
+ }): Promise<void> {
629
+ await Promise.allSettled(
630
+ params.records.map((record) =>
631
+ updateLifecycleRecordCardBestEffort({
632
+ record,
633
+ config: params.config,
634
+ log: params.log,
635
+ }),
636
+ ),
637
+ );
638
+ }
639
+
640
+ export async function recoverAskUserQuestionsForAccount(params: {
641
+ storePath?: string;
642
+ accountId: string;
643
+ config: DingTalkConfig;
644
+ log?: Logger;
645
+ }): Promise<number> {
646
+ if (!params.storePath) {
647
+ return 0;
648
+ }
649
+ const recovered = recoverAskUserQuestionsAfterRestart({
650
+ storePath: params.storePath,
651
+ accountId: params.accountId,
652
+ log: params.log,
653
+ });
654
+ for (const record of recovered) {
655
+ consumeLifecyclePendingContext(record);
656
+ await updateLifecycleRecordCardBestEffort({
657
+ record,
658
+ config: params.config,
659
+ log: params.log,
660
+ });
661
+ }
662
+ return recovered.length;
663
+ }
664
+
665
+ async function terminatePendingQuestion(params: {
666
+ ctx: PendingQuestion;
667
+ reason: AskUserTerminalReason;
668
+ }): Promise<void> {
669
+ const storeOptions = getAskUserStoreOptions(params.ctx);
670
+ if (storeOptions) {
671
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, params.reason);
672
+ }
673
+ params.ctx.submitted = true;
674
+ consumePendingQuestion(params.ctx);
675
+ addHandledQuestionTombstone(
676
+ params.ctx,
677
+ params.reason === "cancelled"
678
+ ? "cancelled"
679
+ : params.reason === "empty"
680
+ ? "empty"
681
+ : params.reason === "submitted"
682
+ ? "submitted"
683
+ : params.reason === "expired"
684
+ ? "expired"
685
+ : "superseded",
686
+ );
687
+ await updateQuestionCardBestEffort(params.ctx, terminalCardVariables(params.reason));
688
+ }
689
+
502
690
  function parseEmbeddedJson(value: unknown): unknown {
503
691
  if (typeof value !== "string") {
504
692
  return value;
@@ -644,13 +832,59 @@ async function injectAnswerSyntheticMessage(
644
832
  sessionWebhook: ctx.sessionWebhook,
645
833
  log: ctx.log,
646
834
  dingtalkConfig: ctx.dingtalkConfig,
835
+ inboundOrigin: "ask-user",
836
+ routeOverride: ctx.resolvedRoute,
837
+ subAgentOptions: ctx.continuationSubAgentOptions,
647
838
  });
648
839
  }
649
840
 
841
+ function claimPendingQuestionForDispatch(ctx: PendingQuestion): boolean {
842
+ const storeOptions = getAskUserStoreOptions(ctx);
843
+ if (storeOptions) {
844
+ return Boolean(
845
+ claimAskUserQuestion(storeOptions, {
846
+ questionId: ctx.questionId,
847
+ outTrackId: ctx.outTrackId,
848
+ }),
849
+ );
850
+ }
851
+ if (ctx.submitted) {
852
+ return false;
853
+ }
854
+ ctx.submitted = true;
855
+ return true;
856
+ }
857
+
858
+ function dispatchSyntheticAnswer(params: {
859
+ ctx: PendingQuestion;
860
+ text: string;
861
+ suffix: string;
862
+ successReason: "submitted" | "cancelled" | "empty" | "expired";
863
+ log?: Logger;
864
+ }): void {
865
+ const storeOptions = getAskUserStoreOptions(params.ctx);
866
+ void injectAnswerSyntheticMessage(params.ctx, params.text, params.suffix)
867
+ .then(() => {
868
+ if (storeOptions) {
869
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, params.successReason);
870
+ }
871
+ })
872
+ .catch((err) => {
873
+ if (storeOptions) {
874
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, "dispatch_failed");
875
+ }
876
+ void updateQuestionCardBestEffort(params.ctx, terminalCardVariables("dispatch_failed"));
877
+ params.log?.error?.(
878
+ `[DingTalk][AskUser] Failed to inject ${params.suffix} answer message: ${String(err)}`,
879
+ );
880
+ });
881
+ }
882
+
650
883
  export async function handleDingTalkAskUserCardCallback(params: {
651
884
  payload: unknown;
652
885
  cfg: DingTalkQuestionContext["cfg"];
653
886
  accountId: string;
887
+ storePath?: string;
654
888
  config: DingTalkConfig;
655
889
  clickerUserId?: string;
656
890
  log?: Logger;
@@ -663,10 +897,49 @@ export async function handleDingTalkAskUserCardCallback(params: {
663
897
  );
664
898
  return { handled: true };
665
899
  }
900
+ const storeOptions = params.storePath
901
+ ? {
902
+ storePath: params.storePath,
903
+ accountId: params.accountId,
904
+ log: params.log,
905
+ }
906
+ : undefined;
907
+ const lifecycleRecord = storeOptions
908
+ ? resolveAskUserQuestion(storeOptions, {
909
+ questionId: parsed.actionId,
910
+ outTrackId: parsed.outTrackId,
911
+ })
912
+ : undefined;
913
+ if (lifecycleRecord?.state === "terminal") {
914
+ params.log?.debug?.(
915
+ `[DingTalk][AskUser] Ignoring terminal callback question=${lifecycleRecord.questionId} reason=${lifecycleRecord.terminalReason ?? "unknown"}`,
916
+ );
917
+ await updateLifecycleRecordCardBestEffort({
918
+ record: lifecycleRecord,
919
+ config: params.config,
920
+ log: params.log,
921
+ });
922
+ return { handled: true };
923
+ }
666
924
  const ctx =
667
925
  (parsed.outTrackId ? pendingQuestionsByTrackId.get(parsed.outTrackId) : undefined) ??
668
926
  (parsed.actionId ? pendingQuestionsByQuestionId.get(parsed.actionId) : undefined);
669
927
  if (!ctx) {
928
+ if (lifecycleRecord && storeOptions) {
929
+ const recovered = terminateAskUserQuestion(
930
+ storeOptions,
931
+ lifecycleRecord.questionId,
932
+ lifecycleRecord.state === "dispatching" ? "restart_during_dispatch" : "restart_invalidated",
933
+ );
934
+ if (recovered) {
935
+ await updateLifecycleRecordCardBestEffort({
936
+ record: recovered,
937
+ config: params.config,
938
+ log: params.log,
939
+ });
940
+ }
941
+ return { handled: true };
942
+ }
670
943
  return { handled: false };
671
944
  }
672
945
 
@@ -684,15 +957,17 @@ export async function handleDingTalkAskUserCardCallback(params: {
684
957
  return { handled: true };
685
958
  }
686
959
 
687
- if (ctx.submitted) {
960
+ if (ctx.submitted || lifecycleRecord?.state === "dispatching") {
688
961
  params.log?.debug?.(`[DingTalk][AskUser] Duplicate submit ignored question=${ctx.questionId}`);
689
962
  return { handled: true };
690
963
  }
691
964
 
692
965
  const isCancel = parseBooleanLike(parsed.params.user_cancel) === true;
693
- ctx.submitted = true;
694
966
 
695
967
  if (isCancel) {
968
+ if (!claimPendingQuestionForDispatch(ctx)) {
969
+ return { handled: true };
970
+ }
696
971
  await updateQuestionCardBestEffort(ctx, {
697
972
  card_status: "cancelled",
698
973
  question_desc: "已取消。",
@@ -700,27 +975,28 @@ export async function handleDingTalkAskUserCardCallback(params: {
700
975
  });
701
976
  consumePendingQuestion(ctx);
702
977
  addHandledQuestionTombstone(ctx, "cancelled");
703
- setImmediate(() => {
704
- void injectAnswerSyntheticMessage(ctx, buildCancelledAnswerMessage(ctx), "cancelled").catch(
705
- (err) => {
706
- params.log?.error?.(
707
- `[DingTalk][AskUser] Failed to inject cancelled answer message: ${String(err)}`,
708
- );
709
- },
710
- );
978
+ dispatchSyntheticAnswer({
979
+ ctx,
980
+ text: buildCancelledAnswerMessage(ctx),
981
+ suffix: "cancelled",
982
+ successReason: "cancelled",
983
+ log: params.log,
711
984
  });
712
985
  return { handled: true };
713
986
  }
714
987
 
715
988
  const form = asRecord(parsed.params.form);
716
989
  if (!form) {
717
- ctx.submitted = false;
718
990
  params.log?.warn?.(
719
991
  `[DingTalk][AskUser] Missing form payload question=${ctx.questionId} params=${JSON.stringify(parsed.params)}`,
720
992
  );
721
993
  return { handled: true };
722
994
  }
723
995
 
996
+ if (!claimPendingQuestionForDispatch(ctx)) {
997
+ return { handled: true };
998
+ }
999
+
724
1000
  const answers: AnswerEntry[] = [];
725
1001
  const selectedValues: string[] = [];
726
1002
  for (const question of ctx.questions) {
@@ -745,12 +1021,12 @@ export async function handleDingTalkAskUserCardCallback(params: {
745
1021
  });
746
1022
  consumePendingQuestion(ctx);
747
1023
  addHandledQuestionTombstone(ctx, "empty");
748
- setImmediate(() => {
749
- void injectAnswerSyntheticMessage(ctx, buildEmptyAnswerMessage(ctx), "empty").catch((err) => {
750
- params.log?.error?.(
751
- `[DingTalk][AskUser] Failed to inject empty answer message: ${String(err)}`,
752
- );
753
- });
1024
+ dispatchSyntheticAnswer({
1025
+ ctx,
1026
+ text: buildEmptyAnswerMessage(ctx),
1027
+ suffix: "empty",
1028
+ successReason: "empty",
1029
+ log: params.log,
754
1030
  });
755
1031
  return { handled: true };
756
1032
  }
@@ -767,10 +1043,12 @@ export async function handleDingTalkAskUserCardCallback(params: {
767
1043
  addHandledQuestionTombstone(ctx, "submitted");
768
1044
 
769
1045
  const message = buildAnswerMessage(ctx, answers);
770
- setImmediate(() => {
771
- void injectAnswerSyntheticMessage(ctx, message, "submitted").catch((err) => {
772
- params.log?.error?.(`[DingTalk][AskUser] Failed to inject answer message: ${String(err)}`);
773
- });
1046
+ dispatchSyntheticAnswer({
1047
+ ctx,
1048
+ text: message,
1049
+ suffix: "submitted",
1050
+ successReason: "submitted",
1051
+ log: params.log,
774
1052
  });
775
1053
  return { handled: true };
776
1054
  }
@@ -955,7 +1233,7 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
955
1233
  return;
956
1234
  }
957
1235
 
958
- registerTool.call(api, {
1236
+ const createTool = (context: DingTalkQuestionContext | undefined) => ({
959
1237
  name: TOOL_NAME,
960
1238
  label: "Ask User Question",
961
1239
  description:
@@ -969,7 +1247,6 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
969
1247
  "Do not call this tool for normal explanations, why/how questions, capability introductions, or cases where you can answer directly.",
970
1248
  parameters: AskUserQuestionSchema as any,
971
1249
  async execute(_toolCallId: string, params: unknown) {
972
- const context = getDingTalkQuestionContext();
973
1250
  if (!context) {
974
1251
  return jsonToolResult({
975
1252
  status: "failed",
@@ -1010,6 +1287,16 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1010
1287
  selected_values: "[]",
1011
1288
  form: { fields },
1012
1289
  };
1290
+ const storeOptions = getAskUserStoreOptions(context);
1291
+ const canPersistLifecycle = Boolean(storeOptions && context.questionScopeKey);
1292
+ if (storeOptions && context.questionScopeKey) {
1293
+ reserveAskUserQuestion(storeOptions, {
1294
+ questionId,
1295
+ questionScopeKey: context.questionScopeKey,
1296
+ outTrackId,
1297
+ title,
1298
+ });
1299
+ }
1013
1300
 
1014
1301
  try {
1015
1302
  await createAndDeliverQuestionCard({
@@ -1025,6 +1312,9 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1025
1312
  log: context.log,
1026
1313
  });
1027
1314
  } catch (err) {
1315
+ if (storeOptions && canPersistLifecycle) {
1316
+ terminateAskUserQuestion(storeOptions, questionId, "delivery_failed");
1317
+ }
1028
1318
  const detail = formatDingTalkErrorPayloadLog("ask_user_create", err, "[DingTalk]");
1029
1319
  return jsonToolResult({
1030
1320
  status: "failed",
@@ -1035,21 +1325,76 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1035
1325
  ...context,
1036
1326
  onQuestionCardSent: undefined,
1037
1327
  };
1038
- storePendingQuestion({
1328
+ const pendingQuestion: PendingQuestion = {
1039
1329
  ...pendingContext,
1040
1330
  questionId,
1041
1331
  outTrackId,
1042
1332
  title,
1043
1333
  questions: parsed,
1044
1334
  submitted: false,
1335
+ };
1336
+ storePendingQuestion(pendingQuestion, {
1337
+ supersedeExisting: !canPersistLifecycle,
1045
1338
  });
1339
+ if (storeOptions && canPersistLifecycle) {
1340
+ const activation = activateAskUserQuestion(storeOptions, questionId);
1341
+ if (activation.record?.state !== "pending") {
1342
+ const terminalReason = activation.record?.terminalReason ?? "superseded_by_message";
1343
+ if (activation.record) {
1344
+ consumeLifecyclePendingContext(activation.record);
1345
+ } else {
1346
+ pendingQuestion.submitted = true;
1347
+ consumePendingQuestion(pendingQuestion);
1348
+ addHandledQuestionTombstone(pendingQuestion, "superseded");
1349
+ }
1350
+ await updateQuestionCardBestEffort(
1351
+ pendingQuestion,
1352
+ terminalCardVariables(terminalReason),
1353
+ );
1354
+ return jsonToolResult({
1355
+ status: "failed",
1356
+ questionId,
1357
+ outTrackId,
1358
+ error: "问题卡片在发送期间已失效,请重新发起。",
1359
+ });
1360
+ }
1361
+ for (const superseded of activation.superseded) {
1362
+ const supersededContext = consumeLifecyclePendingContext(superseded);
1363
+ if (supersededContext) {
1364
+ void updateQuestionCardBestEffort(
1365
+ supersededContext,
1366
+ terminalCardVariables("superseded_by_question"),
1367
+ );
1368
+ } else {
1369
+ void updateLifecycleRecordCardBestEffort({
1370
+ record: superseded,
1371
+ config: context.dingtalkConfig,
1372
+ log: context.log,
1373
+ });
1374
+ }
1375
+ }
1376
+ }
1046
1377
 
1378
+ let takeoverSucceeded: boolean | void = undefined;
1047
1379
  try {
1048
- await context.onQuestionCardSent?.({ questionId, outTrackId });
1380
+ takeoverSucceeded = await context.onQuestionCardSent?.({ questionId, outTrackId });
1049
1381
  } catch (err) {
1050
1382
  context.log?.warn?.(
1051
1383
  `[DingTalk][AskUser] onQuestionCardSent hook failed: ${err instanceof Error ? err.message : String(err)}`,
1052
1384
  );
1385
+ takeoverSucceeded = false;
1386
+ }
1387
+ if (takeoverSucceeded === false) {
1388
+ await terminatePendingQuestion({
1389
+ ctx: pendingQuestion,
1390
+ reason: "pause_failed",
1391
+ });
1392
+ return jsonToolResult({
1393
+ status: "failed",
1394
+ questionId,
1395
+ outTrackId,
1396
+ error: "当前任务未能暂停,此卡已失效,请重新发起。",
1397
+ });
1053
1398
  }
1054
1399
 
1055
1400
  context.log?.info?.(
@@ -1064,5 +1409,21 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1064
1409
  });
1065
1410
  },
1066
1411
  });
1412
+ registerTool.call(
1413
+ api,
1414
+ (toolContext: OpenClawPluginToolContext) => {
1415
+ // Capture the inbound run's context while the factory is still inside its
1416
+ // AsyncLocalStorage scope; shared agent clients may execute the tool later.
1417
+ const context = getDingTalkQuestionContext();
1418
+ const runtimeSessionKey = toolContext.sessionKey?.trim();
1419
+ const contextSessionKey = context?.resolvedRoute?.sessionKey.trim();
1420
+ return createTool(
1421
+ context && (!runtimeSessionKey || contextSessionKey === runtimeSessionKey)
1422
+ ? context
1423
+ : undefined,
1424
+ );
1425
+ },
1426
+ { name: TOOL_NAME },
1427
+ );
1067
1428
  api.logger?.debug?.(`${TOOL_NAME}: registered tool`);
1068
1429
  }
@@ -14,6 +14,7 @@ export async function handleCardAction(params: {
14
14
  analysis: CardCallbackAnalysis;
15
15
  cfg: OpenClawConfig;
16
16
  accountId: string;
17
+ storePath?: string;
17
18
  config: DingTalkConfig;
18
19
  log?: Logger;
19
20
  }): Promise<CardActionResult> {
@@ -21,6 +22,7 @@ export async function handleCardAction(params: {
21
22
  payload: params.payload,
22
23
  cfg: params.cfg,
23
24
  accountId: params.accountId,
25
+ storePath: params.storePath,
24
26
  config: params.config,
25
27
  clickerUserId: params.analysis.userId,
26
28
  log: params.log,