@soimy/dingtalk 3.6.11 → 3.7.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.
Files changed (161) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +11684 -10583
  3. package/dist/src/{ack-reaction-service.d.ts → ack-reaction/ack-reaction-service.d.ts} +1 -1
  4. package/dist/src/ack-reaction/dynamic-ack-reaction-controller.d.ts +3 -3
  5. package/dist/src/card/ask-user-question-context.d.ts +1 -1
  6. package/dist/src/card/ask-user-question-store.d.ts +1 -1
  7. package/dist/src/card/ask-user-question.d.ts +1 -1
  8. package/dist/src/card/card-action-handler.d.ts +2 -2
  9. package/dist/src/{card-draft-controller.d.ts → card/card-draft-controller.d.ts} +5 -1
  10. package/dist/src/card/card-run-registry.d.ts +2 -2
  11. package/dist/src/{card-service.d.ts → card/card-service.d.ts} +1 -1
  12. package/dist/src/card/card-stop-handler.d.ts +1 -1
  13. package/dist/src/card/card-streaming-mode.d.ts +1 -1
  14. package/dist/src/card/card-task-progress.d.ts +60 -0
  15. package/dist/src/channel.d.ts +6 -6
  16. package/dist/src/command/card-stop-command.d.ts +1 -1
  17. package/dist/src/{feedback-learning-service.d.ts → command/feedback-learning-service.d.ts} +1 -1
  18. package/dist/src/command/inbound-command-dispatch-service.d.ts +2 -2
  19. package/dist/src/{learning-command-service.d.ts → command/learning-command-service.d.ts} +1 -1
  20. package/dist/src/gateway/channel-gateway.d.ts +1 -1
  21. package/dist/src/{connection-manager.d.ts → gateway/connection-manager.d.ts} +1 -1
  22. package/dist/src/{docs-service.d.ts → gateway/docs-service.d.ts} +1 -1
  23. package/dist/src/{inbound-handler.d.ts → gateway/inbound-handler.d.ts} +1 -1
  24. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +1 -1
  25. package/dist/src/messaging/attachment-text-extractor.d.ts +1 -1
  26. package/dist/src/messaging/btw-deliver.d.ts +1 -1
  27. package/dist/src/messaging/channel-outbound.d.ts +1 -1
  28. package/dist/src/{media-utils.d.ts → messaging/media-utils.d.ts} +1 -1
  29. package/dist/src/{message-context-store.d.ts → messaging/message-context-store.d.ts} +7 -1
  30. package/dist/src/{message-utils.d.ts → messaging/message-utils.d.ts} +1 -1
  31. package/dist/src/messaging/quoted-context.d.ts +2 -2
  32. package/dist/src/messaging/quoted-file-service.d.ts +1 -1
  33. package/dist/src/messaging/quoted-ref.d.ts +2 -2
  34. package/dist/src/{reply-strategy-card.d.ts → messaging/reply-strategy-card.d.ts} +1 -1
  35. package/dist/src/{reply-strategy-types.d.ts → messaging/reply-strategy-types.d.ts} +10 -1
  36. package/dist/src/{reply-strategy-with-reaction.d.ts → messaging/reply-strategy-with-reaction.d.ts} +1 -1
  37. package/dist/src/{reply-strategy.d.ts → messaging/reply-strategy.d.ts} +1 -1
  38. package/dist/src/{send-service.d.ts → messaging/send-service.d.ts} +1 -1
  39. package/dist/src/platform/channel-status.d.ts +1 -1
  40. package/dist/src/platform/config.d.ts +110 -0
  41. package/dist/src/platform/runtime-events.d.ts +67 -0
  42. package/dist/src/{types.d.ts → platform/types.d.ts} +74 -1
  43. package/dist/src/{persistence-store.d.ts → shared/persistence-store.d.ts} +1 -1
  44. package/dist/src/{utils.d.ts → shared/utils.d.ts} +1 -1
  45. package/dist/src/targeting/agent-name-matcher.d.ts +1 -1
  46. package/dist/src/targeting/agent-routing.d.ts +3 -3
  47. package/dist/src/{session-routing.d.ts → targeting/session-routing.d.ts} +1 -1
  48. package/index.ts +193 -69
  49. package/openclaw.plugin.json +158 -2
  50. package/package.json +16 -16
  51. package/src/{ack-reaction-classifier.ts → ack-reaction/ack-reaction-classifier.ts} +85 -13
  52. package/src/{ack-reaction-service.ts → ack-reaction/ack-reaction-service.ts} +13 -7
  53. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +46 -31
  54. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +4 -1
  55. package/src/card/ask-user-question-context.ts +1 -1
  56. package/src/card/ask-user-question-store.ts +2 -2
  57. package/src/card/ask-user-question.ts +11 -7
  58. package/src/card/card-action-handler.ts +2 -2
  59. package/src/{card-callback-service.ts → card/card-callback-service.ts} +7 -9
  60. package/src/card/card-draft-controller.ts +770 -0
  61. package/src/card/card-markdown-image-reroute.ts +8 -2
  62. package/src/card/card-run-registry.ts +17 -7
  63. package/src/{card-service.ts → card/card-service.ts} +91 -61
  64. package/src/card/card-stop-handler.ts +12 -8
  65. package/src/card/card-streaming-mode.ts +1 -1
  66. package/src/card/card-task-progress.ts +330 -0
  67. package/src/card/draft-stream-loop.ts +119 -0
  68. package/src/card/reasoning-answer-split.ts +125 -121
  69. package/src/card/reasoning-block-assembler.ts +122 -123
  70. package/src/{run-usage-store.ts → card/run-usage-store.ts} +18 -6
  71. package/src/card/statusline-renderer.ts +14 -5
  72. package/src/card/task-model-metadata.ts +8 -3
  73. package/src/channel.ts +14 -16
  74. package/src/command/card-stop-command.ts +2 -2
  75. package/src/{feedback-learning-service.ts → command/feedback-learning-service.ts} +36 -16
  76. package/src/{feedback-learning-store.ts → command/feedback-learning-store.ts} +133 -81
  77. package/src/command/inbound-command-dispatch-service.ts +12 -6
  78. package/src/{learning-command-service.ts → command/learning-command-service.ts} +36 -29
  79. package/src/{session-command-service.ts → command/session-command-service.ts} +3 -1
  80. package/src/gateway/channel-gateway.ts +106 -28
  81. package/src/{connection-manager.ts → gateway/connection-manager.ts} +45 -30
  82. package/src/{docs-service.ts → gateway/docs-service.ts} +5 -5
  83. package/src/{inbound-handler.ts → gateway/inbound-handler.ts} +139 -84
  84. package/src/gateway/inbound-session-queue-dispatcher.ts +18 -14
  85. package/src/{session-lock.ts → gateway/session-lock.ts} +14 -14
  86. package/src/messaging/attachment-text-extractor.ts +21 -7
  87. package/src/messaging/btw-deliver.ts +2 -2
  88. package/src/messaging/channel-actions.ts +15 -7
  89. package/src/messaging/channel-outbound.ts +16 -11
  90. package/src/{media-utils.ts → messaging/media-utils.ts} +276 -72
  91. package/src/{message-context-store.ts → messaging/message-context-store.ts} +11 -2
  92. package/src/{message-utils.ts → messaging/message-utils.ts} +46 -11
  93. package/src/messaging/quoted-context.ts +2 -2
  94. package/src/messaging/quoted-file-service.ts +311 -294
  95. package/src/messaging/quoted-ref.ts +11 -7
  96. package/src/{reply-strategy-card.ts → messaging/reply-strategy-card.ts} +178 -101
  97. package/src/{reply-strategy-markdown.ts → messaging/reply-strategy-markdown.ts} +8 -2
  98. package/src/{reply-strategy-types.ts → messaging/reply-strategy-types.ts} +11 -1
  99. package/src/{reply-strategy-with-reaction.ts → messaging/reply-strategy-with-reaction.ts} +6 -1
  100. package/src/{reply-strategy.ts → messaging/reply-strategy.ts} +2 -2
  101. package/src/{send-service.ts → messaging/send-service.ts} +100 -65
  102. package/src/{auth.ts → platform/auth.ts} +2 -2
  103. package/src/platform/channel-status.ts +3 -3
  104. package/src/{config-schema.ts → platform/config-schema.ts} +81 -4
  105. package/src/{config.ts → platform/config.ts} +167 -19
  106. package/src/{device-registration.ts → platform/device-registration.ts} +5 -4
  107. package/src/{onboarding.ts → platform/onboarding.ts} +1 -4
  108. package/src/{plugin-sdk-channel-actions-augment.ts → platform/plugin-sdk-channel-actions-augment.ts} +6 -5
  109. package/src/platform/runtime-events.ts +202 -0
  110. package/src/{secret-input.ts → platform/secret-input.ts} +117 -11
  111. package/src/{types.ts → platform/types.ts} +76 -1
  112. package/src/{http-client.ts → shared/http-client.ts} +1 -3
  113. package/src/{persistence-store.ts → shared/persistence-store.ts} +6 -6
  114. package/src/{utils.ts → shared/utils.ts} +30 -13
  115. package/src/targeting/agent-name-matcher.ts +1 -1
  116. package/src/targeting/agent-routing.ts +7 -7
  117. package/src/targeting/group-members-store.ts +1 -1
  118. package/src/{session-peer-store.ts → targeting/session-peer-store.ts} +9 -4
  119. package/src/{session-routing.ts → targeting/session-routing.ts} +1 -1
  120. package/src/targeting/target-directory-adapter.ts +3 -3
  121. package/src/targeting/target-directory-store.ts +1 -1
  122. package/src/targeting/target-input.ts +1 -1
  123. package/dist/src/ack-reaction/dynamic-ack-reaction-events.d.ts +0 -35
  124. package/dist/src/config.d.ts +0 -59
  125. package/src/ack-reaction/dynamic-ack-reaction-events.ts +0 -123
  126. package/src/card-draft-controller.ts +0 -637
  127. package/src/draft-stream-loop.ts +0 -119
  128. /package/dist/src/{ack-reaction-classifier.d.ts → ack-reaction/ack-reaction-classifier.d.ts} +0 -0
  129. /package/dist/src/{card-callback-service.d.ts → card/card-callback-service.d.ts} +0 -0
  130. /package/dist/src/{draft-stream-loop.d.ts → card/draft-stream-loop.d.ts} +0 -0
  131. /package/dist/src/{run-usage-store.d.ts → card/run-usage-store.d.ts} +0 -0
  132. /package/dist/src/{feedback-learning-store.d.ts → command/feedback-learning-store.d.ts} +0 -0
  133. /package/dist/src/{session-command-service.d.ts → command/session-command-service.d.ts} +0 -0
  134. /package/dist/src/{session-lock.d.ts → gateway/session-lock.d.ts} +0 -0
  135. /package/dist/src/{proactive-risk-registry.d.ts → messaging/proactive-risk-registry.d.ts} +0 -0
  136. /package/dist/src/{reply-strategy-markdown.d.ts → messaging/reply-strategy-markdown.d.ts} +0 -0
  137. /package/dist/src/{access-control.d.ts → platform/access-control.d.ts} +0 -0
  138. /package/dist/src/{auth.d.ts → platform/auth.d.ts} +0 -0
  139. /package/dist/src/{config-schema.d.ts → platform/config-schema.d.ts} +0 -0
  140. /package/dist/src/{device-registration.d.ts → platform/device-registration.d.ts} +0 -0
  141. /package/dist/src/{logger-context.d.ts → platform/logger-context.d.ts} +0 -0
  142. /package/dist/src/{onboarding.d.ts → platform/onboarding.d.ts} +0 -0
  143. /package/dist/src/{plugin-sdk-channel-actions-augment.d.ts → platform/plugin-sdk-channel-actions-augment.d.ts} +0 -0
  144. /package/dist/src/{runtime.d.ts → platform/runtime.d.ts} +0 -0
  145. /package/dist/src/{secret-input.d.ts → platform/secret-input.d.ts} +0 -0
  146. /package/dist/src/{session-state.d.ts → platform/session-state.d.ts} +0 -0
  147. /package/dist/src/{signature.d.ts → platform/signature.d.ts} +0 -0
  148. /package/dist/src/{dedup.d.ts → shared/dedup.d.ts} +0 -0
  149. /package/dist/src/{http-client.d.ts → shared/http-client.d.ts} +0 -0
  150. /package/dist/src/{path-utils.d.ts → shared/path-utils.d.ts} +0 -0
  151. /package/dist/src/{peer-id-registry.d.ts → targeting/peer-id-registry.d.ts} +0 -0
  152. /package/dist/src/{session-peer-store.d.ts → targeting/session-peer-store.d.ts} +0 -0
  153. /package/src/{proactive-risk-registry.ts → messaging/proactive-risk-registry.ts} +0 -0
  154. /package/src/{access-control.ts → platform/access-control.ts} +0 -0
  155. /package/src/{logger-context.ts → platform/logger-context.ts} +0 -0
  156. /package/src/{runtime.ts → platform/runtime.ts} +0 -0
  157. /package/src/{session-state.ts → platform/session-state.ts} +0 -0
  158. /package/src/{signature.ts → platform/signature.ts} +0 -0
  159. /package/src/{dedup.ts → shared/dedup.ts} +0 -0
  160. /package/src/{path-utils.ts → shared/path-utils.ts} +0 -0
  161. /package/src/{peer-id-registry.ts → targeting/peer-id-registry.ts} +0 -0
@@ -1,16 +1,16 @@
1
1
  import http from "node:http";
2
2
  import https from "node:https";
3
- import axios from "../http-client";
4
- import { getAccessToken } from "../auth";
5
- import { getDingTalkRuntime } from "../runtime";
6
- import type { DingTalkConfig, Logger, MediaFile } from "../types";
7
- import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../utils";
3
+ import { getAccessToken } from "../platform/auth";
4
+ import { getDingTalkRuntime } from "../platform/runtime";
5
+ import type { DingTalkConfig, Logger, MediaFile } from "../platform/types";
6
+ import axios from "../shared/http-client";
7
+ import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../shared/utils";
8
8
 
9
9
  function asRecord(value: unknown): Record<string, unknown> | undefined {
10
- if (!value || typeof value !== "object" || Array.isArray(value)) {
11
- return undefined;
12
- }
13
- return value as Record<string, unknown>;
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ return undefined;
12
+ }
13
+ return value as Record<string, unknown>;
14
14
  }
15
15
 
16
16
  const ipv4OnlyHttpAgent = new http.Agent({ family: 4 });
@@ -27,26 +27,28 @@ const UNION_ID_CACHE_MAX = 5000;
27
27
  const SPACE_ID_CACHE_MAX = 500;
28
28
 
29
29
  function describeResolveError(err: unknown): string {
30
- if (axios.isAxiosError(err)) {
31
- const status = err.response?.status;
32
- const statusText = err.response?.statusText;
33
- const statusLabel = status ? `status=${status}${statusText ? ` ${statusText}` : ""}` : "status=unknown";
34
- const code = typeof err.code === "string" && err.code ? ` code=${err.code}` : "";
35
- const hasRequest = err.request ? " request=yes" : " request=no";
36
- const hasResponse = err.response ? " response=yes" : " response=no";
37
- if (err.response?.data !== undefined) {
38
- return `${statusLabel}${code}${hasRequest}${hasResponse} ${formatDingTalkErrorPayload(err.response.data)}`;
39
- }
40
- return `${statusLabel}${code}${hasRequest}${hasResponse} message=${err.message || "unknown axios error"}`;
41
- }
42
- if (err instanceof Error) {
43
- return `${err.name || "Error"} message=${err.message || "unknown error"}`;
44
- }
45
- try {
46
- return JSON.stringify(err);
47
- } catch {
48
- return String(err);
30
+ if (axios.isAxiosError(err)) {
31
+ const status = err.response?.status;
32
+ const statusText = err.response?.statusText;
33
+ const statusLabel = status
34
+ ? `status=${status}${statusText ? ` ${statusText}` : ""}`
35
+ : "status=unknown";
36
+ const code = typeof err.code === "string" && err.code ? ` code=${err.code}` : "";
37
+ const hasRequest = err.request ? " request=yes" : " request=no";
38
+ const hasResponse = err.response ? " response=yes" : " response=no";
39
+ if (err.response?.data !== undefined) {
40
+ return `${statusLabel}${code}${hasRequest}${hasResponse} ${formatDingTalkErrorPayload(err.response.data)}`;
49
41
  }
42
+ return `${statusLabel}${code}${hasRequest}${hasResponse} message=${err.message || "unknown axios error"}`;
43
+ }
44
+ if (err instanceof Error) {
45
+ return `${err.name || "Error"} message=${err.message || "unknown error"}`;
46
+ }
47
+ try {
48
+ return JSON.stringify(err);
49
+ } catch {
50
+ return String(err);
51
+ }
50
52
  }
51
53
 
52
54
  // ============ LRU caches ============
@@ -55,336 +57,351 @@ const unionIdCache = new Map<string, string>();
55
57
  const spaceIdCache = new Map<string, string>();
56
58
 
57
59
  function lruSet<V>(map: Map<string, V>, key: string, value: V, maxSize: number): void {
58
- if (map.has(key)) {
59
- map.delete(key);
60
- }
61
- map.set(key, value);
62
- if (map.size > maxSize) {
63
- const oldest = map.keys().next().value;
64
- if (oldest !== undefined) {
65
- map.delete(oldest);
66
- }
60
+ if (map.has(key)) {
61
+ map.delete(key);
62
+ }
63
+ map.set(key, value);
64
+ if (map.size > maxSize) {
65
+ const oldest = map.keys().next().value;
66
+ if (oldest !== undefined) {
67
+ map.delete(oldest);
67
68
  }
69
+ }
68
70
  }
69
71
 
70
72
  function lruGet<V>(map: Map<string, V>, key: string): V | undefined {
71
- const value = map.get(key);
72
- if (value === undefined) {
73
- return undefined;
74
- }
75
- map.delete(key);
76
- map.set(key, value);
77
- return value;
73
+ const value = map.get(key);
74
+ if (value === undefined) {
75
+ return undefined;
76
+ }
77
+ map.delete(key);
78
+ map.set(key, value);
79
+ return value;
78
80
  }
79
81
 
80
82
  // ============ Time parsing ============
81
83
 
82
84
  export function parseDingTalkFileTime(timeStr: string): number {
83
- const normalized = timeStr.replace(/\bCST\b/, "+0800");
84
- const ms = new Date(normalized).getTime();
85
- if (Number.isNaN(ms)) {
86
- throw new Error(`Cannot parse DingTalk file time: ${timeStr}`);
87
- }
88
- return ms;
85
+ const normalized = timeStr.replace(/\bCST\b/, "+0800");
86
+ const ms = new Date(normalized).getTime();
87
+ if (Number.isNaN(ms)) {
88
+ throw new Error(`Cannot parse DingTalk file time: ${timeStr}`);
89
+ }
90
+ return ms;
89
91
  }
90
92
 
91
93
  // ============ API helpers ============
92
94
 
93
95
  export async function getUnionIdByStaffId(
94
- config: DingTalkConfig,
95
- staffId: string,
96
- log?: Logger,
96
+ config: DingTalkConfig,
97
+ staffId: string,
98
+ log?: Logger,
97
99
  ): Promise<string> {
98
- const cacheKey = `${config.clientId}:${staffId}`;
99
- const cached = lruGet(unionIdCache, cacheKey);
100
- if (cached) {
101
- return cached;
102
- }
103
-
104
- const token = await getAccessToken(config, log);
105
- const resp = await axios.post(
106
- `${DINGTALK_OAPI}/topapi/v2/user/get?access_token=${token}`,
107
- { userid: staffId },
108
- );
109
-
110
- const payload = asRecord(resp.data) ?? {};
111
- const errcode = typeof payload.errcode === "number" || typeof payload.errcode === "string"
112
- ? String(payload.errcode)
113
- : "unknown";
114
- const errmsg = typeof payload.errmsg === "string" ? payload.errmsg : "unknown";
115
- if (payload.errcode !== 0) {
116
- throw new Error(`topapi/v2/user/get failed: errcode=${errcode} errmsg=${errmsg}`);
117
- }
118
-
119
- const unionIdValue = asRecord(payload.result)?.unionid;
120
- const unionId = typeof unionIdValue === "string" ? unionIdValue : undefined;
121
- if (!unionId) {
122
- throw new Error(`topapi/v2/user/get returned no unionid for staffId=${staffId}`);
123
- }
124
-
125
- lruSet(unionIdCache, cacheKey, unionId, UNION_ID_CACHE_MAX);
126
- return unionId;
100
+ const cacheKey = `${config.clientId}:${staffId}`;
101
+ const cached = lruGet(unionIdCache, cacheKey);
102
+ if (cached) {
103
+ return cached;
104
+ }
105
+
106
+ const token = await getAccessToken(config, log);
107
+ const resp = await axios.post(`${DINGTALK_OAPI}/topapi/v2/user/get?access_token=${token}`, {
108
+ userid: staffId,
109
+ });
110
+
111
+ const payload = asRecord(resp.data) ?? {};
112
+ const errcode =
113
+ typeof payload.errcode === "number" || typeof payload.errcode === "string"
114
+ ? String(payload.errcode)
115
+ : "unknown";
116
+ const errmsg = typeof payload.errmsg === "string" ? payload.errmsg : "unknown";
117
+ if (payload.errcode !== 0) {
118
+ throw new Error(`topapi/v2/user/get failed: errcode=${errcode} errmsg=${errmsg}`);
119
+ }
120
+
121
+ const unionIdValue = asRecord(payload.result)?.unionid;
122
+ const unionId = typeof unionIdValue === "string" ? unionIdValue : undefined;
123
+ if (!unionId) {
124
+ throw new Error(`topapi/v2/user/get returned no unionid for staffId=${staffId}`);
125
+ }
126
+
127
+ lruSet(unionIdCache, cacheKey, unionId, UNION_ID_CACHE_MAX);
128
+ return unionId;
127
129
  }
128
130
 
129
131
  export async function getGroupFileSpaceId(
130
- config: DingTalkConfig,
131
- openConversationId: string,
132
- unionId: string,
133
- log?: Logger,
132
+ config: DingTalkConfig,
133
+ openConversationId: string,
134
+ unionId: string,
135
+ log?: Logger,
134
136
  ): Promise<string> {
135
- const cacheKey = `${config.clientId}:${openConversationId}`;
136
- const cached = lruGet(spaceIdCache, cacheKey);
137
- if (cached) {
138
- return cached;
139
- }
140
-
141
- const token = await getAccessToken(config, log);
142
- const resp = await axios.post(
143
- `${DINGTALK_API}/v1.0/convFile/conversations/spaces/query`,
144
- { openConversationId, unionId },
145
- { headers: { "x-acs-dingtalk-access-token": token } },
137
+ const cacheKey = `${config.clientId}:${openConversationId}`;
138
+ const cached = lruGet(spaceIdCache, cacheKey);
139
+ if (cached) {
140
+ return cached;
141
+ }
142
+
143
+ const token = await getAccessToken(config, log);
144
+ const resp = await axios.post(
145
+ `${DINGTALK_API}/v1.0/convFile/conversations/spaces/query`,
146
+ { openConversationId, unionId },
147
+ { headers: { "x-acs-dingtalk-access-token": token } },
148
+ );
149
+
150
+ const spaceIdValue = asRecord(asRecord(resp.data)?.space)?.spaceId;
151
+ const spaceId = typeof spaceIdValue === "string" ? spaceIdValue : undefined;
152
+ if (!spaceId) {
153
+ throw new Error(
154
+ `convFile spaces/query returned no spaceId for conversationId=${openConversationId}`,
146
155
  );
156
+ }
147
157
 
148
- const spaceIdValue = asRecord(asRecord(resp.data)?.space)?.spaceId;
149
- const spaceId = typeof spaceIdValue === "string" ? spaceIdValue : undefined;
150
- if (!spaceId) {
151
- throw new Error(`convFile spaces/query returned no spaceId for conversationId=${openConversationId}`);
152
- }
153
-
154
- lruSet(spaceIdCache, cacheKey, spaceId, SPACE_ID_CACHE_MAX);
155
- return spaceId;
158
+ lruSet(spaceIdCache, cacheKey, spaceId, SPACE_ID_CACHE_MAX);
159
+ return spaceId;
156
160
  }
157
161
 
158
162
  interface DentryMatch {
159
- dentryId: string;
160
- name: string;
163
+ dentryId: string;
164
+ name: string;
161
165
  }
162
166
 
163
167
  export interface ResolvedQuotedFile {
164
- media: MediaFile;
165
- spaceId: string;
166
- fileId: string;
167
- name?: string;
168
+ media: MediaFile;
169
+ spaceId: string;
170
+ fileId: string;
171
+ name?: string;
168
172
  }
169
173
 
170
174
  export async function findFileByTimestamp(
171
- config: DingTalkConfig,
172
- spaceId: string,
173
- unionId: string,
174
- createdAt: number,
175
- log?: Logger,
175
+ config: DingTalkConfig,
176
+ spaceId: string,
177
+ unionId: string,
178
+ createdAt: number,
179
+ log?: Logger,
176
180
  ): Promise<DentryMatch | null> {
177
- const token = await getAccessToken(config, log);
178
-
179
- let bestMatch: DentryMatch | null = null;
180
- let bestDelta = Infinity;
181
- let nextToken: string | undefined;
182
-
183
- for (let page = 0; page < MAX_PAGES; page++) {
184
- const body: Record<string, unknown> = {
185
- option: { maxResults: PAGE_SIZE },
186
- };
187
- if (nextToken) {
188
- (body.option as { nextToken?: string }).nextToken = nextToken;
189
- }
181
+ const token = await getAccessToken(config, log);
182
+
183
+ let bestMatch: DentryMatch | null = null;
184
+ let bestDelta = Infinity;
185
+ let nextToken: string | undefined;
186
+
187
+ for (let page = 0; page < MAX_PAGES; page++) {
188
+ const body: Record<string, unknown> = {
189
+ option: { maxResults: PAGE_SIZE },
190
+ };
191
+ if (nextToken) {
192
+ (body.option as { nextToken?: string }).nextToken = nextToken;
193
+ }
190
194
 
191
- const resp = await axios.post(
192
- `${DINGTALK_API}/v1.0/storage/spaces/${spaceId}/dentries/listAll?unionId=${unionId}`,
193
- body,
194
- { headers: { "x-acs-dingtalk-access-token": token } },
195
- );
195
+ const resp = await axios.post(
196
+ `${DINGTALK_API}/v1.0/storage/spaces/${spaceId}/dentries/listAll?unionId=${unionId}`,
197
+ body,
198
+ { headers: { "x-acs-dingtalk-access-token": token } },
199
+ );
196
200
 
197
- const data = asRecord(resp.data) ?? {};
198
- const dentries = Array.isArray(data.dentries) ? data.dentries as Array<Record<string, unknown>> : [];
199
-
200
- for (const entry of dentries) {
201
- if (entry.type !== "FILE" || !entry.createTime) {
202
- continue;
203
- }
204
- try {
205
- const fileTime = parseDingTalkFileTime(entry.createTime as string);
206
- const delta = Math.abs(fileTime - createdAt);
207
- if (delta <= MATCH_WINDOW_MS && delta < bestDelta) {
208
- bestDelta = delta;
209
- bestMatch = { dentryId: entry.id as string, name: entry.name as string };
210
- }
211
- } catch {
212
- const createTime = typeof entry.createTime === "string" ? entry.createTime : JSON.stringify(entry.createTime);
213
- log?.debug?.(`[DingTalk][QuotedFile] Failed to parse createTime: ${createTime}`);
214
- }
201
+ const data = asRecord(resp.data) ?? {};
202
+ const dentries = Array.isArray(data.dentries)
203
+ ? (data.dentries as Array<Record<string, unknown>>)
204
+ : [];
205
+
206
+ for (const entry of dentries) {
207
+ if (entry.type !== "FILE" || !entry.createTime) {
208
+ continue;
209
+ }
210
+ try {
211
+ const fileTime = parseDingTalkFileTime(entry.createTime as string);
212
+ const delta = Math.abs(fileTime - createdAt);
213
+ if (delta <= MATCH_WINDOW_MS && delta < bestDelta) {
214
+ bestDelta = delta;
215
+ bestMatch = { dentryId: entry.id as string, name: entry.name as string };
215
216
  }
217
+ } catch {
218
+ const createTime =
219
+ typeof entry.createTime === "string"
220
+ ? entry.createTime
221
+ : JSON.stringify(entry.createTime);
222
+ log?.debug?.(`[DingTalk][QuotedFile] Failed to parse createTime: ${createTime}`);
223
+ }
224
+ }
216
225
 
217
- nextToken = typeof data.nextToken === "string" ? data.nextToken : undefined;
218
- if (!nextToken) {
219
- break;
220
- }
226
+ nextToken = typeof data.nextToken === "string" ? data.nextToken : undefined;
227
+ if (!nextToken) {
228
+ break;
221
229
  }
230
+ }
222
231
 
223
- return bestMatch;
232
+ return bestMatch;
224
233
  }
225
234
 
226
235
  export async function downloadGroupFile(
227
- config: DingTalkConfig,
228
- spaceId: string,
229
- dentryId: string,
230
- unionId: string,
231
- log?: Logger,
232
- originalFilename?: string,
236
+ config: DingTalkConfig,
237
+ spaceId: string,
238
+ dentryId: string,
239
+ unionId: string,
240
+ log?: Logger,
241
+ originalFilename?: string,
233
242
  ): Promise<MediaFile | null> {
234
- const rt = getDingTalkRuntime();
235
- const token = await getAccessToken(config, log);
236
- let resourceUrl = "";
237
- let contentType = "application/octet-stream";
243
+ const rt = getDingTalkRuntime();
244
+ const token = await getAccessToken(config, log);
245
+ let resourceUrl = "";
246
+ let contentType = "application/octet-stream";
247
+
248
+ try {
249
+ const infoResp = await axios.post(
250
+ `${DINGTALK_API}/v1.0/storage/spaces/${spaceId}/dentries/${dentryId}/downloadInfos/query?unionId=${unionId}`,
251
+ {},
252
+ { headers: { "x-acs-dingtalk-access-token": token } },
253
+ );
238
254
 
239
- try {
240
- const infoResp = await axios.post(
241
- `${DINGTALK_API}/v1.0/storage/spaces/${spaceId}/dentries/${dentryId}/downloadInfos/query?unionId=${unionId}`,
242
- {},
243
- { headers: { "x-acs-dingtalk-access-token": token } },
244
- );
255
+ const info = asRecord(infoResp.data) ?? {};
256
+ const headerSig = asRecord(info.headerSignatureInfo);
257
+ const resourceUrls =
258
+ headerSig && Array.isArray(headerSig.resourceUrls) ? headerSig.resourceUrls : undefined;
259
+ resourceUrl = typeof resourceUrls?.[0] === "string" ? resourceUrls[0] : "";
260
+ if (!resourceUrl) {
261
+ log?.warn?.("[DingTalk][QuotedFile] downloadInfos/query returned no resourceUrl");
262
+ return null;
263
+ }
245
264
 
246
- const info = asRecord(infoResp.data) ?? {};
247
- const headerSig = asRecord(info.headerSignatureInfo);
248
- const resourceUrls = headerSig && Array.isArray(headerSig.resourceUrls) ? headerSig.resourceUrls : undefined;
249
- resourceUrl = typeof resourceUrls?.[0] === "string" ? resourceUrls[0] : "";
250
- if (!resourceUrl) {
251
- log?.warn?.("[DingTalk][QuotedFile] downloadInfos/query returned no resourceUrl");
252
- return null;
265
+ const sigHeaders: Record<string, string> = {};
266
+ const headerMap = asRecord(headerSig?.headers);
267
+ if (headerMap) {
268
+ for (const [k, v] of Object.entries(headerMap)) {
269
+ if (typeof v === "string") {
270
+ sigHeaders[k] = v;
253
271
  }
272
+ }
273
+ }
274
+ let fileResp;
275
+ try {
276
+ fileResp = await axios.get(resourceUrl, {
277
+ headers: sigHeaders,
278
+ responseType: "arraybuffer",
279
+ timeout: 15000,
280
+ });
281
+ } catch (firstErr: unknown) {
282
+ log?.warn?.(
283
+ `[DingTalk][QuotedFile] CDN download failed on default network path, retrying with IPv4-only: ${describeResolveError(firstErr)}`,
284
+ );
285
+ try {
286
+ fileResp = await axios.get(resourceUrl, {
287
+ headers: sigHeaders,
288
+ responseType: "arraybuffer",
289
+ httpAgent: ipv4OnlyHttpAgent,
290
+ httpsAgent: ipv4OnlyHttpsAgent,
291
+ timeout: 15000,
292
+ });
293
+ } catch (retryErr: unknown) {
294
+ const host = resourceUrl ? new URL(resourceUrl).host : "(unknown-host)";
295
+ throw new Error(
296
+ `download-resource failed host=${host} detail=${describeResolveError(retryErr)}`,
297
+ { cause: retryErr },
298
+ );
299
+ }
300
+ }
254
301
 
255
- const sigHeaders: Record<string, string> = {};
256
- const headerMap = asRecord(headerSig?.headers);
257
- if (headerMap) {
258
- for (const [k, v] of Object.entries(headerMap)) {
259
- if (typeof v === "string") {
260
- sigHeaders[k] = v;
261
- }
262
- }
263
- }
264
- let fileResp;
265
- try {
266
- fileResp = await axios.get(resourceUrl, {
267
- headers: sigHeaders,
268
- responseType: "arraybuffer",
269
- timeout: 15000,
270
- });
271
- } catch (firstErr: unknown) {
272
- log?.warn?.(
273
- `[DingTalk][QuotedFile] CDN download failed on default network path, retrying with IPv4-only: ${describeResolveError(firstErr)}`,
274
- );
275
- try {
276
- fileResp = await axios.get(resourceUrl, {
277
- headers: sigHeaders,
278
- responseType: "arraybuffer",
279
- httpAgent: ipv4OnlyHttpAgent,
280
- httpsAgent: ipv4OnlyHttpsAgent,
281
- timeout: 15000,
282
- });
283
- } catch (retryErr: unknown) {
284
- const host = resourceUrl ? new URL(resourceUrl).host : "(unknown-host)";
285
- throw new Error(
286
- `download-resource failed host=${host} detail=${describeResolveError(retryErr)}`,
287
- { cause: retryErr },
288
- );
289
- }
290
- }
302
+ contentType = (fileResp.headers["content-type"] as string) || "application/octet-stream";
303
+ const buffer = Buffer.from(fileResp.data as ArrayBuffer);
291
304
 
292
- contentType = (fileResp.headers["content-type"] as string) || "application/octet-stream";
293
- const buffer = Buffer.from(fileResp.data as ArrayBuffer);
294
-
295
- const maxBytes =
296
- config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
297
- try {
298
- const saved = await rt.channel.media.saveMediaBuffer(
299
- buffer,
300
- contentType,
301
- "inbound",
302
- maxBytes,
303
- originalFilename,
304
- );
305
-
306
- return { path: saved.path, mimeType: saved.contentType ?? contentType };
307
- } catch (err: unknown) {
308
- throw new Error(
309
- `save-buffer failed contentType=${contentType} detail=${describeResolveError(err)}`,
310
- { cause: err },
311
- );
312
- }
305
+ const maxBytes =
306
+ config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
307
+ try {
308
+ const saved = await rt.channel.media.saveMediaBuffer(
309
+ buffer,
310
+ contentType,
311
+ "inbound",
312
+ maxBytes,
313
+ originalFilename,
314
+ );
315
+
316
+ return { path: saved.path, mimeType: saved.contentType ?? contentType };
313
317
  } catch (err: unknown) {
314
- if (axios.isAxiosError(err) && err.response?.data !== undefined) {
315
- log?.warn?.(formatDingTalkErrorPayloadLog("quotedFile.downloadGroupFile", err.response.data));
316
- }
317
- log?.warn?.(
318
- `[DingTalk][QuotedFile] downloadGroupFile failed: spaceId=${spaceId} dentryId=${dentryId} resourceUrl=${resourceUrl || "(none)"} contentType=${contentType} error=${describeResolveError(err)}`,
319
- );
320
- return null;
318
+ throw new Error(
319
+ `save-buffer failed contentType=${contentType} detail=${describeResolveError(err)}`,
320
+ { cause: err },
321
+ );
322
+ }
323
+ } catch (err: unknown) {
324
+ if (axios.isAxiosError(err) && err.response?.data !== undefined) {
325
+ log?.warn?.(formatDingTalkErrorPayloadLog("quotedFile.downloadGroupFile", err.response.data));
321
326
  }
327
+ log?.warn?.(
328
+ `[DingTalk][QuotedFile] downloadGroupFile failed: spaceId=${spaceId} dentryId=${dentryId} resourceUrl=${resourceUrl || "(none)"} contentType=${contentType} error=${describeResolveError(err)}`,
329
+ );
330
+ return null;
331
+ }
322
332
  }
323
333
 
324
334
  // ============ Composite entry point ============
325
335
 
326
336
  export interface ResolveQuotedFileParams {
327
- openConversationId: string;
328
- senderStaffId?: string;
329
- fileCreatedAt?: number;
337
+ openConversationId: string;
338
+ senderStaffId?: string;
339
+ fileCreatedAt?: number;
330
340
  }
331
341
 
332
342
  export async function resolveQuotedFile(
333
- config: DingTalkConfig,
334
- params: ResolveQuotedFileParams,
335
- log?: Logger,
343
+ config: DingTalkConfig,
344
+ params: ResolveQuotedFileParams,
345
+ log?: Logger,
336
346
  ): Promise<ResolvedQuotedFile | null> {
337
- const { openConversationId, senderStaffId, fileCreatedAt } = params;
338
- let stage = "init";
339
-
340
- if (!senderStaffId || !fileCreatedAt) {
341
- log?.warn?.("[DingTalk][QuotedFile] Missing senderStaffId or fileCreatedAt, skipping");
342
- return null;
347
+ const { openConversationId, senderStaffId, fileCreatedAt } = params;
348
+ let stage = "init";
349
+
350
+ if (!senderStaffId || !fileCreatedAt) {
351
+ log?.warn?.("[DingTalk][QuotedFile] Missing senderStaffId or fileCreatedAt, skipping");
352
+ return null;
353
+ }
354
+
355
+ try {
356
+ stage = "resolve-unionId";
357
+ const unionId = await getUnionIdByStaffId(config, senderStaffId, log);
358
+ stage = "resolve-spaceId";
359
+ const spaceId = await getGroupFileSpaceId(config, openConversationId, unionId, log);
360
+ stage = "list-and-match";
361
+ const match = await findFileByTimestamp(config, spaceId, unionId, fileCreatedAt, log);
362
+
363
+ if (!match) {
364
+ log?.warn?.(
365
+ `[DingTalk][QuotedFile] No file matched within ±${MATCH_WINDOW_MS}ms window for createdAt=${fileCreatedAt}`,
366
+ );
367
+ return null;
343
368
  }
344
369
 
345
- try {
346
- stage = "resolve-unionId";
347
- const unionId = await getUnionIdByStaffId(config, senderStaffId, log);
348
- stage = "resolve-spaceId";
349
- const spaceId = await getGroupFileSpaceId(config, openConversationId, unionId, log);
350
- stage = "list-and-match";
351
- const match = await findFileByTimestamp(config, spaceId, unionId, fileCreatedAt, log);
352
-
353
- if (!match) {
354
- log?.warn?.(
355
- `[DingTalk][QuotedFile] No file matched within ±${MATCH_WINDOW_MS}ms window for createdAt=${fileCreatedAt}`,
356
- );
357
- return null;
358
- }
359
-
360
- stage = "download-file";
361
- const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log, match.name);
362
- if (!media) {
363
- return null;
364
- }
370
+ stage = "download-file";
371
+ const media = await downloadGroupFile(
372
+ config,
373
+ spaceId,
374
+ match.dentryId,
375
+ unionId,
376
+ log,
377
+ match.name,
378
+ );
379
+ if (!media) {
380
+ return null;
381
+ }
365
382
 
366
- return {
367
- media,
368
- spaceId,
369
- fileId: match.dentryId,
370
- name: match.name,
371
- };
372
- } catch (err: unknown) {
373
- if (log?.warn) {
374
- if (axios.isAxiosError(err) && err.response?.data !== undefined) {
375
- log.warn(formatDingTalkErrorPayloadLog("quotedFile.resolve", err.response.data));
376
- }
377
- log.warn(
378
- `[DingTalk][QuotedFile] Failed to resolve quoted file: stage=${stage} conversationId=${openConversationId} senderStaffId=${senderStaffId} fileCreatedAt=${fileCreatedAt} error=${describeResolveError(err)}`,
379
- );
380
- }
381
- return null;
383
+ return {
384
+ media,
385
+ spaceId,
386
+ fileId: match.dentryId,
387
+ name: match.name,
388
+ };
389
+ } catch (err: unknown) {
390
+ if (log?.warn) {
391
+ if (axios.isAxiosError(err) && err.response?.data !== undefined) {
392
+ log.warn(formatDingTalkErrorPayloadLog("quotedFile.resolve", err.response.data));
393
+ }
394
+ log.warn(
395
+ `[DingTalk][QuotedFile] Failed to resolve quoted file: stage=${stage} conversationId=${openConversationId} senderStaffId=${senderStaffId} fileCreatedAt=${fileCreatedAt} error=${describeResolveError(err)}`,
396
+ );
382
397
  }
398
+ return null;
399
+ }
383
400
  }
384
401
 
385
402
  // ============ Test helpers ============
386
403
 
387
404
  export function clearQuotedFileServiceCachesForTest(): void {
388
- unionIdCache.clear();
389
- spaceIdCache.clear();
405
+ unionIdCache.clear();
406
+ spaceIdCache.clear();
390
407
  }