@soimy/dingtalk 3.6.5 → 3.6.7

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 (41) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +773 -136
  3. package/dist/index.js.map +4 -4
  4. package/dist/src/card/ask-user-question-context.d.ts +5 -2
  5. package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
  6. package/dist/src/card/ask-user-question-store.d.ts +42 -0
  7. package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
  8. package/dist/src/card/ask-user-question.d.ts +20 -0
  9. package/dist/src/card/ask-user-question.d.ts.map +1 -1
  10. package/dist/src/card/card-action-handler.d.ts +1 -0
  11. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  12. package/dist/src/card/card-template.d.ts +1 -1
  13. package/dist/src/card/task-model-metadata.d.ts +11 -0
  14. package/dist/src/card/task-model-metadata.d.ts.map +1 -0
  15. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.d.ts.map +1 -1
  17. package/dist/src/reply-strategy-card.d.ts.map +1 -1
  18. package/dist/src/reply-strategy-types.d.ts +1 -0
  19. package/dist/src/reply-strategy-types.d.ts.map +1 -1
  20. package/dist/src/session-state.d.ts +11 -5
  21. package/dist/src/session-state.d.ts.map +1 -1
  22. package/dist/src/targeting/agent-routing.d.ts +8 -1
  23. package/dist/src/targeting/agent-routing.d.ts.map +1 -1
  24. package/dist/src/types.d.ts +13 -0
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
  27. package/package.json +2 -1
  28. package/src/access-control.ts +1 -1
  29. package/src/card/ask-user-question-context.ts +9 -1
  30. package/src/card/ask-user-question-store.ts +294 -0
  31. package/src/card/ask-user-question.ts +380 -34
  32. package/src/card/card-action-handler.ts +2 -0
  33. package/src/card/card-template.ts +1 -1
  34. package/src/card/task-model-metadata.ts +51 -0
  35. package/src/gateway/channel-gateway.ts +19 -0
  36. package/src/inbound-handler.ts +131 -49
  37. package/src/reply-strategy-card.ts +30 -6
  38. package/src/reply-strategy-types.ts +1 -0
  39. package/src/session-state.ts +44 -13
  40. package/src/targeting/agent-routing.ts +67 -19
  41. package/src/types.ts +14 -0
@@ -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
  }
@@ -1010,6 +1288,16 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1010
1288
  selected_values: "[]",
1011
1289
  form: { fields },
1012
1290
  };
1291
+ const storeOptions = getAskUserStoreOptions(context);
1292
+ const canPersistLifecycle = Boolean(storeOptions && context.questionScopeKey);
1293
+ if (storeOptions && context.questionScopeKey) {
1294
+ reserveAskUserQuestion(storeOptions, {
1295
+ questionId,
1296
+ questionScopeKey: context.questionScopeKey,
1297
+ outTrackId,
1298
+ title,
1299
+ });
1300
+ }
1013
1301
 
1014
1302
  try {
1015
1303
  await createAndDeliverQuestionCard({
@@ -1025,6 +1313,9 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1025
1313
  log: context.log,
1026
1314
  });
1027
1315
  } catch (err) {
1316
+ if (storeOptions && canPersistLifecycle) {
1317
+ terminateAskUserQuestion(storeOptions, questionId, "delivery_failed");
1318
+ }
1028
1319
  const detail = formatDingTalkErrorPayloadLog("ask_user_create", err, "[DingTalk]");
1029
1320
  return jsonToolResult({
1030
1321
  status: "failed",
@@ -1035,21 +1326,76 @@ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): voi
1035
1326
  ...context,
1036
1327
  onQuestionCardSent: undefined,
1037
1328
  };
1038
- storePendingQuestion({
1329
+ const pendingQuestion: PendingQuestion = {
1039
1330
  ...pendingContext,
1040
1331
  questionId,
1041
1332
  outTrackId,
1042
1333
  title,
1043
1334
  questions: parsed,
1044
1335
  submitted: false,
1336
+ };
1337
+ storePendingQuestion(pendingQuestion, {
1338
+ supersedeExisting: !canPersistLifecycle,
1045
1339
  });
1340
+ if (storeOptions && canPersistLifecycle) {
1341
+ const activation = activateAskUserQuestion(storeOptions, questionId);
1342
+ if (activation.record?.state !== "pending") {
1343
+ const terminalReason = activation.record?.terminalReason ?? "superseded_by_message";
1344
+ if (activation.record) {
1345
+ consumeLifecyclePendingContext(activation.record);
1346
+ } else {
1347
+ pendingQuestion.submitted = true;
1348
+ consumePendingQuestion(pendingQuestion);
1349
+ addHandledQuestionTombstone(pendingQuestion, "superseded");
1350
+ }
1351
+ await updateQuestionCardBestEffort(
1352
+ pendingQuestion,
1353
+ terminalCardVariables(terminalReason),
1354
+ );
1355
+ return jsonToolResult({
1356
+ status: "failed",
1357
+ questionId,
1358
+ outTrackId,
1359
+ error: "问题卡片在发送期间已失效,请重新发起。",
1360
+ });
1361
+ }
1362
+ for (const superseded of activation.superseded) {
1363
+ const supersededContext = consumeLifecyclePendingContext(superseded);
1364
+ if (supersededContext) {
1365
+ void updateQuestionCardBestEffort(
1366
+ supersededContext,
1367
+ terminalCardVariables("superseded_by_question"),
1368
+ );
1369
+ } else {
1370
+ void updateLifecycleRecordCardBestEffort({
1371
+ record: superseded,
1372
+ config: context.dingtalkConfig,
1373
+ log: context.log,
1374
+ });
1375
+ }
1376
+ }
1377
+ }
1046
1378
 
1379
+ let takeoverSucceeded: boolean | void = undefined;
1047
1380
  try {
1048
- await context.onQuestionCardSent?.({ questionId, outTrackId });
1381
+ takeoverSucceeded = await context.onQuestionCardSent?.({ questionId, outTrackId });
1049
1382
  } catch (err) {
1050
1383
  context.log?.warn?.(
1051
1384
  `[DingTalk][AskUser] onQuestionCardSent hook failed: ${err instanceof Error ? err.message : String(err)}`,
1052
1385
  );
1386
+ takeoverSucceeded = false;
1387
+ }
1388
+ if (takeoverSucceeded === false) {
1389
+ await terminatePendingQuestion({
1390
+ ctx: pendingQuestion,
1391
+ reason: "pause_failed",
1392
+ });
1393
+ return jsonToolResult({
1394
+ status: "failed",
1395
+ questionId,
1396
+ outTrackId,
1397
+ error: "当前任务未能暂停,此卡已失效,请重新发起。",
1398
+ });
1053
1399
  }
1054
1400
 
1055
1401
  context.log?.info?.(
@@ -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,
@@ -9,7 +9,7 @@ export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
9
9
  export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
10
10
  export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
11
11
  export const BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID =
12
- "89d0c6fe-3822-44c8-950e-e950f562546d.schema";
12
+ "c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema";
13
13
 
14
14
  export interface DingTalkCardTemplateContract {
15
15
  templateId: string;
@@ -0,0 +1,51 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+
3
+ export interface InitialTaskModelMetadata {
4
+ model?: string;
5
+ effort?: string;
6
+ }
7
+
8
+ type AgentModelConfigLike = string | { primary?: unknown } | undefined;
9
+
10
+ function readPrimaryModelRef(value: AgentModelConfigLike): string | undefined {
11
+ if (typeof value === "string") {
12
+ return value.trim() || undefined;
13
+ }
14
+ if (value && typeof value === "object" && typeof value.primary === "string") {
15
+ return value.primary.trim() || undefined;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function normalizeModelDisplayName(modelRef: string | undefined): string | undefined {
21
+ const trimmed = typeof modelRef === "string" ? modelRef.trim() : "";
22
+ if (!trimmed) {
23
+ return undefined;
24
+ }
25
+ const parts = trimmed.split("/").map((part) => part.trim()).filter(Boolean);
26
+ return parts.at(-1) || undefined;
27
+ }
28
+
29
+ export function resolveConfiguredTaskModelMetadata(params: {
30
+ cfg: OpenClawConfig;
31
+ agentId?: string | null;
32
+ }): InitialTaskModelMetadata {
33
+ const agents = params.cfg.agents;
34
+ const agentId = String(params.agentId || "").trim();
35
+ const agent = Array.isArray(agents?.list)
36
+ ? agents.list.find((entry) => String(entry.id || "").trim() === agentId)
37
+ : undefined;
38
+
39
+ const modelRef = readPrimaryModelRef(agent?.model) ?? readPrimaryModelRef(agents?.defaults?.model);
40
+ const effort =
41
+ typeof agent?.thinkingDefault === "string" && agent.thinkingDefault.trim()
42
+ ? agent.thinkingDefault.trim()
43
+ : typeof agents?.defaults?.thinkingDefault === "string" && agents.defaults.thinkingDefault.trim()
44
+ ? agents.defaults.thinkingDefault.trim()
45
+ : undefined;
46
+
47
+ return {
48
+ model: normalizeModelDisplayName(modelRef),
49
+ effort,
50
+ };
51
+ }
@@ -1,6 +1,7 @@
1
1
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
2
2
  import { analyzeCardCallback } from "../card-callback-service";
3
3
  import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
4
+ import { recoverAskUserQuestionsForAccount } from "../card/ask-user-question";
4
5
  import { handleCardAction } from "../card/card-action-handler";
5
6
  import { resolveRobotCode, resolveRuntimeConfig } from "../config";
6
7
  import { ConnectionManager } from "../connection-manager";
@@ -199,6 +200,23 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
199
200
  `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
200
201
  );
201
202
  }
203
+ try {
204
+ const recoveredQuestions = await recoverAskUserQuestionsForAccount({
205
+ storePath: accountStorePath,
206
+ accountId: account.accountId,
207
+ config,
208
+ log: pluginLog,
209
+ });
210
+ if (recoveredQuestions > 0) {
211
+ pluginLog?.info?.(
212
+ `[${account.accountId}] Invalidated ${recoveredQuestions} unfinished Ask User card(s) from previous runtime`,
213
+ );
214
+ }
215
+ } catch (err: any) {
216
+ pluginLog?.warn?.(
217
+ `[${account.accountId}] Failed to recover Ask User cards: ${err.message}`,
218
+ );
219
+ }
202
220
 
203
221
  const useConnectionManager = config.useConnectionManager ?? true;
204
222
  const applyStatusPatch = (patch: Record<string, unknown>) => {
@@ -382,6 +400,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
382
400
  analysis,
383
401
  cfg,
384
402
  accountId: account.accountId,
403
+ storePath: accountStorePath,
385
404
  config,
386
405
  log: pluginLog,
387
406
  });