@soimy/dingtalk 3.2.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.
- package/LICENSE +21 -0
- package/README.md +796 -42
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +83 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +75 -0
- package/src/ack-reaction-service.ts +182 -0
- package/src/attachment-text-extractor.ts +148 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +666 -26
- package/src/channel.ts +455 -150
- package/src/config-schema.ts +64 -6
- package/src/config.ts +161 -5
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1374 -259
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +487 -46
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +96 -1
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +267 -45
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +261 -28
- package/src/utils.ts +231 -12
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { Logger } from "./types";
|
|
4
|
+
|
|
5
|
+
type NamespaceFormat = "json";
|
|
6
|
+
|
|
7
|
+
export interface PersistenceScope {
|
|
8
|
+
accountId?: string;
|
|
9
|
+
agentId?: string;
|
|
10
|
+
conversationId?: string;
|
|
11
|
+
groupId?: string;
|
|
12
|
+
targetId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveNamespacePathOptions {
|
|
16
|
+
storePath: string;
|
|
17
|
+
scope?: PersistenceScope;
|
|
18
|
+
format?: NamespaceFormat;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReadNamespaceJsonOptions<T> extends ResolveNamespacePathOptions {
|
|
22
|
+
fallback: T;
|
|
23
|
+
log?: Logger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WriteNamespaceJsonOptions<T> extends ResolveNamespacePathOptions {
|
|
27
|
+
data: T;
|
|
28
|
+
log?: Logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAMESPACE_ROOT_DIR = "dingtalk-state";
|
|
32
|
+
|
|
33
|
+
function toErrorMessage(err: unknown): string {
|
|
34
|
+
if (err instanceof Error) {
|
|
35
|
+
return err.message;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.stringify(err);
|
|
39
|
+
} catch {
|
|
40
|
+
return String(err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sanitizeSegment(value: string): string {
|
|
45
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encodeScopeValue(value: string): string {
|
|
49
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildScopeSuffix(scope?: PersistenceScope): string {
|
|
53
|
+
if (!scope) {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
const ordered: Array<[keyof PersistenceScope, string | undefined]> = [
|
|
57
|
+
["accountId", scope.accountId],
|
|
58
|
+
["agentId", scope.agentId],
|
|
59
|
+
["conversationId", scope.conversationId],
|
|
60
|
+
["groupId", scope.groupId],
|
|
61
|
+
["targetId", scope.targetId],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const segments = ordered
|
|
65
|
+
.filter(([, value]) => Boolean(value && value.trim()))
|
|
66
|
+
.map(([key, value]) => `${key.replace(/Id$/, "")}-${encodeScopeValue((value || "").trim())}`);
|
|
67
|
+
|
|
68
|
+
if (segments.length === 0) {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
return `.${segments.join(".")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveNamespacePath(namespace: string, options: ResolveNamespacePathOptions): string {
|
|
75
|
+
const format = options.format || "json";
|
|
76
|
+
const baseDir = path.join(path.dirname(options.storePath), NAMESPACE_ROOT_DIR);
|
|
77
|
+
const safeNamespace = sanitizeSegment(namespace.trim());
|
|
78
|
+
const suffix = buildScopeSuffix(options.scope);
|
|
79
|
+
return path.join(baseDir, `${safeNamespace}${suffix}.${format}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readNamespaceJson<T>(
|
|
83
|
+
namespace: string,
|
|
84
|
+
options: ReadNamespaceJsonOptions<T>,
|
|
85
|
+
): T {
|
|
86
|
+
const filePath = resolveNamespacePath(namespace, options);
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.existsSync(filePath)) {
|
|
89
|
+
return options.fallback;
|
|
90
|
+
}
|
|
91
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
92
|
+
if (!raw.trim()) {
|
|
93
|
+
return options.fallback;
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(raw) as T;
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
options.log?.warn?.(
|
|
98
|
+
`[DingTalk][Persistence] Failed to read namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`,
|
|
99
|
+
);
|
|
100
|
+
return options.fallback;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function writeNamespaceJsonAtomic<T>(
|
|
105
|
+
namespace: string,
|
|
106
|
+
options: WriteNamespaceJsonOptions<T>,
|
|
107
|
+
): void {
|
|
108
|
+
const filePath = resolveNamespacePath(namespace, options);
|
|
109
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(tempPath, JSON.stringify(options.data, null, 2));
|
|
113
|
+
try {
|
|
114
|
+
fs.renameSync(tempPath, filePath);
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
if (fs.existsSync(filePath)) {
|
|
117
|
+
fs.rmSync(filePath, { force: true });
|
|
118
|
+
fs.renameSync(tempPath, filePath);
|
|
119
|
+
} else {
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
options.log?.warn?.(
|
|
125
|
+
`[DingTalk][Persistence] Failed to write namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`,
|
|
126
|
+
);
|
|
127
|
+
if (fs.existsSync(tempPath)) {
|
|
128
|
+
fs.rmSync(tempPath, { force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { getAccessToken } from "./auth";
|
|
5
|
+
import { getDingTalkRuntime } from "./runtime";
|
|
6
|
+
import type { DingTalkConfig, Logger, MediaFile } from "./types";
|
|
7
|
+
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "./utils";
|
|
8
|
+
|
|
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>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ipv4OnlyHttpAgent = new http.Agent({ family: 4 });
|
|
17
|
+
const ipv4OnlyHttpsAgent = new https.Agent({ family: 4 });
|
|
18
|
+
|
|
19
|
+
const DINGTALK_API = "https://api.dingtalk.com";
|
|
20
|
+
const DINGTALK_OAPI = "https://oapi.dingtalk.com";
|
|
21
|
+
|
|
22
|
+
const MATCH_WINDOW_MS = 10_000;
|
|
23
|
+
const MAX_PAGES = 3;
|
|
24
|
+
const PAGE_SIZE = 50;
|
|
25
|
+
|
|
26
|
+
const UNION_ID_CACHE_MAX = 5000;
|
|
27
|
+
const SPACE_ID_CACHE_MAX = 500;
|
|
28
|
+
|
|
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);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============ LRU caches ============
|
|
53
|
+
|
|
54
|
+
const unionIdCache = new Map<string, string>();
|
|
55
|
+
const spaceIdCache = new Map<string, string>();
|
|
56
|
+
|
|
57
|
+
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
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
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;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============ Time parsing ============
|
|
81
|
+
|
|
82
|
+
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;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============ API helpers ============
|
|
92
|
+
|
|
93
|
+
export async function getUnionIdByStaffId(
|
|
94
|
+
config: DingTalkConfig,
|
|
95
|
+
staffId: string,
|
|
96
|
+
log?: Logger,
|
|
97
|
+
): 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;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getGroupFileSpaceId(
|
|
130
|
+
config: DingTalkConfig,
|
|
131
|
+
openConversationId: string,
|
|
132
|
+
unionId: string,
|
|
133
|
+
log?: Logger,
|
|
134
|
+
): 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 } },
|
|
146
|
+
);
|
|
147
|
+
|
|
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;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface DentryMatch {
|
|
159
|
+
dentryId: string;
|
|
160
|
+
name: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ResolvedQuotedFile {
|
|
164
|
+
media: MediaFile;
|
|
165
|
+
spaceId: string;
|
|
166
|
+
fileId: string;
|
|
167
|
+
name?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function findFileByTimestamp(
|
|
171
|
+
config: DingTalkConfig,
|
|
172
|
+
spaceId: string,
|
|
173
|
+
unionId: string,
|
|
174
|
+
createdAt: number,
|
|
175
|
+
log?: Logger,
|
|
176
|
+
): 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
|
+
}
|
|
190
|
+
|
|
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
|
+
);
|
|
196
|
+
|
|
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
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
nextToken = typeof data.nextToken === "string" ? data.nextToken : undefined;
|
|
218
|
+
if (!nextToken) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return bestMatch;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function downloadGroupFile(
|
|
227
|
+
config: DingTalkConfig,
|
|
228
|
+
spaceId: string,
|
|
229
|
+
dentryId: string,
|
|
230
|
+
unionId: string,
|
|
231
|
+
log?: Logger,
|
|
232
|
+
): Promise<MediaFile | null> {
|
|
233
|
+
const rt = getDingTalkRuntime();
|
|
234
|
+
const token = await getAccessToken(config, log);
|
|
235
|
+
let resourceUrl = "";
|
|
236
|
+
let contentType = "application/octet-stream";
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const infoResp = await axios.post(
|
|
240
|
+
`${DINGTALK_API}/v1.0/storage/spaces/${spaceId}/dentries/${dentryId}/downloadInfos/query?unionId=${unionId}`,
|
|
241
|
+
{},
|
|
242
|
+
{ headers: { "x-acs-dingtalk-access-token": token } },
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const info = asRecord(infoResp.data) ?? {};
|
|
246
|
+
const headerSig = asRecord(info.headerSignatureInfo);
|
|
247
|
+
const resourceUrls = headerSig && Array.isArray(headerSig.resourceUrls) ? headerSig.resourceUrls : undefined;
|
|
248
|
+
resourceUrl = typeof resourceUrls?.[0] === "string" ? resourceUrls[0] : "";
|
|
249
|
+
if (!resourceUrl) {
|
|
250
|
+
log?.warn?.("[DingTalk][QuotedFile] downloadInfos/query returned no resourceUrl");
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sigHeaders: Record<string, string> = {};
|
|
255
|
+
const headerMap = asRecord(headerSig?.headers);
|
|
256
|
+
if (headerMap) {
|
|
257
|
+
for (const [k, v] of Object.entries(headerMap)) {
|
|
258
|
+
if (typeof v === "string") {
|
|
259
|
+
sigHeaders[k] = v;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let fileResp;
|
|
264
|
+
try {
|
|
265
|
+
fileResp = await axios.get(resourceUrl, {
|
|
266
|
+
headers: sigHeaders,
|
|
267
|
+
responseType: "arraybuffer",
|
|
268
|
+
timeout: 15000,
|
|
269
|
+
});
|
|
270
|
+
} catch (firstErr: unknown) {
|
|
271
|
+
log?.warn?.(
|
|
272
|
+
`[DingTalk][QuotedFile] CDN download failed on default network path, retrying with IPv4-only: ${describeResolveError(firstErr)}`,
|
|
273
|
+
);
|
|
274
|
+
try {
|
|
275
|
+
fileResp = await axios.get(resourceUrl, {
|
|
276
|
+
headers: sigHeaders,
|
|
277
|
+
responseType: "arraybuffer",
|
|
278
|
+
httpAgent: ipv4OnlyHttpAgent,
|
|
279
|
+
httpsAgent: ipv4OnlyHttpsAgent,
|
|
280
|
+
timeout: 15000,
|
|
281
|
+
});
|
|
282
|
+
} catch (retryErr: unknown) {
|
|
283
|
+
const host = resourceUrl ? new URL(resourceUrl).host : "(unknown-host)";
|
|
284
|
+
throw new Error(
|
|
285
|
+
`download-resource failed host=${host} detail=${describeResolveError(retryErr)}`,
|
|
286
|
+
{ cause: retryErr },
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
contentType = (fileResp.headers["content-type"] as string) || "application/octet-stream";
|
|
292
|
+
const buffer = Buffer.from(fileResp.data as ArrayBuffer);
|
|
293
|
+
|
|
294
|
+
const maxBytes =
|
|
295
|
+
config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
|
|
296
|
+
try {
|
|
297
|
+
const saved = maxBytes
|
|
298
|
+
? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
|
|
299
|
+
: await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
|
|
300
|
+
|
|
301
|
+
return { path: saved.path, mimeType: saved.contentType ?? contentType };
|
|
302
|
+
} catch (err: unknown) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`save-buffer failed contentType=${contentType} detail=${describeResolveError(err)}`,
|
|
305
|
+
{ cause: err },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
} catch (err: unknown) {
|
|
309
|
+
if (axios.isAxiosError(err) && err.response?.data !== undefined) {
|
|
310
|
+
log?.warn?.(formatDingTalkErrorPayloadLog("quotedFile.downloadGroupFile", err.response.data));
|
|
311
|
+
}
|
|
312
|
+
log?.warn?.(
|
|
313
|
+
`[DingTalk][QuotedFile] downloadGroupFile failed: spaceId=${spaceId} dentryId=${dentryId} resourceUrl=${resourceUrl || "(none)"} contentType=${contentType} error=${describeResolveError(err)}`,
|
|
314
|
+
);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ============ Composite entry point ============
|
|
320
|
+
|
|
321
|
+
export interface ResolveQuotedFileParams {
|
|
322
|
+
openConversationId: string;
|
|
323
|
+
senderStaffId?: string;
|
|
324
|
+
fileCreatedAt?: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function resolveQuotedFile(
|
|
328
|
+
config: DingTalkConfig,
|
|
329
|
+
params: ResolveQuotedFileParams,
|
|
330
|
+
log?: Logger,
|
|
331
|
+
): Promise<ResolvedQuotedFile | null> {
|
|
332
|
+
const { openConversationId, senderStaffId, fileCreatedAt } = params;
|
|
333
|
+
let stage = "init";
|
|
334
|
+
|
|
335
|
+
if (!senderStaffId || !fileCreatedAt) {
|
|
336
|
+
log?.warn?.("[DingTalk][QuotedFile] Missing senderStaffId or fileCreatedAt, skipping");
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
stage = "resolve-unionId";
|
|
342
|
+
const unionId = await getUnionIdByStaffId(config, senderStaffId, log);
|
|
343
|
+
stage = "resolve-spaceId";
|
|
344
|
+
const spaceId = await getGroupFileSpaceId(config, openConversationId, unionId, log);
|
|
345
|
+
stage = "list-and-match";
|
|
346
|
+
const match = await findFileByTimestamp(config, spaceId, unionId, fileCreatedAt, log);
|
|
347
|
+
|
|
348
|
+
if (!match) {
|
|
349
|
+
log?.warn?.(
|
|
350
|
+
`[DingTalk][QuotedFile] No file matched within ±${MATCH_WINDOW_MS}ms window for createdAt=${fileCreatedAt}`,
|
|
351
|
+
);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
stage = "download-file";
|
|
356
|
+
const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log);
|
|
357
|
+
if (!media) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
media,
|
|
363
|
+
spaceId,
|
|
364
|
+
fileId: match.dentryId,
|
|
365
|
+
name: match.name,
|
|
366
|
+
};
|
|
367
|
+
} catch (err: unknown) {
|
|
368
|
+
if (log?.warn) {
|
|
369
|
+
if (axios.isAxiosError(err) && err.response?.data !== undefined) {
|
|
370
|
+
log.warn(formatDingTalkErrorPayloadLog("quotedFile.resolve", err.response.data));
|
|
371
|
+
}
|
|
372
|
+
log.warn(
|
|
373
|
+
`[DingTalk][QuotedFile] Failed to resolve quoted file: stage=${stage} conversationId=${openConversationId} senderStaffId=${senderStaffId} fileCreatedAt=${fileCreatedAt} error=${describeResolveError(err)}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============ Test helpers ============
|
|
381
|
+
|
|
382
|
+
export function clearQuotedFileServiceCachesForTest(): void {
|
|
383
|
+
unionIdCache.clear();
|
|
384
|
+
spaceIdCache.clear();
|
|
385
|
+
}
|