codeksei 0.1.0 → 0.1.1
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 +661 -661
- package/README.en.md +109 -47
- package/README.md +79 -58
- package/bin/cyberboss.js +1 -1
- package/package.json +86 -86
- package/scripts/open_shared_wechat_thread.sh +77 -77
- package/scripts/open_wechat_thread.sh +108 -108
- package/scripts/shared-common.js +144 -144
- package/scripts/shared-open.js +14 -14
- package/scripts/shared-start.js +5 -5
- package/scripts/shared-status.js +27 -27
- package/scripts/show_shared_status.sh +45 -45
- package/scripts/start_shared_app_server.sh +52 -52
- package/scripts/start_shared_wechat.sh +94 -94
- package/scripts/timeline-screenshot.sh +14 -14
- package/src/adapters/channel/weixin/account-store.js +99 -99
- package/src/adapters/channel/weixin/api-v2.js +50 -50
- package/src/adapters/channel/weixin/api.js +169 -169
- package/src/adapters/channel/weixin/context-token-store.js +84 -84
- package/src/adapters/channel/weixin/index.js +618 -604
- package/src/adapters/channel/weixin/legacy.js +579 -566
- package/src/adapters/channel/weixin/media-mime.js +22 -22
- package/src/adapters/channel/weixin/media-receive.js +370 -370
- package/src/adapters/channel/weixin/media-send.js +102 -102
- package/src/adapters/channel/weixin/message-utils-v2.js +282 -282
- package/src/adapters/channel/weixin/message-utils.js +199 -199
- package/src/adapters/channel/weixin/redact.js +41 -41
- package/src/adapters/channel/weixin/reminder-queue-store.js +101 -101
- package/src/adapters/channel/weixin/sync-buffer-store.js +35 -35
- package/src/adapters/runtime/codex/events.js +215 -215
- package/src/adapters/runtime/codex/index.js +109 -104
- package/src/adapters/runtime/codex/message-utils.js +95 -95
- package/src/adapters/runtime/codex/model-catalog.js +106 -106
- package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -75
- package/src/adapters/runtime/codex/rpc-client.js +339 -339
- package/src/adapters/runtime/codex/session-store.js +286 -286
- package/src/app/channel-send-file-cli.js +57 -57
- package/src/app/diary-write-cli.js +236 -88
- package/src/app/note-sync-cli.js +2 -2
- package/src/app/reminder-write-cli.js +215 -210
- package/src/app/review-cli.js +7 -5
- package/src/app/system-checkin-poller.js +64 -64
- package/src/app/system-send-cli.js +129 -129
- package/src/app/timeline-event-cli.js +28 -25
- package/src/app/timeline-screenshot-cli.js +103 -100
- package/src/core/app.js +1763 -1763
- package/src/core/branding.js +2 -1
- package/src/core/command-registry.js +381 -369
- package/src/core/config.js +30 -14
- package/src/core/default-targets.js +163 -163
- package/src/core/durable-note-schema.js +9 -8
- package/src/core/instructions-template.js +17 -16
- package/src/core/note-sync.js +8 -7
- package/src/core/path-utils.js +54 -0
- package/src/core/project-radar.js +11 -10
- package/src/core/review.js +48 -50
- package/src/core/stream-delivery.js +1162 -983
- package/src/core/system-message-dispatcher.js +68 -68
- package/src/core/system-message-queue-store.js +128 -128
- package/src/core/thread-state-store.js +96 -96
- package/src/core/timeline-screenshot-queue-store.js +134 -134
- package/src/core/timezone.js +436 -0
- package/src/core/workspace-bootstrap.js +9 -1
- package/src/index.js +148 -146
- package/src/integrations/timeline/index.js +130 -74
- package/src/integrations/timeline/state-sync.js +240 -0
- package/templates/weixin-instructions.md +12 -38
- package/templates/weixin-operations.md +29 -31
|
@@ -1,370 +1,370 @@
|
|
|
1
|
-
const crypto = require("crypto");
|
|
2
|
-
const fs = require("fs/promises");
|
|
3
|
-
const path = require("path");
|
|
4
|
-
|
|
5
|
-
const DEFAULT_INBOX_DIR = "inbox";
|
|
6
|
-
const MAX_FILE_NAME_LENGTH = 120;
|
|
7
|
-
|
|
8
|
-
async function persistIncomingWeixinAttachments({
|
|
9
|
-
attachments,
|
|
10
|
-
stateDir,
|
|
11
|
-
cdnBaseUrl,
|
|
12
|
-
messageId = "",
|
|
13
|
-
receivedAt = "",
|
|
14
|
-
}) {
|
|
15
|
-
const saved = [];
|
|
16
|
-
const failed = [];
|
|
17
|
-
|
|
18
|
-
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
|
19
|
-
try {
|
|
20
|
-
const persisted = await persistSingleAttachment({
|
|
21
|
-
attachment,
|
|
22
|
-
stateDir,
|
|
23
|
-
cdnBaseUrl,
|
|
24
|
-
messageId,
|
|
25
|
-
receivedAt,
|
|
26
|
-
});
|
|
27
|
-
saved.push(persisted);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
failed.push({
|
|
30
|
-
kind: attachment?.kind || "file",
|
|
31
|
-
sourceFileName: attachment?.fileName || "",
|
|
32
|
-
reason: error instanceof Error ? error.message : String(error || "unknown attachment error"),
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return { saved, failed };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function persistSingleAttachment({ attachment, stateDir, cdnBaseUrl, messageId, receivedAt }) {
|
|
41
|
-
const download = await downloadAttachmentPayload(attachment, cdnBaseUrl);
|
|
42
|
-
const plaintext = decodeAttachmentPayload(download.bytes, attachment, download.contentType);
|
|
43
|
-
const fileName = buildTargetFileName({
|
|
44
|
-
attachment,
|
|
45
|
-
plaintext,
|
|
46
|
-
contentType: download.contentType,
|
|
47
|
-
messageId,
|
|
48
|
-
});
|
|
49
|
-
const targetDir = buildInboxDirectory(stateDir, receivedAt);
|
|
50
|
-
const absolutePath = await writeUniqueFile(targetDir, fileName, plaintext);
|
|
51
|
-
const relativePath = path.relative(stateDir, absolutePath).replace(/\\/g, "/");
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
kind: attachment.kind || "file",
|
|
55
|
-
sourceFileName: attachment.fileName || "",
|
|
56
|
-
fileName: path.basename(absolutePath),
|
|
57
|
-
absolutePath,
|
|
58
|
-
relativePath,
|
|
59
|
-
sizeBytes: plaintext.length,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function buildInboxDirectory(stateDir, receivedAt) {
|
|
64
|
-
const day = normalizeDateFolder(receivedAt);
|
|
65
|
-
return path.join(stateDir, DEFAULT_INBOX_DIR, day);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function normalizeDateFolder(receivedAt) {
|
|
69
|
-
const date = receivedAt ? new Date(receivedAt) : new Date();
|
|
70
|
-
if (Number.isNaN(date.getTime())) {
|
|
71
|
-
return new Date().toISOString().slice(0, 10);
|
|
72
|
-
}
|
|
73
|
-
return date.toISOString().slice(0, 10);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function downloadAttachmentPayload(attachment, cdnBaseUrl) {
|
|
77
|
-
const candidates = buildDownloadCandidates(attachment, cdnBaseUrl);
|
|
78
|
-
if (!candidates.length) {
|
|
79
|
-
throw new Error("attachment did not include a supported download reference");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
let lastError = null;
|
|
83
|
-
for (const candidate of candidates) {
|
|
84
|
-
try {
|
|
85
|
-
const response = await fetch(candidate, {
|
|
86
|
-
method: "GET",
|
|
87
|
-
headers: {
|
|
88
|
-
Accept: "*/*",
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
if (!response.ok) {
|
|
92
|
-
lastError = new Error(`download failed with HTTP ${response.status}`);
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
97
|
-
return {
|
|
98
|
-
bytes: Buffer.from(arrayBuffer),
|
|
99
|
-
contentType: normalizeContentType(response.headers.get("content-type")),
|
|
100
|
-
};
|
|
101
|
-
} catch (error) {
|
|
102
|
-
lastError = error;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw lastError || new Error("attachment download failed");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function buildDownloadCandidates(attachment, cdnBaseUrl) {
|
|
110
|
-
const candidates = [];
|
|
111
|
-
const seen = new Set();
|
|
112
|
-
const directUrls = Array.isArray(attachment?.directUrls) ? attachment.directUrls : [];
|
|
113
|
-
for (const directUrl of directUrls) {
|
|
114
|
-
addCandidate(candidates, seen, directUrl);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const encryptedQueryParam = normalizeText(attachment?.mediaRef?.encryptQueryParam);
|
|
118
|
-
if (encryptedQueryParam) {
|
|
119
|
-
const normalizedCdnBaseUrl = String(cdnBaseUrl || "").replace(/\/+$/g, "");
|
|
120
|
-
addCandidate(
|
|
121
|
-
candidates,
|
|
122
|
-
seen,
|
|
123
|
-
`${normalizedCdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const fileKey = normalizeText(attachment?.mediaRef?.fileKey);
|
|
127
|
-
if (fileKey) {
|
|
128
|
-
addCandidate(
|
|
129
|
-
candidates,
|
|
130
|
-
seen,
|
|
131
|
-
`${normalizedCdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}&filekey=${encodeURIComponent(fileKey)}`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return candidates;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function addCandidate(candidates, seen, rawUrl) {
|
|
140
|
-
const normalizedUrl = normalizeText(rawUrl);
|
|
141
|
-
if (!normalizedUrl || seen.has(normalizedUrl)) {
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
seen.add(normalizedUrl);
|
|
145
|
-
candidates.push(normalizedUrl);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function decodeAttachmentPayload(bytes, attachment, contentType) {
|
|
149
|
-
const encryptType = Number(attachment?.mediaRef?.encryptType);
|
|
150
|
-
const keyCandidates = buildAesKeyCandidates(attachment);
|
|
151
|
-
if (encryptType !== 1 || keyCandidates.length === 0) {
|
|
152
|
-
return bytes;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
for (const key of keyCandidates) {
|
|
156
|
-
try {
|
|
157
|
-
return decryptAesEcb(bytes, key);
|
|
158
|
-
} catch {
|
|
159
|
-
// Try the next key encoding variant.
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (looksLikePlainMedia(bytes, contentType)) {
|
|
164
|
-
return bytes;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
throw new Error("failed to decrypt attachment payload");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function buildAesKeyCandidates(attachment) {
|
|
171
|
-
const candidates = [];
|
|
172
|
-
const seen = new Set();
|
|
173
|
-
const rawValues = [
|
|
174
|
-
attachment?.mediaRef?.aesKeyHex,
|
|
175
|
-
attachment?.mediaRef?.aesKey,
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
for (const rawValue of rawValues) {
|
|
179
|
-
const variants = decodeAesKeyVariants(rawValue);
|
|
180
|
-
for (const variant of variants) {
|
|
181
|
-
const signature = variant.toString("hex");
|
|
182
|
-
if (seen.has(signature)) {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
seen.add(signature);
|
|
186
|
-
candidates.push(variant);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return candidates;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function decodeAesKeyVariants(value) {
|
|
194
|
-
const normalized = normalizeText(value);
|
|
195
|
-
if (!normalized) {
|
|
196
|
-
return [];
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const candidates = [];
|
|
200
|
-
if (/^[0-9a-f]{32}$/i.test(normalized)) {
|
|
201
|
-
candidates.push(Buffer.from(normalized, "hex"));
|
|
202
|
-
}
|
|
203
|
-
if (normalized.length === 16) {
|
|
204
|
-
candidates.push(Buffer.from(normalized, "utf8"));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const decoded = Buffer.from(normalized, "base64");
|
|
209
|
-
if (decoded.length === 16) {
|
|
210
|
-
candidates.push(decoded);
|
|
211
|
-
} else {
|
|
212
|
-
const decodedText = decoded.toString("utf8").trim();
|
|
213
|
-
if (/^[0-9a-f]{32}$/i.test(decodedText)) {
|
|
214
|
-
candidates.push(Buffer.from(decodedText, "hex"));
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
} catch {
|
|
218
|
-
// Ignore invalid base64 variants.
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return candidates.filter((candidate) => candidate.length === 16);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function decryptAesEcb(ciphertext, key) {
|
|
225
|
-
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
|
|
226
|
-
decipher.setAutoPadding(true);
|
|
227
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function looksLikePlainMedia(bytes, contentType) {
|
|
231
|
-
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (contentType.startsWith("text/")) {
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return detectExtensionFromBuffer(bytes) !== "";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function buildTargetFileName({ attachment, plaintext, contentType, messageId }) {
|
|
243
|
-
const sourceName = sanitizeFileName(attachment?.fileName || "");
|
|
244
|
-
if (sourceName) {
|
|
245
|
-
const existingExt = path.extname(sourceName);
|
|
246
|
-
if (existingExt) {
|
|
247
|
-
return sourceName;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const inferredExt = inferExtension({
|
|
251
|
-
contentType,
|
|
252
|
-
plaintext,
|
|
253
|
-
kind: attachment?.kind,
|
|
254
|
-
});
|
|
255
|
-
return `${sourceName}${inferredExt}`;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const baseName = sanitizeFileName([
|
|
259
|
-
attachment?.kind || "file",
|
|
260
|
-
messageId || Date.now(),
|
|
261
|
-
String((attachment?.index ?? 0) + 1),
|
|
262
|
-
].join("-"));
|
|
263
|
-
const inferredExt = inferExtension({
|
|
264
|
-
contentType,
|
|
265
|
-
plaintext,
|
|
266
|
-
kind: attachment?.kind,
|
|
267
|
-
});
|
|
268
|
-
return `${baseName || "attachment"}${inferredExt}`;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function inferExtension({ contentType, plaintext, kind }) {
|
|
272
|
-
const contentTypeExt = extensionFromContentType(contentType);
|
|
273
|
-
if (contentTypeExt) {
|
|
274
|
-
return contentTypeExt;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const bufferExt = detectExtensionFromBuffer(plaintext);
|
|
278
|
-
if (bufferExt) {
|
|
279
|
-
return bufferExt;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (kind === "image") {
|
|
283
|
-
return ".png";
|
|
284
|
-
}
|
|
285
|
-
if (kind === "video") {
|
|
286
|
-
return ".mp4";
|
|
287
|
-
}
|
|
288
|
-
return ".bin";
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function extensionFromContentType(contentType) {
|
|
292
|
-
const normalized = normalizeContentType(contentType);
|
|
293
|
-
const map = {
|
|
294
|
-
"image/png": ".png",
|
|
295
|
-
"image/jpeg": ".jpg",
|
|
296
|
-
"image/gif": ".gif",
|
|
297
|
-
"image/webp": ".webp",
|
|
298
|
-
"video/mp4": ".mp4",
|
|
299
|
-
"application/pdf": ".pdf",
|
|
300
|
-
"text/plain": ".txt",
|
|
301
|
-
};
|
|
302
|
-
return map[normalized] || "";
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function detectExtensionFromBuffer(buffer) {
|
|
306
|
-
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
|
307
|
-
return "";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) {
|
|
311
|
-
return ".png";
|
|
312
|
-
}
|
|
313
|
-
if (buffer.subarray(0, 3).equals(Buffer.from([0xFF, 0xD8, 0xFF]))) {
|
|
314
|
-
return ".jpg";
|
|
315
|
-
}
|
|
316
|
-
if (buffer.subarray(0, 4).toString("ascii") === "GIF8") {
|
|
317
|
-
return ".gif";
|
|
318
|
-
}
|
|
319
|
-
if (buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
320
|
-
&& buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
321
|
-
return ".webp";
|
|
322
|
-
}
|
|
323
|
-
if (buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
324
|
-
return ".mp4";
|
|
325
|
-
}
|
|
326
|
-
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
|
|
327
|
-
return ".pdf";
|
|
328
|
-
}
|
|
329
|
-
return "";
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function sanitizeFileName(value) {
|
|
333
|
-
const parsed = path.parse(String(value || "").trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-"));
|
|
334
|
-
const safeBaseName = parsed.name || "attachment";
|
|
335
|
-
const safeExt = parsed.ext || "";
|
|
336
|
-
return `${safeBaseName.slice(0, MAX_FILE_NAME_LENGTH)}${safeExt.slice(0, 16)}`;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function writeUniqueFile(targetDir, fileName, plaintext) {
|
|
340
|
-
await fs.mkdir(targetDir, { recursive: true });
|
|
341
|
-
const parsed = path.parse(fileName);
|
|
342
|
-
const baseName = parsed.name || "attachment";
|
|
343
|
-
const extension = parsed.ext || "";
|
|
344
|
-
for (let index = 0; index < 50; index += 1) {
|
|
345
|
-
const suffix = index === 0 ? "" : `-${index + 1}`;
|
|
346
|
-
const candidate = path.join(targetDir, `${baseName}${suffix}${extension}`);
|
|
347
|
-
try {
|
|
348
|
-
await fs.writeFile(candidate, plaintext, { flag: "wx" });
|
|
349
|
-
return candidate;
|
|
350
|
-
} catch (error) {
|
|
351
|
-
if (error?.code !== "EEXIST") {
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
throw new Error("unable to allocate a unique attachment file name");
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function normalizeText(value) {
|
|
361
|
-
return typeof value === "string" ? value.trim() : "";
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function normalizeContentType(value) {
|
|
365
|
-
return typeof value === "string" ? value.split(";")[0].trim().toLowerCase() : "";
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
module.exports = {
|
|
369
|
-
persistIncomingWeixinAttachments,
|
|
370
|
-
};
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const fs = require("fs/promises");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_INBOX_DIR = "inbox";
|
|
6
|
+
const MAX_FILE_NAME_LENGTH = 120;
|
|
7
|
+
|
|
8
|
+
async function persistIncomingWeixinAttachments({
|
|
9
|
+
attachments,
|
|
10
|
+
stateDir,
|
|
11
|
+
cdnBaseUrl,
|
|
12
|
+
messageId = "",
|
|
13
|
+
receivedAt = "",
|
|
14
|
+
}) {
|
|
15
|
+
const saved = [];
|
|
16
|
+
const failed = [];
|
|
17
|
+
|
|
18
|
+
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
|
19
|
+
try {
|
|
20
|
+
const persisted = await persistSingleAttachment({
|
|
21
|
+
attachment,
|
|
22
|
+
stateDir,
|
|
23
|
+
cdnBaseUrl,
|
|
24
|
+
messageId,
|
|
25
|
+
receivedAt,
|
|
26
|
+
});
|
|
27
|
+
saved.push(persisted);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
failed.push({
|
|
30
|
+
kind: attachment?.kind || "file",
|
|
31
|
+
sourceFileName: attachment?.fileName || "",
|
|
32
|
+
reason: error instanceof Error ? error.message : String(error || "unknown attachment error"),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { saved, failed };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function persistSingleAttachment({ attachment, stateDir, cdnBaseUrl, messageId, receivedAt }) {
|
|
41
|
+
const download = await downloadAttachmentPayload(attachment, cdnBaseUrl);
|
|
42
|
+
const plaintext = decodeAttachmentPayload(download.bytes, attachment, download.contentType);
|
|
43
|
+
const fileName = buildTargetFileName({
|
|
44
|
+
attachment,
|
|
45
|
+
plaintext,
|
|
46
|
+
contentType: download.contentType,
|
|
47
|
+
messageId,
|
|
48
|
+
});
|
|
49
|
+
const targetDir = buildInboxDirectory(stateDir, receivedAt);
|
|
50
|
+
const absolutePath = await writeUniqueFile(targetDir, fileName, plaintext);
|
|
51
|
+
const relativePath = path.relative(stateDir, absolutePath).replace(/\\/g, "/");
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
kind: attachment.kind || "file",
|
|
55
|
+
sourceFileName: attachment.fileName || "",
|
|
56
|
+
fileName: path.basename(absolutePath),
|
|
57
|
+
absolutePath,
|
|
58
|
+
relativePath,
|
|
59
|
+
sizeBytes: plaintext.length,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildInboxDirectory(stateDir, receivedAt) {
|
|
64
|
+
const day = normalizeDateFolder(receivedAt);
|
|
65
|
+
return path.join(stateDir, DEFAULT_INBOX_DIR, day);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeDateFolder(receivedAt) {
|
|
69
|
+
const date = receivedAt ? new Date(receivedAt) : new Date();
|
|
70
|
+
if (Number.isNaN(date.getTime())) {
|
|
71
|
+
return new Date().toISOString().slice(0, 10);
|
|
72
|
+
}
|
|
73
|
+
return date.toISOString().slice(0, 10);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function downloadAttachmentPayload(attachment, cdnBaseUrl) {
|
|
77
|
+
const candidates = buildDownloadCandidates(attachment, cdnBaseUrl);
|
|
78
|
+
if (!candidates.length) {
|
|
79
|
+
throw new Error("attachment did not include a supported download reference");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let lastError = null;
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(candidate, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: {
|
|
88
|
+
Accept: "*/*",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
lastError = new Error(`download failed with HTTP ${response.status}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
97
|
+
return {
|
|
98
|
+
bytes: Buffer.from(arrayBuffer),
|
|
99
|
+
contentType: normalizeContentType(response.headers.get("content-type")),
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
lastError = error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw lastError || new Error("attachment download failed");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildDownloadCandidates(attachment, cdnBaseUrl) {
|
|
110
|
+
const candidates = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const directUrls = Array.isArray(attachment?.directUrls) ? attachment.directUrls : [];
|
|
113
|
+
for (const directUrl of directUrls) {
|
|
114
|
+
addCandidate(candidates, seen, directUrl);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const encryptedQueryParam = normalizeText(attachment?.mediaRef?.encryptQueryParam);
|
|
118
|
+
if (encryptedQueryParam) {
|
|
119
|
+
const normalizedCdnBaseUrl = String(cdnBaseUrl || "").replace(/\/+$/g, "");
|
|
120
|
+
addCandidate(
|
|
121
|
+
candidates,
|
|
122
|
+
seen,
|
|
123
|
+
`${normalizedCdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const fileKey = normalizeText(attachment?.mediaRef?.fileKey);
|
|
127
|
+
if (fileKey) {
|
|
128
|
+
addCandidate(
|
|
129
|
+
candidates,
|
|
130
|
+
seen,
|
|
131
|
+
`${normalizedCdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}&filekey=${encodeURIComponent(fileKey)}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return candidates;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function addCandidate(candidates, seen, rawUrl) {
|
|
140
|
+
const normalizedUrl = normalizeText(rawUrl);
|
|
141
|
+
if (!normalizedUrl || seen.has(normalizedUrl)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
seen.add(normalizedUrl);
|
|
145
|
+
candidates.push(normalizedUrl);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function decodeAttachmentPayload(bytes, attachment, contentType) {
|
|
149
|
+
const encryptType = Number(attachment?.mediaRef?.encryptType);
|
|
150
|
+
const keyCandidates = buildAesKeyCandidates(attachment);
|
|
151
|
+
if (encryptType !== 1 || keyCandidates.length === 0) {
|
|
152
|
+
return bytes;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const key of keyCandidates) {
|
|
156
|
+
try {
|
|
157
|
+
return decryptAesEcb(bytes, key);
|
|
158
|
+
} catch {
|
|
159
|
+
// Try the next key encoding variant.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (looksLikePlainMedia(bytes, contentType)) {
|
|
164
|
+
return bytes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new Error("failed to decrypt attachment payload");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildAesKeyCandidates(attachment) {
|
|
171
|
+
const candidates = [];
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const rawValues = [
|
|
174
|
+
attachment?.mediaRef?.aesKeyHex,
|
|
175
|
+
attachment?.mediaRef?.aesKey,
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
for (const rawValue of rawValues) {
|
|
179
|
+
const variants = decodeAesKeyVariants(rawValue);
|
|
180
|
+
for (const variant of variants) {
|
|
181
|
+
const signature = variant.toString("hex");
|
|
182
|
+
if (seen.has(signature)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
seen.add(signature);
|
|
186
|
+
candidates.push(variant);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return candidates;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function decodeAesKeyVariants(value) {
|
|
194
|
+
const normalized = normalizeText(value);
|
|
195
|
+
if (!normalized) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const candidates = [];
|
|
200
|
+
if (/^[0-9a-f]{32}$/i.test(normalized)) {
|
|
201
|
+
candidates.push(Buffer.from(normalized, "hex"));
|
|
202
|
+
}
|
|
203
|
+
if (normalized.length === 16) {
|
|
204
|
+
candidates.push(Buffer.from(normalized, "utf8"));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const decoded = Buffer.from(normalized, "base64");
|
|
209
|
+
if (decoded.length === 16) {
|
|
210
|
+
candidates.push(decoded);
|
|
211
|
+
} else {
|
|
212
|
+
const decodedText = decoded.toString("utf8").trim();
|
|
213
|
+
if (/^[0-9a-f]{32}$/i.test(decodedText)) {
|
|
214
|
+
candidates.push(Buffer.from(decodedText, "hex"));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// Ignore invalid base64 variants.
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return candidates.filter((candidate) => candidate.length === 16);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decryptAesEcb(ciphertext, key) {
|
|
225
|
+
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
|
|
226
|
+
decipher.setAutoPadding(true);
|
|
227
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function looksLikePlainMedia(bytes, contentType) {
|
|
231
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (contentType.startsWith("text/")) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return detectExtensionFromBuffer(bytes) !== "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildTargetFileName({ attachment, plaintext, contentType, messageId }) {
|
|
243
|
+
const sourceName = sanitizeFileName(attachment?.fileName || "");
|
|
244
|
+
if (sourceName) {
|
|
245
|
+
const existingExt = path.extname(sourceName);
|
|
246
|
+
if (existingExt) {
|
|
247
|
+
return sourceName;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const inferredExt = inferExtension({
|
|
251
|
+
contentType,
|
|
252
|
+
plaintext,
|
|
253
|
+
kind: attachment?.kind,
|
|
254
|
+
});
|
|
255
|
+
return `${sourceName}${inferredExt}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const baseName = sanitizeFileName([
|
|
259
|
+
attachment?.kind || "file",
|
|
260
|
+
messageId || Date.now(),
|
|
261
|
+
String((attachment?.index ?? 0) + 1),
|
|
262
|
+
].join("-"));
|
|
263
|
+
const inferredExt = inferExtension({
|
|
264
|
+
contentType,
|
|
265
|
+
plaintext,
|
|
266
|
+
kind: attachment?.kind,
|
|
267
|
+
});
|
|
268
|
+
return `${baseName || "attachment"}${inferredExt}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function inferExtension({ contentType, plaintext, kind }) {
|
|
272
|
+
const contentTypeExt = extensionFromContentType(contentType);
|
|
273
|
+
if (contentTypeExt) {
|
|
274
|
+
return contentTypeExt;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const bufferExt = detectExtensionFromBuffer(plaintext);
|
|
278
|
+
if (bufferExt) {
|
|
279
|
+
return bufferExt;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (kind === "image") {
|
|
283
|
+
return ".png";
|
|
284
|
+
}
|
|
285
|
+
if (kind === "video") {
|
|
286
|
+
return ".mp4";
|
|
287
|
+
}
|
|
288
|
+
return ".bin";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function extensionFromContentType(contentType) {
|
|
292
|
+
const normalized = normalizeContentType(contentType);
|
|
293
|
+
const map = {
|
|
294
|
+
"image/png": ".png",
|
|
295
|
+
"image/jpeg": ".jpg",
|
|
296
|
+
"image/gif": ".gif",
|
|
297
|
+
"image/webp": ".webp",
|
|
298
|
+
"video/mp4": ".mp4",
|
|
299
|
+
"application/pdf": ".pdf",
|
|
300
|
+
"text/plain": ".txt",
|
|
301
|
+
};
|
|
302
|
+
return map[normalized] || "";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function detectExtensionFromBuffer(buffer) {
|
|
306
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) {
|
|
311
|
+
return ".png";
|
|
312
|
+
}
|
|
313
|
+
if (buffer.subarray(0, 3).equals(Buffer.from([0xFF, 0xD8, 0xFF]))) {
|
|
314
|
+
return ".jpg";
|
|
315
|
+
}
|
|
316
|
+
if (buffer.subarray(0, 4).toString("ascii") === "GIF8") {
|
|
317
|
+
return ".gif";
|
|
318
|
+
}
|
|
319
|
+
if (buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
320
|
+
&& buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
321
|
+
return ".webp";
|
|
322
|
+
}
|
|
323
|
+
if (buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
324
|
+
return ".mp4";
|
|
325
|
+
}
|
|
326
|
+
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
|
|
327
|
+
return ".pdf";
|
|
328
|
+
}
|
|
329
|
+
return "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sanitizeFileName(value) {
|
|
333
|
+
const parsed = path.parse(String(value || "").trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-"));
|
|
334
|
+
const safeBaseName = parsed.name || "attachment";
|
|
335
|
+
const safeExt = parsed.ext || "";
|
|
336
|
+
return `${safeBaseName.slice(0, MAX_FILE_NAME_LENGTH)}${safeExt.slice(0, 16)}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function writeUniqueFile(targetDir, fileName, plaintext) {
|
|
340
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
341
|
+
const parsed = path.parse(fileName);
|
|
342
|
+
const baseName = parsed.name || "attachment";
|
|
343
|
+
const extension = parsed.ext || "";
|
|
344
|
+
for (let index = 0; index < 50; index += 1) {
|
|
345
|
+
const suffix = index === 0 ? "" : `-${index + 1}`;
|
|
346
|
+
const candidate = path.join(targetDir, `${baseName}${suffix}${extension}`);
|
|
347
|
+
try {
|
|
348
|
+
await fs.writeFile(candidate, plaintext, { flag: "wx" });
|
|
349
|
+
return candidate;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error?.code !== "EEXIST") {
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
throw new Error("unable to allocate a unique attachment file name");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizeText(value) {
|
|
361
|
+
return typeof value === "string" ? value.trim() : "";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizeContentType(value) {
|
|
365
|
+
return typeof value === "string" ? value.split(";")[0].trim().toLowerCase() : "";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = {
|
|
369
|
+
persistIncomingWeixinAttachments,
|
|
370
|
+
};
|