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.
Files changed (80) hide show
  1. package/LICENSE +661 -0
  2. package/README.en.md +215 -0
  3. package/README.md +259 -0
  4. package/bin/codeksei.js +10 -0
  5. package/bin/cyberboss.js +11 -0
  6. package/package.json +86 -0
  7. package/scripts/install-background-tasks.ps1 +135 -0
  8. package/scripts/open_shared_wechat_thread.sh +94 -0
  9. package/scripts/open_wechat_thread.sh +117 -0
  10. package/scripts/shared-common.js +791 -0
  11. package/scripts/shared-open.js +46 -0
  12. package/scripts/shared-start.js +41 -0
  13. package/scripts/shared-status.js +74 -0
  14. package/scripts/shared-supervisor.js +141 -0
  15. package/scripts/shared-task-runner.ps1 +87 -0
  16. package/scripts/shared-watchdog.js +290 -0
  17. package/scripts/show_shared_status.sh +53 -0
  18. package/scripts/start_shared_app_server.sh +65 -0
  19. package/scripts/start_shared_wechat.sh +108 -0
  20. package/scripts/timeline-screenshot.sh +15 -0
  21. package/scripts/uninstall-background-tasks.ps1 +23 -0
  22. package/src/adapters/channel/weixin/account-store.js +135 -0
  23. package/src/adapters/channel/weixin/api-v2.js +258 -0
  24. package/src/adapters/channel/weixin/api.js +180 -0
  25. package/src/adapters/channel/weixin/context-token-store.js +84 -0
  26. package/src/adapters/channel/weixin/index.js +605 -0
  27. package/src/adapters/channel/weixin/legacy.js +567 -0
  28. package/src/adapters/channel/weixin/login-common.js +63 -0
  29. package/src/adapters/channel/weixin/login-legacy.js +124 -0
  30. package/src/adapters/channel/weixin/login-v2.js +186 -0
  31. package/src/adapters/channel/weixin/media-mime.js +22 -0
  32. package/src/adapters/channel/weixin/media-receive.js +370 -0
  33. package/src/adapters/channel/weixin/media-send.js +331 -0
  34. package/src/adapters/channel/weixin/message-utils-v2.js +282 -0
  35. package/src/adapters/channel/weixin/message-utils.js +199 -0
  36. package/src/adapters/channel/weixin/protocol.js +77 -0
  37. package/src/adapters/channel/weixin/redact.js +41 -0
  38. package/src/adapters/channel/weixin/reminder-queue-store.js +101 -0
  39. package/src/adapters/channel/weixin/sync-buffer-store.js +35 -0
  40. package/src/adapters/runtime/codex/events.js +252 -0
  41. package/src/adapters/runtime/codex/index.js +502 -0
  42. package/src/adapters/runtime/codex/message-utils.js +141 -0
  43. package/src/adapters/runtime/codex/model-catalog.js +106 -0
  44. package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -0
  45. package/src/adapters/runtime/codex/rpc-client.js +443 -0
  46. package/src/adapters/runtime/codex/session-store.js +376 -0
  47. package/src/app/channel-send-file-cli.js +57 -0
  48. package/src/app/diary-write-cli.js +620 -0
  49. package/src/app/note-auto-cli.js +201 -0
  50. package/src/app/note-sync-cli.js +130 -0
  51. package/src/app/project-radar-cli.js +165 -0
  52. package/src/app/reminder-write-cli.js +210 -0
  53. package/src/app/review-cli.js +134 -0
  54. package/src/app/system-checkin-poller.js +100 -0
  55. package/src/app/system-send-cli.js +129 -0
  56. package/src/app/timeline-event-cli.js +273 -0
  57. package/src/app/timeline-screenshot-cli.js +109 -0
  58. package/src/core/app.js +1810 -0
  59. package/src/core/branding.js +167 -0
  60. package/src/core/command-registry.js +609 -0
  61. package/src/core/config.js +84 -0
  62. package/src/core/default-targets.js +163 -0
  63. package/src/core/durable-note-schema.js +325 -0
  64. package/src/core/instructions-template.js +31 -0
  65. package/src/core/note-sync.js +433 -0
  66. package/src/core/project-radar.js +402 -0
  67. package/src/core/review-semantic.js +524 -0
  68. package/src/core/review.js +1081 -0
  69. package/src/core/shared-bridge-heartbeat.js +140 -0
  70. package/src/core/stream-delivery.js +990 -0
  71. package/src/core/system-message-dispatcher.js +68 -0
  72. package/src/core/system-message-queue-store.js +128 -0
  73. package/src/core/thread-state-store.js +135 -0
  74. package/src/core/timeline-screenshot-queue-store.js +134 -0
  75. package/src/core/workspace-alias.js +163 -0
  76. package/src/core/workspace-bootstrap.js +338 -0
  77. package/src/index.js +270 -0
  78. package/src/integrations/timeline/index.js +191 -0
  79. package/templates/weixin-instructions.md +53 -0
  80. 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
+ };