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,331 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
|
|
5
|
+
const { getUploadUrl, sendMessage } = require("./api");
|
|
6
|
+
const { getUploadUrlV2, sendMessageV2 } = require("./api-v2");
|
|
7
|
+
const { getMimeFromFilename } = require("./media-mime");
|
|
8
|
+
|
|
9
|
+
const WEIXIN_MEDIA_TYPE = {
|
|
10
|
+
IMAGE: 1,
|
|
11
|
+
VIDEO: 2,
|
|
12
|
+
FILE: 3,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function encryptAesEcb(plaintext, key) {
|
|
16
|
+
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
|
|
17
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function aesEcbPaddedSize(plaintextSize) {
|
|
21
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey }) {
|
|
25
|
+
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function uploadBufferToCdn({ buf, uploadParam, filekey, cdnBaseUrl, aeskey }) {
|
|
29
|
+
const ciphertext = encryptAesEcb(buf, aeskey);
|
|
30
|
+
const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
|
|
31
|
+
const response = await fetch(cdnUrl, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
34
|
+
body: new Uint8Array(ciphertext),
|
|
35
|
+
});
|
|
36
|
+
if (response.status !== 200) {
|
|
37
|
+
const errMsg = response.headers.get("x-error-message") || await response.text();
|
|
38
|
+
throw new Error(`CDN upload failed: ${errMsg || response.status}`);
|
|
39
|
+
}
|
|
40
|
+
const downloadParam = response.headers.get("x-encrypted-param") || "";
|
|
41
|
+
if (!downloadParam) {
|
|
42
|
+
throw new Error("CDN upload response missing x-encrypted-param header");
|
|
43
|
+
}
|
|
44
|
+
return { downloadParam };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function uploadMediaToWeixin({ filePath, toUserId, opts, cdnBaseUrl, mediaType, getUploadUrlImpl }) {
|
|
48
|
+
const plaintext = await fs.readFile(filePath);
|
|
49
|
+
const rawsize = plaintext.length;
|
|
50
|
+
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
51
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
52
|
+
const filekey = crypto.randomBytes(16).toString("hex");
|
|
53
|
+
const aeskey = crypto.randomBytes(16);
|
|
54
|
+
|
|
55
|
+
const uploadUrlResp = await getUploadUrlImpl({
|
|
56
|
+
...opts,
|
|
57
|
+
filekey,
|
|
58
|
+
media_type: mediaType,
|
|
59
|
+
to_user_id: toUserId,
|
|
60
|
+
rawsize,
|
|
61
|
+
rawfilemd5,
|
|
62
|
+
filesize,
|
|
63
|
+
no_need_thumb: true,
|
|
64
|
+
aeskey: aeskey.toString("hex"),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const uploadParam = uploadUrlResp?.upload_param || "";
|
|
68
|
+
if (!uploadParam) {
|
|
69
|
+
throw new Error("getUploadUrl returned no upload_param");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { downloadParam } = await uploadBufferToCdn({
|
|
73
|
+
buf: plaintext,
|
|
74
|
+
uploadParam,
|
|
75
|
+
filekey,
|
|
76
|
+
cdnBaseUrl,
|
|
77
|
+
aeskey,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
downloadEncryptedQueryParam: downloadParam,
|
|
82
|
+
aeskey: aeskey.toString("hex"),
|
|
83
|
+
fileSize: rawsize,
|
|
84
|
+
fileSizeCiphertext: filesize,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildMediaRef(uploaded) {
|
|
89
|
+
return {
|
|
90
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
91
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
92
|
+
encrypt_type: 1,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function sendMediaItem({ to, item, contextToken, baseUrl, token, routeTag = "", clientVersion = "", sendMessageImpl }) {
|
|
97
|
+
await sendMessageImpl({
|
|
98
|
+
baseUrl,
|
|
99
|
+
token,
|
|
100
|
+
routeTag,
|
|
101
|
+
clientVersion,
|
|
102
|
+
body: {
|
|
103
|
+
msg: {
|
|
104
|
+
from_user_id: "",
|
|
105
|
+
to_user_id: to,
|
|
106
|
+
client_id: crypto.randomUUID(),
|
|
107
|
+
message_type: 2,
|
|
108
|
+
message_state: 2,
|
|
109
|
+
item_list: [item],
|
|
110
|
+
context_token: contextToken,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveWeixinMediaApi(apiVariant) {
|
|
117
|
+
if (String(apiVariant || "").trim().toLowerCase() === "v2") {
|
|
118
|
+
// Upload URL lookup and the final sendmessage call must stay on the same
|
|
119
|
+
// v2 header stack as text polling/sending, or routed sessions may split
|
|
120
|
+
// across gateways after login and fail only on attachments.
|
|
121
|
+
return {
|
|
122
|
+
getUploadUrlImpl: getUploadUrlV2,
|
|
123
|
+
sendMessageImpl: sendMessageV2,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
getUploadUrlImpl: getUploadUrl,
|
|
128
|
+
sendMessageImpl: sendMessage,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function sendWeixinMediaFile({
|
|
133
|
+
filePath,
|
|
134
|
+
to,
|
|
135
|
+
contextToken,
|
|
136
|
+
baseUrl,
|
|
137
|
+
token,
|
|
138
|
+
cdnBaseUrl,
|
|
139
|
+
apiVariant = "legacy",
|
|
140
|
+
routeTag = "",
|
|
141
|
+
clientVersion = "",
|
|
142
|
+
mediaApiOverride = null,
|
|
143
|
+
mediaApiFallbackOverride = null,
|
|
144
|
+
}) {
|
|
145
|
+
if (!contextToken) {
|
|
146
|
+
throw new Error("sendWeixinMediaFile requires contextToken");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mime = getMimeFromFilename(filePath);
|
|
150
|
+
const uploadOpts = { baseUrl, token, routeTag, clientVersion };
|
|
151
|
+
const primaryMediaApi = mediaApiOverride || resolveWeixinMediaApi(apiVariant);
|
|
152
|
+
const fallbackMediaApi = mediaApiFallbackOverride
|
|
153
|
+
|| (mediaApiOverride ? null : resolveFallbackWeixinMediaApi(apiVariant));
|
|
154
|
+
const { getUploadUrlImpl, sendMessageImpl } = primaryMediaApi;
|
|
155
|
+
|
|
156
|
+
if (mime.startsWith("image/")) {
|
|
157
|
+
try {
|
|
158
|
+
const uploaded = await uploadMediaToWeixin({
|
|
159
|
+
filePath,
|
|
160
|
+
toUserId: to,
|
|
161
|
+
opts: uploadOpts,
|
|
162
|
+
cdnBaseUrl,
|
|
163
|
+
mediaType: WEIXIN_MEDIA_TYPE.IMAGE,
|
|
164
|
+
getUploadUrlImpl,
|
|
165
|
+
});
|
|
166
|
+
await sendMediaItem({
|
|
167
|
+
to,
|
|
168
|
+
contextToken,
|
|
169
|
+
baseUrl,
|
|
170
|
+
token,
|
|
171
|
+
routeTag,
|
|
172
|
+
clientVersion,
|
|
173
|
+
sendMessageImpl,
|
|
174
|
+
item: {
|
|
175
|
+
type: 2,
|
|
176
|
+
image_item: {
|
|
177
|
+
media: buildMediaRef(uploaded),
|
|
178
|
+
aeskey: uploaded.aeskey,
|
|
179
|
+
mid_size: uploaded.fileSizeCiphertext,
|
|
180
|
+
hd_size: uploaded.fileSizeCiphertext,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return { kind: "image", fileName: path.basename(filePath) };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!isMissingUploadParamError(error)) {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
// Some WeChat routed sessions refuse image upload params but still accept
|
|
190
|
+
// generic file uploads. Falling back keeps screenshot delivery alive
|
|
191
|
+
// instead of failing the whole "send back the screenshot" flow.
|
|
192
|
+
return sendFileFallback({
|
|
193
|
+
filePath,
|
|
194
|
+
to,
|
|
195
|
+
contextToken,
|
|
196
|
+
baseUrl,
|
|
197
|
+
token,
|
|
198
|
+
routeTag,
|
|
199
|
+
clientVersion,
|
|
200
|
+
uploadOpts,
|
|
201
|
+
cdnBaseUrl,
|
|
202
|
+
primaryMediaApi,
|
|
203
|
+
fallbackMediaApi,
|
|
204
|
+
fallbackFrom: "image",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (mime.startsWith("video/")) {
|
|
210
|
+
const uploaded = await uploadMediaToWeixin({
|
|
211
|
+
filePath,
|
|
212
|
+
toUserId: to,
|
|
213
|
+
opts: uploadOpts,
|
|
214
|
+
cdnBaseUrl,
|
|
215
|
+
mediaType: WEIXIN_MEDIA_TYPE.VIDEO,
|
|
216
|
+
getUploadUrlImpl,
|
|
217
|
+
});
|
|
218
|
+
await sendMediaItem({
|
|
219
|
+
to,
|
|
220
|
+
contextToken,
|
|
221
|
+
baseUrl,
|
|
222
|
+
token,
|
|
223
|
+
routeTag,
|
|
224
|
+
clientVersion,
|
|
225
|
+
sendMessageImpl,
|
|
226
|
+
item: {
|
|
227
|
+
type: 5,
|
|
228
|
+
video_item: {
|
|
229
|
+
media: buildMediaRef(uploaded),
|
|
230
|
+
video_size: uploaded.fileSizeCiphertext,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return { kind: "video", fileName: path.basename(filePath) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return sendFileFallback({
|
|
238
|
+
filePath,
|
|
239
|
+
to,
|
|
240
|
+
contextToken,
|
|
241
|
+
baseUrl,
|
|
242
|
+
token,
|
|
243
|
+
routeTag,
|
|
244
|
+
clientVersion,
|
|
245
|
+
uploadOpts,
|
|
246
|
+
cdnBaseUrl,
|
|
247
|
+
primaryMediaApi,
|
|
248
|
+
fallbackMediaApi,
|
|
249
|
+
fallbackFrom: "",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isMissingUploadParamError(error) {
|
|
254
|
+
return String(error?.message || error || "").includes("getUploadUrl returned no upload_param");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveFallbackWeixinMediaApi(apiVariant) {
|
|
258
|
+
const normalized = String(apiVariant || "").trim().toLowerCase();
|
|
259
|
+
return normalized === "legacy" ? null : resolveWeixinMediaApi("legacy");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function sendFileFallback({
|
|
263
|
+
filePath,
|
|
264
|
+
to,
|
|
265
|
+
contextToken,
|
|
266
|
+
baseUrl,
|
|
267
|
+
token,
|
|
268
|
+
routeTag,
|
|
269
|
+
clientVersion,
|
|
270
|
+
uploadOpts,
|
|
271
|
+
cdnBaseUrl,
|
|
272
|
+
primaryMediaApi,
|
|
273
|
+
fallbackMediaApi = null,
|
|
274
|
+
fallbackFrom = "",
|
|
275
|
+
}) {
|
|
276
|
+
const strategies = [
|
|
277
|
+
{ label: "primary", api: primaryMediaApi },
|
|
278
|
+
{ label: "fallback", api: fallbackMediaApi },
|
|
279
|
+
].filter((entry) => entry.api);
|
|
280
|
+
|
|
281
|
+
let lastError = null;
|
|
282
|
+
for (let index = 0; index < strategies.length; index += 1) {
|
|
283
|
+
const { label, api } = strategies[index];
|
|
284
|
+
try {
|
|
285
|
+
const uploaded = await uploadMediaToWeixin({
|
|
286
|
+
filePath,
|
|
287
|
+
toUserId: to,
|
|
288
|
+
opts: uploadOpts,
|
|
289
|
+
cdnBaseUrl,
|
|
290
|
+
mediaType: WEIXIN_MEDIA_TYPE.FILE,
|
|
291
|
+
getUploadUrlImpl: api.getUploadUrlImpl,
|
|
292
|
+
});
|
|
293
|
+
await sendMediaItem({
|
|
294
|
+
to,
|
|
295
|
+
contextToken,
|
|
296
|
+
baseUrl,
|
|
297
|
+
token,
|
|
298
|
+
routeTag,
|
|
299
|
+
clientVersion,
|
|
300
|
+
sendMessageImpl: api.sendMessageImpl,
|
|
301
|
+
item: {
|
|
302
|
+
type: 4,
|
|
303
|
+
file_item: {
|
|
304
|
+
media: buildMediaRef(uploaded),
|
|
305
|
+
file_name: path.basename(filePath),
|
|
306
|
+
len: String(uploaded.fileSize),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
kind: "file",
|
|
312
|
+
fileName: path.basename(filePath),
|
|
313
|
+
fallbackFrom: fallbackFrom || undefined,
|
|
314
|
+
uploadStrategy: label,
|
|
315
|
+
};
|
|
316
|
+
} catch (error) {
|
|
317
|
+
lastError = error;
|
|
318
|
+
if (!isMissingUploadParamError(error) || index >= strategies.length - 1) {
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
console.warn(
|
|
322
|
+
`[codeksei] weixin media upload fallback `
|
|
323
|
+
+ `file=${path.basename(filePath)} reason=${String(error.message || error)}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw lastError || new Error("weixin media upload failed");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { sendWeixinMediaFile };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const MESSAGE_TYPE_USER = 1;
|
|
2
|
+
const MESSAGE_TYPE_BOT = 2;
|
|
3
|
+
const MESSAGE_ITEM_TEXT = 1;
|
|
4
|
+
const MESSAGE_ITEM_IMAGE = 2;
|
|
5
|
+
const MESSAGE_ITEM_VOICE = 3;
|
|
6
|
+
const MESSAGE_ITEM_FILE = 4;
|
|
7
|
+
const MESSAGE_ITEM_VIDEO = 5;
|
|
8
|
+
const DEDUP_TTL_MS = 5 * 60_000;
|
|
9
|
+
|
|
10
|
+
function createInboundFilter() {
|
|
11
|
+
const seen = new Map();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
normalize(message, config, accountId) {
|
|
15
|
+
if (!message || typeof message !== "object") {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const messageType = Number(message.message_type);
|
|
19
|
+
if (messageType === MESSAGE_TYPE_BOT) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (messageType !== 0 && messageType !== MESSAGE_TYPE_USER) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const senderId = normalizeText(message.from_user_id);
|
|
27
|
+
if (!senderId) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const createdAtMs = normalizeMessageTimestampMs(message);
|
|
32
|
+
|
|
33
|
+
const dedupKey = buildDedupKey(message, senderId, createdAtMs);
|
|
34
|
+
pruneSeen(seen);
|
|
35
|
+
if (dedupKey && seen.has(dedupKey)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (dedupKey) {
|
|
39
|
+
seen.set(dedupKey, Date.now());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const itemList = Array.isArray(message.item_list) ? message.item_list : [];
|
|
43
|
+
const text = bodyFromItemList(itemList);
|
|
44
|
+
const attachments = extractAttachmentItems(itemList);
|
|
45
|
+
if (!text && !attachments.length) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
provider: "weixin",
|
|
51
|
+
accountId,
|
|
52
|
+
workspaceId: config.workspaceId,
|
|
53
|
+
senderId,
|
|
54
|
+
chatId: senderId,
|
|
55
|
+
messageId: normalizeMessageId(message),
|
|
56
|
+
threadKey: normalizeText(message.session_id),
|
|
57
|
+
text,
|
|
58
|
+
attachments,
|
|
59
|
+
contextToken: normalizeText(message.context_token),
|
|
60
|
+
receivedAt: createdAtMs > 0 ? new Date(createdAtMs).toISOString() : new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bodyFromItemList(items) {
|
|
67
|
+
if (!Array.isArray(items) || !items.length) {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
const itemType = Number(item?.type);
|
|
72
|
+
if (itemType === MESSAGE_ITEM_TEXT) {
|
|
73
|
+
const text = normalizeText(item?.text_item?.text);
|
|
74
|
+
if (!text) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const ref = item?.ref_msg;
|
|
78
|
+
if (!ref || !ref.message_item || isMediaItemType(Number(ref.message_item.type))) {
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
const parts = [];
|
|
82
|
+
const refTitle = normalizeText(ref.title);
|
|
83
|
+
if (refTitle) {
|
|
84
|
+
parts.push(refTitle);
|
|
85
|
+
}
|
|
86
|
+
const refBody = bodyFromItemList([ref.message_item]);
|
|
87
|
+
if (refBody) {
|
|
88
|
+
parts.push(refBody);
|
|
89
|
+
}
|
|
90
|
+
if (!parts.length) {
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
return `[引用: ${parts.join(" | ")}]\n${text}`;
|
|
94
|
+
}
|
|
95
|
+
if (itemType === MESSAGE_ITEM_VOICE) {
|
|
96
|
+
const voiceText = normalizeText(item?.voice_item?.text);
|
|
97
|
+
if (voiceText) {
|
|
98
|
+
return voiceText;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isMediaItemType(type) {
|
|
106
|
+
return type === MESSAGE_ITEM_IMAGE || type === MESSAGE_ITEM_VOICE || type === MESSAGE_ITEM_FILE || type === MESSAGE_ITEM_VIDEO;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractAttachmentItems(itemList) {
|
|
110
|
+
if (!Array.isArray(itemList) || !itemList.length) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const attachments = [];
|
|
115
|
+
for (let index = 0; index < itemList.length; index += 1) {
|
|
116
|
+
const normalized = normalizeAttachmentItem(itemList[index], index);
|
|
117
|
+
if (normalized) {
|
|
118
|
+
attachments.push(normalized);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return attachments;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeAttachmentItem(item, index) {
|
|
125
|
+
const itemType = Number(item?.type);
|
|
126
|
+
const payload = resolveAttachmentPayload(itemType, item);
|
|
127
|
+
if (!payload) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const media = payload.media && typeof payload.media === "object"
|
|
132
|
+
? payload.media
|
|
133
|
+
: {};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
kind: payload.kind,
|
|
137
|
+
itemType,
|
|
138
|
+
index,
|
|
139
|
+
fileName: normalizeText(
|
|
140
|
+
payload.body?.file_name
|
|
141
|
+
|| payload.body?.filename
|
|
142
|
+
|| item?.file_name
|
|
143
|
+
|| item?.filename
|
|
144
|
+
),
|
|
145
|
+
sizeBytes: parseOptionalInt(
|
|
146
|
+
payload.body?.len
|
|
147
|
+
|| payload.body?.file_size
|
|
148
|
+
|| payload.body?.size
|
|
149
|
+
|| payload.body?.video_size
|
|
150
|
+
|| item?.len
|
|
151
|
+
),
|
|
152
|
+
directUrls: collectStringValues([
|
|
153
|
+
payload.body?.url,
|
|
154
|
+
payload.body?.download_url,
|
|
155
|
+
payload.body?.cdn_url,
|
|
156
|
+
media?.url,
|
|
157
|
+
media?.download_url,
|
|
158
|
+
media?.cdn_url,
|
|
159
|
+
]),
|
|
160
|
+
mediaRef: {
|
|
161
|
+
encryptQueryParam: normalizeText(
|
|
162
|
+
media?.encrypt_query_param
|
|
163
|
+
|| media?.encrypted_query_param
|
|
164
|
+
|| payload.body?.encrypt_query_param
|
|
165
|
+
|| payload.body?.encrypted_query_param
|
|
166
|
+
|| item?.encrypt_query_param
|
|
167
|
+
|| item?.encrypted_query_param
|
|
168
|
+
),
|
|
169
|
+
aesKey: normalizeText(
|
|
170
|
+
media?.aes_key
|
|
171
|
+
|| payload.body?.aes_key
|
|
172
|
+
|| item?.aes_key
|
|
173
|
+
),
|
|
174
|
+
aesKeyHex: normalizeText(
|
|
175
|
+
payload.body?.aeskey
|
|
176
|
+
|| payload.body?.aes_key_hex
|
|
177
|
+
|| item?.aeskey
|
|
178
|
+
),
|
|
179
|
+
encryptType: Number(
|
|
180
|
+
media?.encrypt_type
|
|
181
|
+
?? payload.body?.encrypt_type
|
|
182
|
+
?? item?.encrypt_type
|
|
183
|
+
?? 1
|
|
184
|
+
),
|
|
185
|
+
fileKey: normalizeText(
|
|
186
|
+
media?.filekey
|
|
187
|
+
|| payload.body?.filekey
|
|
188
|
+
|| item?.filekey
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
rawItem: item,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveAttachmentPayload(itemType, item) {
|
|
196
|
+
if (itemType === MESSAGE_ITEM_IMAGE && item?.image_item && typeof item.image_item === "object") {
|
|
197
|
+
return { kind: "image", body: item.image_item, media: item.image_item.media };
|
|
198
|
+
}
|
|
199
|
+
if (itemType === MESSAGE_ITEM_FILE && item?.file_item && typeof item.file_item === "object") {
|
|
200
|
+
return { kind: "file", body: item.file_item, media: item.file_item.media };
|
|
201
|
+
}
|
|
202
|
+
if (itemType === MESSAGE_ITEM_VIDEO && item?.video_item && typeof item.video_item === "object") {
|
|
203
|
+
return { kind: "video", body: item.video_item, media: item.video_item.media };
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectStringValues(values) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const result = [];
|
|
211
|
+
for (const value of values) {
|
|
212
|
+
const normalized = normalizeText(value);
|
|
213
|
+
if (!normalized || seen.has(normalized)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
seen.add(normalized);
|
|
217
|
+
result.push(normalized);
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseOptionalInt(value) {
|
|
223
|
+
if (value == null || value === "") {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
227
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeMessageId(message) {
|
|
231
|
+
const raw = message?.message_id;
|
|
232
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
233
|
+
return String(raw);
|
|
234
|
+
}
|
|
235
|
+
if (typeof raw === "string") {
|
|
236
|
+
return raw.trim();
|
|
237
|
+
}
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeMessageTimestampMs(message) {
|
|
242
|
+
const rawMs = Number(message?.create_time_ms);
|
|
243
|
+
if (Number.isFinite(rawMs) && rawMs > 0) {
|
|
244
|
+
return rawMs;
|
|
245
|
+
}
|
|
246
|
+
const rawSeconds = Number(message?.create_time);
|
|
247
|
+
if (Number.isFinite(rawSeconds) && rawSeconds > 0) {
|
|
248
|
+
return rawSeconds * 1000;
|
|
249
|
+
}
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildDedupKey(message, senderId, createdAtMs) {
|
|
254
|
+
const seq = normalizeNumeric(message?.seq);
|
|
255
|
+
const messageId = normalizeNumeric(message?.message_id);
|
|
256
|
+
const clientId = normalizeText(message?.client_id);
|
|
257
|
+
const parts = [senderId, messageId, seq, createdAtMs || 0, clientId];
|
|
258
|
+
return parts.join("|");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeNumeric(value) {
|
|
262
|
+
const num = Number(value);
|
|
263
|
+
return Number.isFinite(num) ? String(num) : "0";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function pruneSeen(seen) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
for (const [key, timestamp] of seen.entries()) {
|
|
269
|
+
if (now - timestamp > DEDUP_TTL_MS) {
|
|
270
|
+
seen.delete(key);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeText(value) {
|
|
276
|
+
return typeof value === "string" ? value.trim() : "";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
createInboundFilter,
|
|
281
|
+
bodyFromItemList,
|
|
282
|
+
};
|