codeksei 0.1.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 +661 -0
- package/README.en.md +215 -0
- package/README.md +259 -0
- package/bin/codeksei.js +10 -0
- package/bin/cyberboss.js +11 -0
- package/package.json +86 -0
- package/scripts/install-background-tasks.ps1 +135 -0
- package/scripts/open_shared_wechat_thread.sh +94 -0
- package/scripts/open_wechat_thread.sh +117 -0
- package/scripts/shared-common.js +791 -0
- package/scripts/shared-open.js +46 -0
- package/scripts/shared-start.js +41 -0
- package/scripts/shared-status.js +74 -0
- package/scripts/shared-supervisor.js +141 -0
- package/scripts/shared-task-runner.ps1 +87 -0
- package/scripts/shared-watchdog.js +290 -0
- package/scripts/show_shared_status.sh +53 -0
- package/scripts/start_shared_app_server.sh +65 -0
- package/scripts/start_shared_wechat.sh +108 -0
- package/scripts/timeline-screenshot.sh +15 -0
- package/scripts/uninstall-background-tasks.ps1 +23 -0
- package/src/adapters/channel/weixin/account-store.js +135 -0
- package/src/adapters/channel/weixin/api-v2.js +258 -0
- package/src/adapters/channel/weixin/api.js +180 -0
- package/src/adapters/channel/weixin/context-token-store.js +84 -0
- package/src/adapters/channel/weixin/index.js +605 -0
- package/src/adapters/channel/weixin/legacy.js +567 -0
- package/src/adapters/channel/weixin/login-common.js +63 -0
- package/src/adapters/channel/weixin/login-legacy.js +124 -0
- package/src/adapters/channel/weixin/login-v2.js +186 -0
- package/src/adapters/channel/weixin/media-mime.js +22 -0
- package/src/adapters/channel/weixin/media-receive.js +370 -0
- package/src/adapters/channel/weixin/media-send.js +331 -0
- package/src/adapters/channel/weixin/message-utils-v2.js +282 -0
- package/src/adapters/channel/weixin/message-utils.js +199 -0
- package/src/adapters/channel/weixin/protocol.js +77 -0
- package/src/adapters/channel/weixin/redact.js +41 -0
- package/src/adapters/channel/weixin/reminder-queue-store.js +101 -0
- package/src/adapters/channel/weixin/sync-buffer-store.js +35 -0
- package/src/adapters/runtime/codex/events.js +252 -0
- package/src/adapters/runtime/codex/index.js +502 -0
- package/src/adapters/runtime/codex/message-utils.js +141 -0
- package/src/adapters/runtime/codex/model-catalog.js +106 -0
- package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -0
- package/src/adapters/runtime/codex/rpc-client.js +443 -0
- package/src/adapters/runtime/codex/session-store.js +376 -0
- package/src/app/channel-send-file-cli.js +57 -0
- package/src/app/diary-write-cli.js +620 -0
- package/src/app/note-auto-cli.js +201 -0
- package/src/app/note-sync-cli.js +130 -0
- package/src/app/project-radar-cli.js +165 -0
- package/src/app/reminder-write-cli.js +210 -0
- package/src/app/review-cli.js +134 -0
- package/src/app/system-checkin-poller.js +100 -0
- package/src/app/system-send-cli.js +129 -0
- package/src/app/timeline-event-cli.js +273 -0
- package/src/app/timeline-screenshot-cli.js +109 -0
- package/src/core/app.js +1810 -0
- package/src/core/branding.js +167 -0
- package/src/core/command-registry.js +609 -0
- package/src/core/config.js +84 -0
- package/src/core/default-targets.js +163 -0
- package/src/core/durable-note-schema.js +325 -0
- package/src/core/instructions-template.js +31 -0
- package/src/core/note-sync.js +433 -0
- package/src/core/project-radar.js +402 -0
- package/src/core/review-semantic.js +524 -0
- package/src/core/review.js +1081 -0
- package/src/core/shared-bridge-heartbeat.js +140 -0
- package/src/core/stream-delivery.js +990 -0
- package/src/core/system-message-dispatcher.js +68 -0
- package/src/core/system-message-queue-store.js +128 -0
- package/src/core/thread-state-store.js +135 -0
- package/src/core/timeline-screenshot-queue-store.js +134 -0
- package/src/core/workspace-alias.js +163 -0
- package/src/core/workspace-bootstrap.js +338 -0
- package/src/index.js +270 -0
- package/src/integrations/timeline/index.js +191 -0
- package/templates/weixin-instructions.md +53 -0
- package/templates/weixin-operations.md +69 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { redactSensitiveText } = require("./redact");
|
|
2
|
+
const {
|
|
3
|
+
ACTIVE_LOGIN_TTL_MS,
|
|
4
|
+
MAX_QR_REFRESH_COUNT,
|
|
5
|
+
ensureTrailingSlash,
|
|
6
|
+
finishWeixinLogin,
|
|
7
|
+
printQrCode,
|
|
8
|
+
} = require("./login-common");
|
|
9
|
+
|
|
10
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
11
|
+
|
|
12
|
+
async function fetchQrCode(apiBaseUrl, botType) {
|
|
13
|
+
const base = ensureTrailingSlash(apiBaseUrl);
|
|
14
|
+
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
|
15
|
+
const response = await fetch(url.toString());
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
const body = await response.text().catch(() => "(unreadable)");
|
|
18
|
+
throw new Error(`二维码获取失败: ${response.status} ${response.statusText} ${redactSensitiveText(body)}`);
|
|
19
|
+
}
|
|
20
|
+
return response.json();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function pollQrStatus(apiBaseUrl, qrcode) {
|
|
24
|
+
const base = ensureTrailingSlash(apiBaseUrl);
|
|
25
|
+
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(url.toString(), {
|
|
30
|
+
headers: {
|
|
31
|
+
"iLink-App-ClientVersion": "1",
|
|
32
|
+
},
|
|
33
|
+
signal: controller.signal,
|
|
34
|
+
});
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
const rawText = await response.text();
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(`二维码状态轮询失败: ${response.status} ${response.statusText} ${redactSensitiveText(rawText)}`);
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(rawText);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
44
|
+
return { status: "wait" };
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function waitForLegacyWeixinLogin({ apiBaseUrl, botType, timeoutMs }) {
|
|
51
|
+
let qrResponse = await fetchQrCode(apiBaseUrl, botType);
|
|
52
|
+
let startedAt = Date.now();
|
|
53
|
+
let scannedPrinted = false;
|
|
54
|
+
let refreshCount = 1;
|
|
55
|
+
|
|
56
|
+
console.log("使用微信扫描以下二维码,以完成连接:\n");
|
|
57
|
+
printQrCode(qrResponse.qrcode_img_content);
|
|
58
|
+
console.log("\n等待连接结果...\n");
|
|
59
|
+
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
if (Date.now() - startedAt > ACTIVE_LOGIN_TTL_MS) {
|
|
63
|
+
qrResponse = await fetchQrCode(apiBaseUrl, botType);
|
|
64
|
+
startedAt = Date.now();
|
|
65
|
+
scannedPrinted = false;
|
|
66
|
+
refreshCount += 1;
|
|
67
|
+
if (refreshCount > MAX_QR_REFRESH_COUNT) {
|
|
68
|
+
throw new Error("二维码多次过期,请重新执行 login");
|
|
69
|
+
}
|
|
70
|
+
console.log(`二维码已过期,正在刷新...(${refreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
|
71
|
+
printQrCode(qrResponse.qrcode_img_content);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const statusResponse = await pollQrStatus(apiBaseUrl, qrResponse.qrcode);
|
|
75
|
+
switch (statusResponse.status) {
|
|
76
|
+
case "wait":
|
|
77
|
+
process.stdout.write(".");
|
|
78
|
+
break;
|
|
79
|
+
case "scaned":
|
|
80
|
+
if (!scannedPrinted) {
|
|
81
|
+
process.stdout.write("\n已扫码,请在微信中确认授权...\n");
|
|
82
|
+
scannedPrinted = true;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
case "expired":
|
|
86
|
+
qrResponse = await fetchQrCode(apiBaseUrl, botType);
|
|
87
|
+
startedAt = Date.now();
|
|
88
|
+
scannedPrinted = false;
|
|
89
|
+
refreshCount += 1;
|
|
90
|
+
if (refreshCount > MAX_QR_REFRESH_COUNT) {
|
|
91
|
+
throw new Error("二维码多次过期,请重新执行 login");
|
|
92
|
+
}
|
|
93
|
+
console.log(`二维码已过期,正在刷新...(${refreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
|
94
|
+
printQrCode(qrResponse.qrcode_img_content);
|
|
95
|
+
break;
|
|
96
|
+
case "confirmed":
|
|
97
|
+
if (!statusResponse.bot_token || !statusResponse.ilink_bot_id) {
|
|
98
|
+
throw new Error("登录成功但缺少 bot token 或账号 ID");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
accountId: statusResponse.ilink_bot_id,
|
|
102
|
+
token: statusResponse.bot_token,
|
|
103
|
+
baseUrl: statusResponse.baseurl || apiBaseUrl,
|
|
104
|
+
userId: statusResponse.ilink_user_id || "",
|
|
105
|
+
routeTag: "",
|
|
106
|
+
};
|
|
107
|
+
default:
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error("登录超时,请重新执行 login");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runLegacyLoginFlow(config) {
|
|
115
|
+
console.log("[codeksei] 正在启动微信扫码登录(legacy)...");
|
|
116
|
+
const result = await waitForLegacyWeixinLogin({
|
|
117
|
+
apiBaseUrl: config.weixinBaseUrl,
|
|
118
|
+
botType: config.weixinQrBotType,
|
|
119
|
+
timeoutMs: 480_000,
|
|
120
|
+
});
|
|
121
|
+
finishWeixinLogin(config, result);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { runLegacyLoginFlow };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const {
|
|
2
|
+
buildCommonHeaders,
|
|
3
|
+
normalizeProtocolClientVersion,
|
|
4
|
+
normalizeRouteTag,
|
|
5
|
+
} = require("./protocol");
|
|
6
|
+
const { redactSensitiveText } = require("./redact");
|
|
7
|
+
const {
|
|
8
|
+
ACTIVE_LOGIN_TTL_MS,
|
|
9
|
+
MAX_QR_REFRESH_COUNT,
|
|
10
|
+
ensureTrailingSlash,
|
|
11
|
+
finishWeixinLogin,
|
|
12
|
+
printQrCode,
|
|
13
|
+
} = require("./login-common");
|
|
14
|
+
|
|
15
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
16
|
+
|
|
17
|
+
async function fetchQrCode({ apiBaseUrl, botType, routeTag = "", clientVersion = "" }) {
|
|
18
|
+
const base = ensureTrailingSlash(apiBaseUrl);
|
|
19
|
+
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
|
20
|
+
const response = await fetch(url.toString(), {
|
|
21
|
+
headers: buildCommonHeaders({ routeTag, clientVersion }),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const body = await response.text().catch(() => "(unreadable)");
|
|
25
|
+
throw new Error(`二维码获取失败: ${response.status} ${response.statusText} ${redactSensitiveText(body)}`);
|
|
26
|
+
}
|
|
27
|
+
return response.json();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function pollQrStatus({ apiBaseUrl, qrcode, routeTag = "", clientVersion = "" }) {
|
|
31
|
+
const base = ensureTrailingSlash(apiBaseUrl);
|
|
32
|
+
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(url.toString(), {
|
|
37
|
+
headers: buildCommonHeaders({ routeTag, clientVersion }),
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
const rawText = await response.text();
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`二维码状态轮询失败: ${response.status} ${response.statusText} ${redactSensitiveText(rawText)}`);
|
|
44
|
+
}
|
|
45
|
+
return JSON.parse(rawText);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (isTransientLongPollError(error)) {
|
|
49
|
+
return { status: "wait" };
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitForV2WeixinLogin({ apiBaseUrl, botType, routeTag = "", clientVersion = "", timeoutMs }) {
|
|
56
|
+
let qrResponse = await fetchQrCode({ apiBaseUrl, botType, routeTag, clientVersion });
|
|
57
|
+
let startedAt = Date.now();
|
|
58
|
+
let refreshCount = 0;
|
|
59
|
+
let scannedPrinted = false;
|
|
60
|
+
let pollBaseUrl = apiBaseUrl;
|
|
61
|
+
|
|
62
|
+
console.log("使用微信扫描以下二维码,以完成连接:\n");
|
|
63
|
+
printQrCode(qrResponse.qrcode_img_content);
|
|
64
|
+
console.log("\n等待连接结果...\n");
|
|
65
|
+
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
if (Date.now() - startedAt > ACTIVE_LOGIN_TTL_MS) {
|
|
69
|
+
({ qrResponse, startedAt, refreshCount, scannedPrinted, pollBaseUrl } = await refreshQrCode({
|
|
70
|
+
reason: "二维码已过期,正在刷新...",
|
|
71
|
+
apiBaseUrl,
|
|
72
|
+
botType,
|
|
73
|
+
routeTag,
|
|
74
|
+
clientVersion,
|
|
75
|
+
refreshCount,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const statusResponse = await pollQrStatus({
|
|
80
|
+
apiBaseUrl: pollBaseUrl,
|
|
81
|
+
qrcode: qrResponse.qrcode,
|
|
82
|
+
routeTag,
|
|
83
|
+
clientVersion,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
switch (statusResponse.status) {
|
|
87
|
+
case "wait":
|
|
88
|
+
process.stdout.write(".");
|
|
89
|
+
await sleep(1_000);
|
|
90
|
+
break;
|
|
91
|
+
case "scaned":
|
|
92
|
+
if (!scannedPrinted) {
|
|
93
|
+
process.stdout.write("\n已扫码,请在微信中确认授权...\n");
|
|
94
|
+
scannedPrinted = true;
|
|
95
|
+
}
|
|
96
|
+
await sleep(1_000);
|
|
97
|
+
break;
|
|
98
|
+
case "scaned_but_redirect":
|
|
99
|
+
// V2 login can hand the QR polling phase off to a redirected host.
|
|
100
|
+
// If we keep polling the gateway after this point, login looks "stuck"
|
|
101
|
+
// even though the user already confirmed in WeChat.
|
|
102
|
+
if (typeof statusResponse.redirect_host === "string" && statusResponse.redirect_host.trim()) {
|
|
103
|
+
pollBaseUrl = `https://${statusResponse.redirect_host.trim()}`;
|
|
104
|
+
}
|
|
105
|
+
await sleep(1_000);
|
|
106
|
+
break;
|
|
107
|
+
case "expired":
|
|
108
|
+
({ qrResponse, startedAt, refreshCount, scannedPrinted, pollBaseUrl } = await refreshQrCode({
|
|
109
|
+
reason: "二维码已过期,正在刷新...",
|
|
110
|
+
apiBaseUrl,
|
|
111
|
+
botType,
|
|
112
|
+
routeTag,
|
|
113
|
+
clientVersion,
|
|
114
|
+
refreshCount,
|
|
115
|
+
}));
|
|
116
|
+
break;
|
|
117
|
+
case "confirmed":
|
|
118
|
+
if (!statusResponse.bot_token || !statusResponse.ilink_bot_id) {
|
|
119
|
+
throw new Error("登录成功但缺少 bot token 或账号 ID");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
accountId: statusResponse.ilink_bot_id,
|
|
123
|
+
token: statusResponse.bot_token,
|
|
124
|
+
baseUrl: statusResponse.baseurl || pollBaseUrl || apiBaseUrl,
|
|
125
|
+
userId: statusResponse.ilink_user_id || "",
|
|
126
|
+
routeTag,
|
|
127
|
+
};
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`二维码状态异常: ${redactSensitiveText(JSON.stringify(statusResponse))}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new Error("登录超时,请重新执行 login");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function refreshQrCode({ reason, apiBaseUrl, botType, routeTag, clientVersion, refreshCount }) {
|
|
137
|
+
const nextRefreshCount = refreshCount + 1;
|
|
138
|
+
if (nextRefreshCount > MAX_QR_REFRESH_COUNT) {
|
|
139
|
+
throw new Error("二维码多次过期,请重新执行 login");
|
|
140
|
+
}
|
|
141
|
+
const qrResponse = await fetchQrCode({ apiBaseUrl, botType, routeTag, clientVersion });
|
|
142
|
+
console.log(`${reason}(${nextRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
|
143
|
+
printQrCode(qrResponse.qrcode_img_content);
|
|
144
|
+
return {
|
|
145
|
+
qrResponse,
|
|
146
|
+
startedAt: Date.now(),
|
|
147
|
+
refreshCount: nextRefreshCount,
|
|
148
|
+
scannedPrinted: false,
|
|
149
|
+
pollBaseUrl: apiBaseUrl,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isTransientLongPollError(error) {
|
|
154
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
const message = String(error?.message || "").toLowerCase();
|
|
158
|
+
return message.includes("aborted")
|
|
159
|
+
|| message.includes("fetch failed")
|
|
160
|
+
|| message.includes("networkerror")
|
|
161
|
+
|| message.includes("timed out")
|
|
162
|
+
|| error instanceof TypeError;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sleep(ms) {
|
|
166
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runV2LoginFlow(config) {
|
|
170
|
+
const routeTag = normalizeRouteTag(config.weixinRouteTag);
|
|
171
|
+
const clientVersion = normalizeProtocolClientVersion(config.weixinProtocolClientVersion);
|
|
172
|
+
const routeTagLabel = routeTag ? ` routeTag=${routeTag}` : "";
|
|
173
|
+
console.log(`[codeksei] 正在启动微信扫码登录(v2)...${routeTagLabel}`);
|
|
174
|
+
const result = await waitForV2WeixinLogin({
|
|
175
|
+
apiBaseUrl: config.weixinBaseUrl,
|
|
176
|
+
botType: config.weixinQrBotType,
|
|
177
|
+
routeTag,
|
|
178
|
+
clientVersion,
|
|
179
|
+
timeoutMs: 480_000,
|
|
180
|
+
});
|
|
181
|
+
finishWeixinLogin(config, result);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
runV2LoginFlow,
|
|
186
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
const MIME_BY_EXT = {
|
|
4
|
+
".png": "image/png",
|
|
5
|
+
".jpg": "image/jpeg",
|
|
6
|
+
".jpeg": "image/jpeg",
|
|
7
|
+
".gif": "image/gif",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
".mp4": "video/mp4",
|
|
10
|
+
".mov": "video/quicktime",
|
|
11
|
+
".pdf": "application/pdf",
|
|
12
|
+
".txt": "text/plain",
|
|
13
|
+
".md": "text/markdown",
|
|
14
|
+
".json": "application/json",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getMimeFromFilename(filePath) {
|
|
18
|
+
const ext = path.extname(String(filePath || "")).toLowerCase();
|
|
19
|
+
return MIME_BY_EXT[ext] || "application/octet-stream";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { getMimeFromFilename };
|
|
@@ -0,0 +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
|
+
};
|