@soimy/dingtalk 3.1.4 → 3.3.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 +808 -40
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +794 -62
- package/src/channel.ts +675 -179
- package/src/config-schema.ts +49 -5
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +356 -36
- 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 +1191 -206
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +566 -8
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +85 -3
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +239 -59
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +34 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +165 -22
- package/src/utils.ts +231 -12
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as dns from "node:dns";
|
|
1
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as net from "node:net";
|
|
2
4
|
import * as os from "node:os";
|
|
3
5
|
import * as path from "node:path";
|
|
4
6
|
import type { Logger, RetryOptions } from "./types";
|
|
@@ -16,10 +18,10 @@ export function maskSensitiveData(data: unknown): any {
|
|
|
16
18
|
return data as string | number;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
const masked = JSON.parse(JSON.stringify(data)) as Record<string,
|
|
21
|
+
const masked = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
|
|
20
22
|
const sensitiveFields = new Set(["token", "accessToken"]);
|
|
21
23
|
|
|
22
|
-
function maskObj(obj:
|
|
24
|
+
function maskObj(obj: Record<string, unknown>): void {
|
|
23
25
|
for (const key in obj) {
|
|
24
26
|
if (sensitiveFields.has(key)) {
|
|
25
27
|
const val = obj[key];
|
|
@@ -28,8 +30,8 @@ export function maskSensitiveData(data: unknown): any {
|
|
|
28
30
|
} else if (typeof val === "string") {
|
|
29
31
|
obj[key] = "*".repeat(val.length);
|
|
30
32
|
}
|
|
31
|
-
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
32
|
-
maskObj(obj[key]);
|
|
33
|
+
} else if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
|
34
|
+
maskObj(obj[key] as Record<string, unknown>);
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -38,6 +40,45 @@ export function maskSensitiveData(data: unknown): any {
|
|
|
38
40
|
return masked;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
export function stringifyUnknown(value: unknown): string {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
typeof value === "number" ||
|
|
49
|
+
typeof value === "boolean" ||
|
|
50
|
+
typeof value === "bigint"
|
|
51
|
+
) {
|
|
52
|
+
return String(value);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const serialized = JSON.stringify(value);
|
|
56
|
+
return serialized ?? String(value);
|
|
57
|
+
} catch {
|
|
58
|
+
return "[unserializable]";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getErrorMessage(err: unknown): string {
|
|
63
|
+
if (err instanceof Error && err.message) {
|
|
64
|
+
return err.message;
|
|
65
|
+
}
|
|
66
|
+
if (err && typeof err === "object") {
|
|
67
|
+
const record = err as Record<string, unknown>;
|
|
68
|
+
if (typeof record.message === "string" && record.message.trim()) {
|
|
69
|
+
return record.message;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return stringifyUnknown(err);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getErrorResponseData(err: unknown): unknown {
|
|
76
|
+
if (!err || typeof err !== "object") {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return (err as { response?: { data?: unknown } }).response?.data;
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
export function formatDingTalkErrorPayload(payload: unknown): string {
|
|
42
83
|
if (payload === null || payload === undefined) {
|
|
43
84
|
return "payload=unknown";
|
|
@@ -87,6 +128,183 @@ export function formatDingTalkErrorPayloadLog(
|
|
|
87
128
|
return `${prefix}[ErrorPayload][${scope}] ${formatDingTalkErrorPayload(payload)}`;
|
|
88
129
|
}
|
|
89
130
|
|
|
131
|
+
export function getProxyBypassOption(config?: { bypassProxyForSend?: boolean }): { proxy: false } | Record<string, never> {
|
|
132
|
+
return config?.bypassProxyForSend ? { proxy: false } : {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type LookupCallback = (
|
|
136
|
+
err: NodeJS.ErrnoException | null,
|
|
137
|
+
address: string | dns.LookupAddress[],
|
|
138
|
+
family?: number,
|
|
139
|
+
) => void;
|
|
140
|
+
|
|
141
|
+
type LookupOptions = dns.LookupOneOptions | dns.LookupAllOptions;
|
|
142
|
+
|
|
143
|
+
export function createResolve4FallbackLookup(log?: Logger, accountId?: string) {
|
|
144
|
+
return createResolve4FallbackLookupWithDeps(log, accountId, dns, net);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createResolve4FallbackLookupWithDeps(
|
|
148
|
+
log: Logger | undefined,
|
|
149
|
+
accountId: string | undefined,
|
|
150
|
+
dnsImpl: Pick<typeof dns, "lookup" | "resolve4">,
|
|
151
|
+
netImpl: Pick<typeof net, "isIP">,
|
|
152
|
+
) {
|
|
153
|
+
let fallbackLogged = false;
|
|
154
|
+
|
|
155
|
+
return (hostname: string, options: LookupOptions, callback: LookupCallback): void => {
|
|
156
|
+
const ipFamily = netImpl.isIP(hostname);
|
|
157
|
+
if (ipFamily !== 0) {
|
|
158
|
+
if (options.all) {
|
|
159
|
+
callback(null, [{ address: hostname, family: ipFamily }], ipFamily);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
callback(null, hostname, ipFamily);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
dnsImpl.lookup(hostname, options, (lookupErr, address, family) => {
|
|
168
|
+
if (!lookupErr) {
|
|
169
|
+
callback(null, address, family);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (lookupErr.code !== "ENOTFOUND") {
|
|
174
|
+
callback(lookupErr, address, family);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
dnsImpl.resolve4(hostname, (resolveErr, addresses) => {
|
|
179
|
+
if (resolveErr || !addresses || addresses.length === 0) {
|
|
180
|
+
callback(lookupErr, address, family);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!fallbackLogged) {
|
|
185
|
+
fallbackLogged = true;
|
|
186
|
+
log?.warn?.(
|
|
187
|
+
`[${accountId ?? "default"}] System DNS lookup failed for ${hostname} (ENOTFOUND); using resolve4 fallback ${addresses[0]}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (options.all) {
|
|
192
|
+
callback(
|
|
193
|
+
null,
|
|
194
|
+
addresses.map((item) => ({ address: item, family: 4 })),
|
|
195
|
+
4,
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
callback(null, addresses[0], 4);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getHeaderCaseInsensitive(headers: unknown, key: string): string | undefined {
|
|
207
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entries = Object.entries(headers as Record<string, unknown>);
|
|
212
|
+
const matched = entries.find(([name]) => name.toLowerCase() === key.toLowerCase());
|
|
213
|
+
if (!matched) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const value = matched[1];
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
return value.length > 0 ? String(value[0]) : undefined;
|
|
220
|
+
}
|
|
221
|
+
if (value === null || value === undefined) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
typeof value === "string" ||
|
|
226
|
+
typeof value === "number" ||
|
|
227
|
+
typeof value === "boolean" ||
|
|
228
|
+
typeof value === "bigint"
|
|
229
|
+
) {
|
|
230
|
+
return String(value);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return JSON.stringify(value);
|
|
234
|
+
} catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatDingTalkConnectionErrorLog(
|
|
240
|
+
scope: string,
|
|
241
|
+
err: unknown,
|
|
242
|
+
baseMessage: string,
|
|
243
|
+
): string | null {
|
|
244
|
+
if (!err || typeof err !== "object") {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const errRecord = err as Record<string, unknown>;
|
|
249
|
+
const stage =
|
|
250
|
+
typeof errRecord.dingtalkConnectionStage === "string"
|
|
251
|
+
? errRecord.dingtalkConnectionStage
|
|
252
|
+
: scope;
|
|
253
|
+
const endpoint =
|
|
254
|
+
typeof errRecord.dingtalkConnectionEndpoint === "string"
|
|
255
|
+
? errRecord.dingtalkConnectionEndpoint
|
|
256
|
+
: undefined;
|
|
257
|
+
|
|
258
|
+
const hasResponse = "response" in errRecord && errRecord.response !== null && errRecord.response !== undefined;
|
|
259
|
+
if (!hasResponse && !endpoint && stage === scope) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const parts: string[] = [`${baseMessage} [DingTalk][ConnectionError][${stage}]`];
|
|
264
|
+
if (endpoint) {
|
|
265
|
+
parts.push(`endpoint=${endpoint}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const response = (err as { response?: { status?: unknown; data?: unknown; headers?: unknown } }).response;
|
|
269
|
+
if (response) {
|
|
270
|
+
if (response.status !== undefined && response.status !== null) {
|
|
271
|
+
const statusText =
|
|
272
|
+
typeof response.status === "string" ||
|
|
273
|
+
typeof response.status === "number" ||
|
|
274
|
+
typeof response.status === "boolean" ||
|
|
275
|
+
typeof response.status === "bigint"
|
|
276
|
+
? String(response.status)
|
|
277
|
+
: JSON.stringify(response.status);
|
|
278
|
+
parts.push(`status=${statusText}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let requestId = getHeaderCaseInsensitive(response.headers, "x-acs-dingtalk-request-id");
|
|
282
|
+
if (!requestId && response.data && typeof response.data === "object" && !Array.isArray(response.data)) {
|
|
283
|
+
const data = response.data as Record<string, unknown>;
|
|
284
|
+
if (typeof data.requestId === "string") {
|
|
285
|
+
requestId = data.requestId;
|
|
286
|
+
} else if (typeof data.requestid === "string") {
|
|
287
|
+
requestId = data.requestid;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (requestId) {
|
|
291
|
+
parts.push(`requestId=${requestId}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (response.data !== undefined) {
|
|
295
|
+
parts.push(formatDingTalkErrorPayload(response.data));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (stage === "connect.websocket") {
|
|
300
|
+
parts.push("Likely websocket/proxy/WSS issue after connections/open succeeded");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
parts.push("See docs/connection-troubleshooting.md or run scripts/dingtalk-connection-check.*");
|
|
304
|
+
|
|
305
|
+
return parts.join(" ");
|
|
306
|
+
}
|
|
307
|
+
|
|
90
308
|
/**
|
|
91
309
|
* Cleanup orphaned temp files from dingtalk media
|
|
92
310
|
* Run at startup to clean up files from crashed processes
|
|
@@ -114,16 +332,16 @@ export function cleanupOrphanedTempFiles(log?: Logger): number {
|
|
|
114
332
|
cleaned++;
|
|
115
333
|
log?.debug?.(`[DingTalk] Cleaned up orphaned temp file: ${file}`);
|
|
116
334
|
}
|
|
117
|
-
} catch (err:
|
|
118
|
-
log?.debug?.(`[DingTalk] Failed to cleanup temp file ${file}: ${err
|
|
335
|
+
} catch (err: unknown) {
|
|
336
|
+
log?.debug?.(`[DingTalk] Failed to cleanup temp file ${file}: ${getErrorMessage(err)}`);
|
|
119
337
|
}
|
|
120
338
|
}
|
|
121
339
|
|
|
122
340
|
if (cleaned > 0) {
|
|
123
341
|
log?.info?.(`[DingTalk] Cleaned up ${cleaned} orphaned temp files`);
|
|
124
342
|
}
|
|
125
|
-
} catch (err:
|
|
126
|
-
log?.debug?.(`[DingTalk] Failed to cleanup temp directory: ${err
|
|
343
|
+
} catch (err: unknown) {
|
|
344
|
+
log?.debug?.(`[DingTalk] Failed to cleanup temp directory: ${getErrorMessage(err)}`);
|
|
127
345
|
}
|
|
128
346
|
|
|
129
347
|
return cleaned;
|
|
@@ -142,13 +360,14 @@ export async function retryWithBackoff<T>(
|
|
|
142
360
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
143
361
|
try {
|
|
144
362
|
return await fn();
|
|
145
|
-
} catch (err:
|
|
146
|
-
const statusCode = err.response?.status;
|
|
363
|
+
} catch (err: unknown) {
|
|
364
|
+
const statusCode = (err as { response?: { status?: number } }).response?.status;
|
|
147
365
|
const isRetryable =
|
|
148
366
|
statusCode === 401 || statusCode === 429 || (statusCode && statusCode >= 500);
|
|
149
367
|
|
|
150
|
-
|
|
151
|
-
|
|
368
|
+
const responseData = getErrorResponseData(err);
|
|
369
|
+
if (responseData !== undefined) {
|
|
370
|
+
log?.debug?.(formatDingTalkErrorPayloadLog("retry.beforeDecision", responseData));
|
|
152
371
|
}
|
|
153
372
|
|
|
154
373
|
if (!isRetryable || attempt === maxRetries) {
|