@soimy/dingtalk 3.3.0 → 3.4.0

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.
@@ -5,6 +5,14 @@ import axios from "axios";
5
5
  import { getAccessToken } from "./auth";
6
6
  import { stripTargetPrefix } from "./config";
7
7
  import { resolveOriginalPeerId } from "./peer-id-registry";
8
+ import {
9
+ createSyntheticOutboundMsgId,
10
+ clearMessageContextCacheForTest,
11
+ DEFAULT_CARD_CONTENT_TTL_MS,
12
+ DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
13
+ resolveByCreatedAtWindow,
14
+ upsertOutboundMessageContext,
15
+ } from "./message-context-store";
8
16
  import {
9
17
  readNamespaceJson,
10
18
  resolveNamespacePath,
@@ -16,6 +24,7 @@ import type {
16
24
  DingTalkConfig,
17
25
  DingTalkTrackingMetadata,
18
26
  Logger,
27
+ QuotedRef,
19
28
  } from "./types";
20
29
  import { AICardStatus } from "./types";
21
30
  import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
@@ -24,11 +33,55 @@ const DINGTALK_API = "https://api.dingtalk.com";
24
33
  // Thinking/tool stream snippets are truncated to keep card updates compact.
25
34
  const CARD_STATE_FILE_VERSION = 1;
26
35
  const CARD_PENDING_NAMESPACE = "cards.active.pending";
27
- const CARD_PROCESS_QUERY_NAMESPACE = "cards.content.quote-process-query";
28
36
  const RECOVERY_FINALIZE_MESSAGE = "⚠️ 上一次回复处理中断,已自动结束。请重新发送你的问题。";
29
37
  const AICARD_DEGRADE_DEFAULT_MS = 30 * 60 * 1000;
38
+ const CARD_CACHE_MAX_PER_CONVERSATION = 20;
39
+ const CARD_CACHE_MAX_CONVERSATIONS = 500;
40
+ const DYNAMIC_SUMMARY_EXTENSION = { dynamicSummary: "true" } as const;
30
41
 
31
42
  const aicardDegradeByAccount = new Map<string, { untilMs: number; reason: string }>();
43
+ const inMemoryCardContentStore = new Map<
44
+ string,
45
+ {
46
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>;
47
+ lastActiveAt: number;
48
+ }
49
+ >();
50
+
51
+ function pruneInMemoryCardContentEntries(
52
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>,
53
+ nowMs: number,
54
+ ): Array<{ content: string; createdAt: number; expiresAt: number }> {
55
+ return entries.filter((entry) => nowMs < entry.expiresAt).slice(-CARD_CACHE_MAX_PER_CONVERSATION);
56
+ }
57
+
58
+ function touchInMemoryCardContentBucket(scopeKey: string, nowMs: number): {
59
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>;
60
+ lastActiveAt: number;
61
+ } {
62
+ const existing = inMemoryCardContentStore.get(scopeKey);
63
+ const bucket = existing
64
+ ? {
65
+ entries: pruneInMemoryCardContentEntries(existing.entries, nowMs),
66
+ lastActiveAt: nowMs,
67
+ }
68
+ : { entries: [], lastActiveAt: nowMs };
69
+ inMemoryCardContentStore.set(scopeKey, bucket);
70
+ if (inMemoryCardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
71
+ let oldestKey: string | undefined;
72
+ let oldestTime = Infinity;
73
+ for (const [key, candidate] of inMemoryCardContentStore) {
74
+ if (candidate.lastActiveAt < oldestTime) {
75
+ oldestTime = candidate.lastActiveAt;
76
+ oldestKey = key;
77
+ }
78
+ }
79
+ if (oldestKey) {
80
+ inMemoryCardContentStore.delete(oldestKey);
81
+ }
82
+ }
83
+ return bucket;
84
+ }
32
85
 
33
86
  function getAICardDegradeMs(config?: DingTalkConfig): number {
34
87
  const raw = config?.aicardDegradeMs;
@@ -147,6 +200,7 @@ interface CreateAICardOptions {
147
200
  accountId?: string;
148
201
  storePath?: string;
149
202
  persistPending?: boolean;
203
+ contextConversationId?: string;
150
204
  }
151
205
 
152
206
  interface PendingCardRecord {
@@ -154,6 +208,7 @@ interface PendingCardRecord {
154
208
  cardInstanceId: string;
155
209
  outTrackId?: string;
156
210
  conversationId: string;
211
+ contextConversationId?: string;
157
212
  createdAt: number;
158
213
  lastUpdated: number;
159
214
  state: string;
@@ -261,6 +316,7 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
261
316
  cardInstanceId: card.cardInstanceId,
262
317
  outTrackId: card.outTrackId,
263
318
  conversationId: card.conversationId,
319
+ contextConversationId: card.contextConversationId,
264
320
  createdAt: card.createdAt,
265
321
  lastUpdated: card.lastUpdated,
266
322
  state: card.state,
@@ -466,6 +522,7 @@ async function finalizePendingCardsByAccount(
466
522
  cardInstanceId: entry.cardInstanceId,
467
523
  accessToken: token,
468
524
  conversationId: entry.conversationId,
525
+ contextConversationId: entry.contextConversationId,
469
526
  accountId: entry.accountId,
470
527
  storePath,
471
528
  outTrackId: entry.outTrackId,
@@ -538,10 +595,17 @@ export async function createAICard(
538
595
  : `dtv1.card//IM_ROBOT.${conversationId}`,
539
596
  userIdType: 1,
540
597
  imGroupOpenDeliverModel: isGroup
541
- ? { robotCode: config.robotCode || config.clientId }
598
+ ? {
599
+ robotCode: config.robotCode || config.clientId,
600
+ extension: DYNAMIC_SUMMARY_EXTENSION,
601
+ }
542
602
  : undefined,
543
603
  imRobotOpenDeliverModel: !isGroup
544
- ? { spaceType: "IM_ROBOT", robotCode: config.robotCode || config.clientId }
604
+ ? {
605
+ spaceType: "IM_ROBOT",
606
+ robotCode: config.robotCode || config.clientId,
607
+ extension: DYNAMIC_SUMMARY_EXTENSION,
608
+ }
545
609
  : undefined,
546
610
  };
547
611
 
@@ -600,6 +664,7 @@ export async function createAICard(
600
664
  cardInstanceId: resolvedCardInstanceId,
601
665
  accessToken: token,
602
666
  conversationId,
667
+ contextConversationId: options.contextConversationId || conversationId,
603
668
  accountId,
604
669
  storePath: options.storePath,
605
670
  createdAt: Date.now(),
@@ -789,214 +854,54 @@ export async function finishAICard(
789
854
  card: AICardInstance,
790
855
  content: string,
791
856
  log?: Logger,
857
+ options: { quotedRef?: QuotedRef } = {},
792
858
  ): Promise<void> {
793
859
  log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
794
860
  await streamAICard(card, content, true, log);
795
861
  if (card.conversationId && content.trim() && card.accountId && card.processQueryKey) {
862
+ const primaryConversationId = card.contextConversationId || card.conversationId;
796
863
  cacheCardContentByProcessQueryKey(
797
864
  card.accountId,
798
- card.conversationId,
865
+ primaryConversationId,
799
866
  card.processQueryKey,
800
867
  content,
801
868
  card.storePath,
869
+ options.quotedRef,
870
+ log,
802
871
  );
803
872
  }
804
873
  }
805
874
 
806
- interface ProcessQueryCardContentEntry {
807
- content: string;
808
- createdAt: number;
809
- expiresAt: number;
810
- }
811
-
812
- interface PersistedProcessQueryCardContent {
813
- updatedAt: number;
814
- entries: Record<string, ProcessQueryCardContentEntry>;
815
- }
816
-
817
- function readProcessQueryCardContent(
818
- accountId: string,
819
- conversationId: string,
820
- storePath?: string,
821
- ): PersistedProcessQueryCardContent {
822
- if (!storePath) {
823
- return { updatedAt: 0, entries: {} };
824
- }
825
- return readNamespaceJson<PersistedProcessQueryCardContent>(CARD_PROCESS_QUERY_NAMESPACE, {
826
- storePath,
827
- scope: { accountId, conversationId },
828
- format: "json",
829
- fallback: { updatedAt: 0, entries: {} },
830
- });
831
- }
832
-
833
- function writeProcessQueryCardContent(
834
- accountId: string,
835
- conversationId: string,
836
- data: PersistedProcessQueryCardContent,
837
- storePath?: string,
838
- ): void {
839
- if (!storePath) {
840
- return;
841
- }
842
- writeNamespaceJsonAtomic(CARD_PROCESS_QUERY_NAMESPACE, {
843
- storePath,
844
- scope: { accountId, conversationId },
845
- format: "json",
846
- data,
847
- });
848
- }
849
-
850
- function purgeExpiredProcessQueryEntries(
851
- persisted: PersistedProcessQueryCardContent,
852
- nowMs: number,
853
- ): boolean {
854
- let changed = false;
855
- for (const [key, entry] of Object.entries(persisted.entries)) {
856
- if (!entry || typeof entry.expiresAt !== "number" || nowMs >= entry.expiresAt) {
857
- delete persisted.entries[key];
858
- changed = true;
859
- }
860
- }
861
- return changed;
862
- }
863
-
864
- export function cacheCardContentByProcessQueryKey(
875
+ function cacheCardContentByProcessQueryKey(
865
876
  accountId: string,
866
877
  conversationId: string,
867
878
  processQueryKey: string,
868
879
  content: string,
869
880
  storePath?: string,
881
+ quotedRef?: QuotedRef,
882
+ log?: Logger,
870
883
  ): void {
871
884
  if (!processQueryKey.trim() || !content.trim() || !storePath) {
872
885
  return;
873
886
  }
874
- const nowMs = Date.now();
875
- const persisted = readProcessQueryCardContent(accountId, conversationId, storePath);
876
- purgeExpiredProcessQueryEntries(persisted, nowMs);
877
- persisted.entries[processQueryKey] = {
878
- content,
879
- createdAt: nowMs,
880
- expiresAt: nowMs + CARD_CACHE_TTL_MS,
881
- };
882
- persisted.updatedAt = nowMs;
883
- writeProcessQueryCardContent(accountId, conversationId, persisted, storePath);
884
- }
885
-
886
- export function getCardContentByProcessQueryKey(
887
- accountId: string,
888
- conversationId: string,
889
- processQueryKey: string,
890
- storePath?: string,
891
- ): string | null {
892
- if (!processQueryKey.trim() || !storePath) {
893
- return null;
894
- }
895
- const nowMs = Date.now();
896
- const persisted = readProcessQueryCardContent(accountId, conversationId, storePath);
897
- const changed = purgeExpiredProcessQueryEntries(persisted, nowMs);
898
- const entry = persisted.entries[processQueryKey];
899
- if (!entry) {
900
- if (changed) {
901
- persisted.updatedAt = nowMs;
902
- writeProcessQueryCardContent(accountId, conversationId, persisted, storePath);
903
- }
904
- return null;
905
- }
906
- return entry.content;
907
- }
908
-
909
- // ============ Card content cache (for quoted card lookup) ============
910
-
911
- const CARD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
912
- const CARD_CACHE_MAX_PER_CONVERSATION = 20;
913
- const CARD_CACHE_MAX_CONVERSATIONS = 500;
914
- const CARD_CACHE_MATCH_WINDOW_MS = 2000;
915
- const CARD_CONTENT_NAMESPACE = "cards.content.quote-lookup";
916
-
917
- interface CardContentEntry {
918
- content: string;
919
- createdAt: number;
920
- expiresAt: number;
921
- }
922
-
923
- interface CardConversationBucket {
924
- entries: CardContentEntry[];
925
- lastActiveAt: number;
926
- }
927
-
928
- interface PersistedCardContentBucket {
929
- updatedAt: number;
930
- entries: CardContentEntry[];
931
- }
932
-
933
- const cardContentStore = new Map<string, CardConversationBucket>();
934
-
935
- function loadCardContentBucketFromPersistence(
936
- accountId: string,
937
- conversationId: string,
938
- storePath?: string,
939
- ): CardConversationBucket | null {
940
- if (!storePath) {
941
- return null;
942
- }
943
- const persisted = readNamespaceJson<PersistedCardContentBucket>(CARD_CONTENT_NAMESPACE, {
944
- storePath,
945
- scope: { accountId, conversationId },
946
- format: "json",
947
- fallback: { updatedAt: 0, entries: [] },
948
- });
949
- if (!Array.isArray(persisted.entries) || persisted.entries.length === 0) {
950
- return null;
951
- }
952
-
953
- const now = Date.now();
954
- const entries: CardContentEntry[] = [];
955
- for (const entry of persisted.entries) {
956
- if (
957
- !entry ||
958
- typeof entry.content !== "string" ||
959
- typeof entry.createdAt !== "number" ||
960
- typeof entry.expiresAt !== "number" ||
961
- now >= entry.expiresAt
962
- ) {
963
- continue;
964
- }
965
- const insertAt = entries.findIndex((item) => item.createdAt > entry.createdAt);
966
- if (insertAt < 0) {
967
- entries.push(entry);
968
- } else {
969
- entries.splice(insertAt, 0, entry);
970
- }
971
- }
972
- const normalizedEntries = entries.slice(-CARD_CACHE_MAX_PER_CONVERSATION);
973
-
974
- if (normalizedEntries.length === 0) {
975
- return null;
976
- }
977
- return {
978
- entries: normalizedEntries,
979
- lastActiveAt: now,
980
- };
981
- }
982
-
983
- function persistCardContentBucket(
984
- accountId: string,
985
- conversationId: string,
986
- bucket: CardConversationBucket,
987
- storePath?: string,
988
- ): void {
989
- if (!storePath) {
990
- return;
991
- }
992
- writeNamespaceJsonAtomic(CARD_CONTENT_NAMESPACE, {
887
+ log?.debug?.(
888
+ `[DingTalk][QuotedRef][Persist] direction=outbound scope=${conversationId} messageType=card ` +
889
+ `processQueryKey=${processQueryKey} quotedRef=${quotedRef ? JSON.stringify(quotedRef) : "(none)"}`,
890
+ );
891
+ upsertOutboundMessageContext({
993
892
  storePath,
994
- scope: { accountId, conversationId },
995
- format: "json",
996
- data: {
997
- updatedAt: Date.now(),
998
- entries: bucket.entries,
999
- } satisfies PersistedCardContentBucket,
893
+ accountId,
894
+ conversationId,
895
+ createdAt: Date.now(),
896
+ text: content,
897
+ messageType: "card",
898
+ ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
899
+ topic: null,
900
+ quotedRef,
901
+ delivery: {
902
+ processQueryKey,
903
+ kind: "proactive-card",
904
+ },
1000
905
  });
1001
906
  }
1002
907
 
@@ -1007,38 +912,29 @@ export function cacheCardContent(
1007
912
  createdAt: number,
1008
913
  storePath?: string,
1009
914
  ): void {
1010
- const scopedKey = `${accountId}:${conversationId}`;
1011
- let bucket = cardContentStore.get(scopedKey);
1012
- if (!bucket) {
1013
- bucket = { entries: [], lastActiveAt: Date.now() };
1014
- cardContentStore.set(scopedKey, bucket);
1015
- if (cardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
1016
- let oldestKey: string | undefined;
1017
- let oldestTime = Infinity;
1018
- for (const [key, b] of cardContentStore) {
1019
- if (b.lastActiveAt < oldestTime) {
1020
- oldestTime = b.lastActiveAt;
1021
- oldestKey = key;
1022
- }
1023
- }
1024
- if (oldestKey) {
1025
- cardContentStore.delete(oldestKey);
1026
- }
1027
- }
1028
- }
1029
- bucket.lastActiveAt = Date.now();
1030
-
1031
- const now = Date.now();
1032
- bucket.entries = bucket.entries.filter((e) => now < e.expiresAt);
1033
-
1034
- bucket.entries.push({ content, createdAt, expiresAt: now + CARD_CACHE_TTL_MS });
1035
-
1036
- if (bucket.entries.length > CARD_CACHE_MAX_PER_CONVERSATION) {
1037
- bucket.entries.sort((a, b) => a.createdAt - b.createdAt);
915
+ if (!storePath) {
916
+ // This fallback only serves short-lived, no-storePath sessions. It is kept
917
+ // local to card-service instead of using the shared message context store
918
+ // because there is no durable scope to share across modules or restarts.
919
+ const scopeKey = `${accountId}:${conversationId}`;
920
+ const nowMs = Date.now();
921
+ const bucket = touchInMemoryCardContentBucket(scopeKey, nowMs);
922
+ bucket.entries.push({ content, createdAt, expiresAt: nowMs + DEFAULT_CARD_CONTENT_TTL_MS });
923
+ bucket.entries.sort((left, right) => left.createdAt - right.createdAt);
1038
924
  bucket.entries = bucket.entries.slice(-CARD_CACHE_MAX_PER_CONVERSATION);
925
+ return;
1039
926
  }
1040
-
1041
- persistCardContentBucket(accountId, conversationId, bucket, storePath);
927
+ upsertOutboundMessageContext({
928
+ storePath,
929
+ accountId,
930
+ conversationId,
931
+ msgId: createSyntheticOutboundMsgId(createdAt),
932
+ createdAt,
933
+ text: content,
934
+ messageType: "card",
935
+ ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
936
+ topic: null,
937
+ });
1042
938
  }
1043
939
 
1044
940
  export function findCardContent(
@@ -1047,50 +943,42 @@ export function findCardContent(
1047
943
  repliedCreatedAt: number,
1048
944
  storePath?: string,
1049
945
  ): string | null {
1050
- const scopedKey = `${accountId}:${conversationId}`;
1051
- let bucket = cardContentStore.get(scopedKey);
1052
- if (!bucket && storePath) {
1053
- const loaded = loadCardContentBucketFromPersistence(accountId, conversationId, storePath);
1054
- if (loaded) {
1055
- cardContentStore.set(scopedKey, loaded);
1056
- bucket = loaded;
1057
- if (cardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
1058
- let oldestKey: string | undefined;
1059
- let oldestTime = Infinity;
1060
- for (const [key, b] of cardContentStore) {
1061
- if (b.lastActiveAt < oldestTime) {
1062
- oldestTime = b.lastActiveAt;
1063
- oldestKey = key;
1064
- }
1065
- }
1066
- if (oldestKey) {
1067
- cardContentStore.delete(oldestKey);
1068
- }
1069
- }
946
+ if (!storePath) {
947
+ const scopeKey = `${accountId}:${conversationId}`;
948
+ const nowMs = Date.now();
949
+ const bucket = inMemoryCardContentStore.get(scopeKey);
950
+ if (!bucket) {
951
+ return null;
1070
952
  }
1071
- }
1072
- if (!bucket) {
1073
- return null;
1074
- }
1075
- bucket.lastActiveAt = Date.now();
1076
-
1077
- let bestContent: string | null = null;
1078
- let bestDelta = Infinity;
1079
-
1080
- for (const entry of bucket.entries) {
1081
- if (Date.now() >= entry.expiresAt) {
1082
- continue;
953
+ bucket.entries = pruneInMemoryCardContentEntries(bucket.entries, nowMs);
954
+ bucket.lastActiveAt = nowMs;
955
+ if (bucket.entries.length === 0) {
956
+ inMemoryCardContentStore.delete(scopeKey);
957
+ return null;
1083
958
  }
1084
- const delta = Math.abs(entry.createdAt - repliedCreatedAt);
1085
- if (delta <= CARD_CACHE_MATCH_WINDOW_MS && delta < bestDelta) {
1086
- bestDelta = delta;
1087
- bestContent = entry.content;
959
+ let bestContent: string | null = null;
960
+ let bestDelta = Infinity;
961
+ for (const entry of bucket.entries) {
962
+ const delta = Math.abs(entry.createdAt - repliedCreatedAt);
963
+ if (delta <= DEFAULT_CREATED_AT_MATCH_WINDOW_MS && delta < bestDelta) {
964
+ bestDelta = delta;
965
+ bestContent = entry.content;
966
+ }
1088
967
  }
968
+ return bestContent;
1089
969
  }
1090
-
1091
- return bestContent;
970
+ const record = resolveByCreatedAtWindow({
971
+ storePath,
972
+ accountId,
973
+ conversationId,
974
+ createdAt: repliedCreatedAt,
975
+ windowMs: DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
976
+ direction: "outbound",
977
+ });
978
+ return record?.text || null;
1092
979
  }
1093
980
 
1094
981
  export function clearCardContentCacheForTest(): void {
1095
- cardContentStore.clear();
982
+ inMemoryCardContentStore.clear();
983
+ clearMessageContextCacheForTest();
1096
984
  }