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.
Files changed (68) hide show
  1. package/LICENSE +661 -661
  2. package/README.en.md +109 -47
  3. package/README.md +79 -58
  4. package/bin/cyberboss.js +1 -1
  5. package/package.json +86 -86
  6. package/scripts/open_shared_wechat_thread.sh +77 -77
  7. package/scripts/open_wechat_thread.sh +108 -108
  8. package/scripts/shared-common.js +144 -144
  9. package/scripts/shared-open.js +14 -14
  10. package/scripts/shared-start.js +5 -5
  11. package/scripts/shared-status.js +27 -27
  12. package/scripts/show_shared_status.sh +45 -45
  13. package/scripts/start_shared_app_server.sh +52 -52
  14. package/scripts/start_shared_wechat.sh +94 -94
  15. package/scripts/timeline-screenshot.sh +14 -14
  16. package/src/adapters/channel/weixin/account-store.js +99 -99
  17. package/src/adapters/channel/weixin/api-v2.js +50 -50
  18. package/src/adapters/channel/weixin/api.js +169 -169
  19. package/src/adapters/channel/weixin/context-token-store.js +84 -84
  20. package/src/adapters/channel/weixin/index.js +618 -604
  21. package/src/adapters/channel/weixin/legacy.js +579 -566
  22. package/src/adapters/channel/weixin/media-mime.js +22 -22
  23. package/src/adapters/channel/weixin/media-receive.js +370 -370
  24. package/src/adapters/channel/weixin/media-send.js +102 -102
  25. package/src/adapters/channel/weixin/message-utils-v2.js +282 -282
  26. package/src/adapters/channel/weixin/message-utils.js +199 -199
  27. package/src/adapters/channel/weixin/redact.js +41 -41
  28. package/src/adapters/channel/weixin/reminder-queue-store.js +101 -101
  29. package/src/adapters/channel/weixin/sync-buffer-store.js +35 -35
  30. package/src/adapters/runtime/codex/events.js +215 -215
  31. package/src/adapters/runtime/codex/index.js +109 -104
  32. package/src/adapters/runtime/codex/message-utils.js +95 -95
  33. package/src/adapters/runtime/codex/model-catalog.js +106 -106
  34. package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -75
  35. package/src/adapters/runtime/codex/rpc-client.js +339 -339
  36. package/src/adapters/runtime/codex/session-store.js +286 -286
  37. package/src/app/channel-send-file-cli.js +57 -57
  38. package/src/app/diary-write-cli.js +236 -88
  39. package/src/app/note-sync-cli.js +2 -2
  40. package/src/app/reminder-write-cli.js +215 -210
  41. package/src/app/review-cli.js +7 -5
  42. package/src/app/system-checkin-poller.js +64 -64
  43. package/src/app/system-send-cli.js +129 -129
  44. package/src/app/timeline-event-cli.js +28 -25
  45. package/src/app/timeline-screenshot-cli.js +103 -100
  46. package/src/core/app.js +1763 -1763
  47. package/src/core/branding.js +2 -1
  48. package/src/core/command-registry.js +381 -369
  49. package/src/core/config.js +30 -14
  50. package/src/core/default-targets.js +163 -163
  51. package/src/core/durable-note-schema.js +9 -8
  52. package/src/core/instructions-template.js +17 -16
  53. package/src/core/note-sync.js +8 -7
  54. package/src/core/path-utils.js +54 -0
  55. package/src/core/project-radar.js +11 -10
  56. package/src/core/review.js +48 -50
  57. package/src/core/stream-delivery.js +1162 -983
  58. package/src/core/system-message-dispatcher.js +68 -68
  59. package/src/core/system-message-queue-store.js +128 -128
  60. package/src/core/thread-state-store.js +96 -96
  61. package/src/core/timeline-screenshot-queue-store.js +134 -134
  62. package/src/core/timezone.js +436 -0
  63. package/src/core/workspace-bootstrap.js +9 -1
  64. package/src/index.js +148 -146
  65. package/src/integrations/timeline/index.js +130 -74
  66. package/src/integrations/timeline/state-sync.js +240 -0
  67. package/templates/weixin-instructions.md +12 -38
  68. package/templates/weixin-operations.md +29 -31
@@ -1,567 +1,580 @@
1
- const crypto = require("crypto");
2
- const { listWeixinAccounts } = require("./account-store");
3
- const { resolveSelectedAccount } = require("./account-store");
4
- const { loadPersistedContextTokens, persistContextToken } = require("./context-token-store");
5
- const { runLegacyLoginFlow } = require("./login-legacy");
6
- const { getConfig, getUpdates, sendMessage, sendTyping } = require("./api");
7
- const { sendWeixinMediaFile } = require("./media-send");
8
- const { normalizeWeixinIncomingMessage } = require("./message-utils");
9
- const { loadSyncBuffer, saveSyncBuffer } = require("./sync-buffer-store");
10
-
11
- const LONG_POLL_TIMEOUT_MS = 35_000;
12
- const SEND_MESSAGE_CHUNK_INTERVAL_MS = 350;
13
- const WEIXIN_SEND_CHUNK_LIMIT = 80;
14
- const MAX_WEIXIN_CHUNK = 3800;
15
- const WEIXIN_MAX_DELIVERY_MESSAGES = 10;
16
- const SEND_RETRY_DELAYS_MS = [900, 1800];
17
-
18
- function createLegacyWeixinChannelAdapter(config) {
19
- let selectedAccount = null;
20
- let contextTokenCache = null;
21
-
22
- function ensureAccount() {
23
- if (!selectedAccount) {
24
- selectedAccount = resolveSelectedAccount(config);
25
- contextTokenCache = loadPersistedContextTokens(config, selectedAccount.accountId);
26
- }
27
- return selectedAccount;
28
- }
29
-
30
- function ensureContextTokenCache() {
31
- if (!contextTokenCache) {
32
- const account = ensureAccount();
33
- contextTokenCache = loadPersistedContextTokens(config, account.accountId);
34
- }
35
- return contextTokenCache;
36
- }
37
-
38
- function rememberContextToken(userId, contextToken) {
39
- const account = ensureAccount();
40
- const normalizedUserId = typeof userId === "string" ? userId.trim() : "";
41
- const normalizedToken = typeof contextToken === "string" ? contextToken.trim() : "";
42
- if (!normalizedUserId || !normalizedToken) {
43
- return "";
44
- }
45
- contextTokenCache = persistContextToken(config, account.accountId, normalizedUserId, normalizedToken);
46
- return normalizedToken;
47
- }
48
-
49
- function resolveContextToken(userId, explicitToken = "") {
50
- const normalizedExplicitToken = typeof explicitToken === "string" ? explicitToken.trim() : "";
51
- if (normalizedExplicitToken) {
52
- return normalizedExplicitToken;
53
- }
54
- const normalizedUserId = typeof userId === "string" ? userId.trim() : "";
55
- if (!normalizedUserId) {
56
- return "";
57
- }
58
- return ensureContextTokenCache()[normalizedUserId] || "";
59
- }
60
-
61
- return {
62
- describe() {
63
- return {
64
- id: "weixin",
65
- variant: "legacy",
66
- kind: "channel",
67
- stateDir: config.stateDir,
68
- baseUrl: config.weixinBaseUrl,
69
- accountsDir: config.accountsDir,
70
- syncBufferDir: config.syncBufferDir,
71
- };
72
- },
73
- async login() {
74
- await runLegacyLoginFlow(config);
75
- },
76
- printAccounts() {
77
- const accounts = listWeixinAccounts(config);
78
- if (!accounts.length) {
79
- console.log("当前没有已保存的微信账号。先执行 `npm run login`。");
80
- return;
81
- }
82
- console.log("已保存账号:");
83
- for (const account of accounts) {
84
- console.log(`- ${account.accountId}`);
85
- console.log(` userId: ${account.userId || "(unknown)"}`);
86
- console.log(` baseUrl: ${account.baseUrl || config.weixinBaseUrl}`);
87
- if (account.routeTag) {
88
- console.log(` routeTag: ${account.routeTag}`);
89
- }
90
- console.log(` savedAt: ${account.savedAt || "(unknown)"}`);
91
- }
92
- },
93
- resolveAccount() {
94
- return ensureAccount();
95
- },
96
- getKnownContextTokens() {
97
- return { ...ensureContextTokenCache() };
98
- },
99
- loadSyncBuffer() {
100
- const account = ensureAccount();
101
- return loadSyncBuffer(config, account.accountId);
102
- },
103
- saveSyncBuffer(buffer) {
104
- const account = ensureAccount();
105
- saveSyncBuffer(config, account.accountId, buffer);
106
- },
107
- rememberContextToken,
108
- async getUpdates({ syncBuffer = "", timeoutMs = LONG_POLL_TIMEOUT_MS } = {}) {
109
- const account = ensureAccount();
110
- const response = await getUpdates({
111
- baseUrl: account.baseUrl,
112
- token: account.token,
113
- get_updates_buf: syncBuffer,
114
- timeoutMs,
115
- });
116
- if (typeof response?.get_updates_buf === "string" && response.get_updates_buf.trim()) {
117
- this.saveSyncBuffer(response.get_updates_buf.trim());
118
- }
119
- const messages = Array.isArray(response?.msgs) ? response.msgs : [];
120
- for (const message of messages) {
121
- const userId = typeof message?.from_user_id === "string" ? message.from_user_id.trim() : "";
122
- const contextToken = typeof message?.context_token === "string" ? message.context_token.trim() : "";
123
- if (userId && contextToken) {
124
- rememberContextToken(userId, contextToken);
125
- }
126
- }
127
- return response;
128
- },
129
- normalizeIncomingMessage(message) {
130
- const account = ensureAccount();
131
- return normalizeWeixinIncomingMessage(message, config, account.accountId);
132
- },
133
- async sendText({ userId, text, contextToken = "", preserveBlock = false, trace = null }) {
134
- const account = ensureAccount();
135
- const resolvedToken = resolveContextToken(userId, contextToken);
136
- if (!resolvedToken) {
137
- throw new Error(`缺少 context_token,无法回复用户 ${userId}`);
138
- }
139
- const content = String(text || "");
140
- const sendChunks = preserveBlock
141
- ? splitUtf8(compactPlainTextForWeixin(content) || "已完成。", MAX_WEIXIN_CHUNK)
142
- : packChunksForWeixinDelivery(
143
- chunkReplyTextForWeixin(content, WEIXIN_SEND_CHUNK_LIMIT).length
144
- ? chunkReplyTextForWeixin(content, WEIXIN_SEND_CHUNK_LIMIT)
145
- : ["已完成。"],
146
- WEIXIN_MAX_DELIVERY_MESSAGES,
147
- MAX_WEIXIN_CHUNK
148
- );
149
- const traceContext = buildWeixinTraceContext(trace, {
150
- enabled: config.weixinDeliveryTrace,
151
- origin: "adapter.sendText",
152
- variant: "legacy",
153
- preserveBlock,
154
- chunkTotal: sendChunks.length,
155
- });
156
- for (let index = 0; index < sendChunks.length; index += 1) {
157
- const compactChunk = compactPlainTextForWeixin(sendChunks[index]) || "已完成。";
158
- const clientId = crypto.randomUUID();
159
- await sendLegacyTextChunk({
160
- baseUrl: account.baseUrl,
161
- token: account.token,
162
- toUserId: userId,
163
- text: compactChunk,
164
- contextToken: resolvedToken,
165
- clientId,
166
- trace: {
167
- ...traceContext,
168
- chunkIndex: index + 1,
169
- chars: compactChunk.length,
170
- textHash: hashTraceText(compactChunk),
171
- clientId,
172
- },
173
- });
174
- if (index < sendChunks.length - 1) {
175
- await sleep(SEND_MESSAGE_CHUNK_INTERVAL_MS);
176
- }
177
- }
178
- },
179
- async sendTyping({ userId, status = 1, contextToken = "" }) {
180
- const account = ensureAccount();
181
- const resolvedToken = resolveContextToken(userId, contextToken);
182
- if (!resolvedToken) {
183
- return;
184
- }
185
- const configResponse = await getConfig({
186
- baseUrl: account.baseUrl,
187
- token: account.token,
188
- ilinkUserId: userId,
189
- contextToken: resolvedToken,
190
- }).catch(() => null);
191
- const typingTicket = typeof configResponse?.typing_ticket === "string"
192
- ? configResponse.typing_ticket.trim()
193
- : "";
194
- if (!typingTicket) {
195
- return;
196
- }
197
- await sendTyping({
198
- baseUrl: account.baseUrl,
199
- token: account.token,
200
- body: {
201
- ilink_user_id: userId,
202
- typing_ticket: typingTicket,
203
- status,
204
- },
205
- });
206
- },
207
- async sendFile({ userId, filePath, contextToken = "" }) {
208
- const account = ensureAccount();
209
- const resolvedToken = resolveContextToken(userId, contextToken);
210
- if (!resolvedToken) {
211
- throw new Error(`缺少 context_token,无法发送文件给用户 ${userId}`);
212
- }
213
- return sendWeixinMediaFile({
214
- filePath,
215
- to: userId,
216
- contextToken: resolvedToken,
217
- baseUrl: account.baseUrl,
218
- token: account.token,
219
- cdnBaseUrl: config.weixinCdnBaseUrl,
220
- });
221
- },
222
- };
223
- }
224
-
225
- function splitUtf8(text, maxRunes) {
226
- const runes = Array.from(String(text || ""));
227
- if (!runes.length || runes.length <= maxRunes) {
228
- return [String(text || "")];
229
- }
230
- const chunks = [];
231
- while (runes.length) {
232
- chunks.push(runes.splice(0, maxRunes).join(""));
233
- }
234
- return chunks;
235
- }
236
-
237
- function compactPlainTextForWeixin(text) {
238
- const normalized = String(text || "").replace(/\r\n/g, "\n");
239
- return trimOuterBlankLines(normalized.replace(/\n\s*\n+/g, "\n"));
240
- }
241
-
242
- function chunkReplyText(text, limit = 3500) {
243
- const normalized = trimOuterBlankLines(String(text || "").replace(/\r\n/g, "\n"));
244
- if (!normalized.trim()) {
245
- return [];
246
- }
247
-
248
- const chunks = [];
249
- let remaining = normalized;
250
- while (remaining.length > limit) {
251
- const candidate = remaining.slice(0, limit);
252
- const splitIndex = Math.max(
253
- candidate.lastIndexOf("\n\n"),
254
- candidate.lastIndexOf("\n"),
255
- candidate.lastIndexOf(""),
256
- candidate.lastIndexOf(". "),
257
- candidate.lastIndexOf(" ")
258
- );
259
- const cut = splitIndex > limit * 0.4 ? splitIndex + (candidate[splitIndex] === "\n" ? 0 : 1) : limit;
260
- const chunk = trimOuterBlankLines(remaining.slice(0, cut));
261
- if (chunk.trim()) {
262
- chunks.push(chunk);
263
- }
264
- remaining = trimOuterBlankLines(remaining.slice(cut));
265
- }
266
- if (remaining) {
267
- chunks.push(remaining);
268
- }
269
- return chunks.filter(Boolean);
270
- }
271
-
272
- function chunkReplyTextForWeixin(text, limit = 80) {
273
- const normalized = trimOuterBlankLines(String(text || "").replace(/\r\n/g, "\n"));
274
- if (!normalized.trim()) {
275
- return [];
276
- }
277
-
278
- const boundaries = collectStreamingBoundaries(normalized);
279
- if (!boundaries.length) {
280
- return chunkReplyText(normalized, limit);
281
- }
282
-
283
- const units = [];
284
- let start = 0;
285
- for (const boundary of boundaries) {
286
- if (boundary <= start) {
287
- continue;
288
- }
289
- const unit = trimOuterBlankLines(normalized.slice(start, boundary));
290
- if (unit) {
291
- units.push(unit);
292
- }
293
- start = boundary;
294
- }
295
-
296
- const tail = trimOuterBlankLines(normalized.slice(start));
297
- if (tail) {
298
- units.push(tail);
299
- }
300
-
301
- if (!units.length) {
302
- return chunkReplyText(normalized, limit);
303
- }
304
-
305
- const chunks = [];
306
- for (const unit of units) {
307
- if (unit.length <= limit) {
308
- chunks.push(unit);
309
- continue;
310
- }
311
- chunks.push(...chunkReplyText(unit, limit));
312
- }
313
- return chunks.filter(Boolean);
314
- }
315
-
316
- function packChunksForWeixinDelivery(chunks, maxMessages = 10, maxChunkChars = 3800) {
317
- const normalizedChunks = Array.isArray(chunks)
318
- ? chunks.map((chunk) => compactPlainTextForWeixin(chunk)).filter(Boolean)
319
- : [];
320
- if (!normalizedChunks.length || normalizedChunks.length <= maxMessages) {
321
- return normalizedChunks;
322
- }
323
-
324
- const packed = normalizedChunks.slice(0, Math.max(0, maxMessages - 1));
325
- const tailChunks = normalizedChunks.slice(Math.max(0, maxMessages - 1));
326
- if (!tailChunks.length) {
327
- return packed;
328
- }
329
-
330
- const tailText = compactPlainTextForWeixin(tailChunks.join("\n")) || "已完成。";
331
- if (tailText.length <= maxChunkChars) {
332
- packed.push(tailText);
333
- return packed;
334
- }
335
-
336
- const tailHardChunks = splitUtf8(tailText, maxChunkChars);
337
- if (tailHardChunks.length === 1) {
338
- packed.push(tailHardChunks[0]);
339
- return packed;
340
- }
341
-
342
- const preserveCount = Math.max(0, maxMessages - tailHardChunks.length);
343
- const preserved = normalizedChunks.slice(0, preserveCount);
344
- const rebundledTail = normalizedChunks.slice(preserveCount);
345
- const groupedTail = [];
346
- let current = "";
347
- for (const chunk of rebundledTail) {
348
- const joined = current ? `${current}\n${chunk}` : chunk;
349
- if (current && joined.length > maxChunkChars) {
350
- groupedTail.push(current);
351
- current = chunk;
352
- continue;
353
- }
354
- current = joined;
355
- }
356
- if (current) {
357
- groupedTail.push(current);
358
- }
359
-
360
- const normalizedGroupedTail = groupedTail.map((item) => compactPlainTextForWeixin(item) || "已完成。");
361
- if (preserved.length + normalizedGroupedTail.length <= maxMessages) {
362
- return preserved.concat(normalizedGroupedTail);
363
- }
364
-
365
- // Never silently drop the tail of a long reply. If grouping by semantic
366
- // chunk boundaries still overflows the per-message budget, fall back to hard
367
- // UTF-8 splits of the already-joined tail so the full answer is still sent.
368
- return preserved.concat(tailHardChunks.slice(0, Math.max(1, maxMessages - preserved.length)));
369
- }
370
-
371
- function collectStreamingBoundaries(text) {
372
- const boundaries = new Set();
373
-
374
- const regex = /\n\s*\n+/g;
375
- let match = regex.exec(text);
376
- while (match) {
377
- boundaries.add(match.index + match[0].length);
378
- match = regex.exec(text);
379
- }
380
-
381
- const listRegex = /\n(?:(?:[-*])\s+|(?:\d+\.)\s+)/g;
382
- match = listRegex.exec(text);
383
- while (match) {
384
- boundaries.add(match.index + 1);
385
- match = listRegex.exec(text);
386
- }
387
-
388
- for (let index = 0; index < text.length; index += 1) {
389
- const char = text[index];
390
- if (!/[。!?!?]/.test(char)) {
391
- continue;
392
- }
393
-
394
- let end = index + 1;
395
- while (end < text.length && /["'”’))\]」』】]/.test(text[end])) {
396
- end += 1;
397
- }
398
- while (end < text.length && /[\t \n]/.test(text[end])) {
399
- end += 1;
400
- }
401
- boundaries.add(end);
402
- }
403
-
404
- return Array.from(boundaries).sort((left, right) => left - right);
405
- }
406
-
407
- async function sendTextChunkWithRetry(send, { trace = null } = {}) {
408
- let lastError = null;
409
- for (let attempt = 0; attempt <= SEND_RETRY_DELAYS_MS.length; attempt += 1) {
410
- const attemptNumber = attempt + 1;
411
- try {
412
- logWeixinSendTrace("attempt", {
413
- ...buildWeixinTraceContext(trace),
414
- attempt: attemptNumber,
415
- });
416
- const result = await send();
417
- logWeixinSendTrace("success", {
418
- ...buildWeixinTraceContext(trace),
419
- attempt: attemptNumber,
420
- });
421
- return result;
422
- } catch (error) {
423
- lastError = error;
424
- const retryable = isRetryableSendError(error);
425
- logWeixinSendTrace("error", {
426
- ...buildWeixinTraceContext(trace),
427
- attempt: attemptNumber,
428
- retryable,
429
- error: String(error?.message || error || ""),
430
- });
431
- if (!retryable || attempt >= SEND_RETRY_DELAYS_MS.length) {
432
- throw error;
433
- }
434
- await sleep(SEND_RETRY_DELAYS_MS[attempt]);
435
- }
436
- }
437
- throw lastError || new Error("sendText chunk failed");
438
- }
439
-
440
- function sendLegacyTextChunk({
441
- sendMessageImpl = sendMessage,
442
- baseUrl,
443
- token,
444
- toUserId,
445
- text,
446
- contextToken,
447
- clientId = "",
448
- trace = null,
449
- }) {
450
- const stableClientId = String(clientId || "").trim() || crypto.randomUUID();
451
- return sendTextChunkWithRetry(
452
- () => sendMessageImpl({
453
- baseUrl,
454
- token,
455
- body: {
456
- msg: {
457
- client_id: stableClientId,
458
- from_user_id: "",
459
- to_user_id: toUserId,
460
- message_type: 2,
461
- message_state: 2,
462
- item_list: [
463
- {
464
- type: 1,
465
- text_item: { text: String(text || "") },
466
- },
467
- ],
468
- context_token: contextToken,
469
- },
470
- },
471
- }),
472
- {
473
- trace: buildWeixinTraceContext(trace, {
474
- variant: "legacy",
475
- clientId: stableClientId,
476
- chars: String(text || "").length,
477
- textHash: hashTraceText(text),
478
- }),
479
- }
480
- );
481
- }
482
-
483
- function isRetryableSendError(error) {
484
- const message = String(error?.message || error || "");
485
- // Legacy sendmessage shows the same live ambiguity as v2: `ret=-2` can mean
486
- // "client saw an error even though WeChat already accepted the message".
487
- // Do not auto-retry it, or one failed bridge turn can fan out into duplicate
488
- // bubbles on the user side.
489
- return message.includes("AbortError")
490
- || message.includes("aborted")
491
- || message.includes("fetch failed")
492
- || message.includes("ECONNRESET")
493
- || message.includes("ETIMEDOUT")
494
- || /http 5\d\d/.test(message);
495
- }
496
-
497
- function buildWeixinTraceContext(trace, defaults = {}) {
498
- const normalizedTrace = normalizeTraceContext(trace);
499
- return {
500
- ...defaults,
501
- ...normalizedTrace,
502
- enabled: Boolean(normalizedTrace.enabled ?? defaults.enabled),
503
- traceId: normalizeTraceText(normalizedTrace.traceId)
504
- || normalizeTraceText(defaults.traceId)
505
- || `wx-${crypto.randomUUID().slice(0, 8)}`,
506
- };
507
- }
508
-
509
- function normalizeTraceContext(trace) {
510
- if (!trace || typeof trace !== "object") {
511
- return {};
512
- }
513
- return { ...trace };
514
- }
515
-
516
- function normalizeTraceText(value) {
517
- return typeof value === "string" ? value.trim() : "";
518
- }
519
-
520
- function logWeixinSendTrace(stage, trace) {
521
- if (!Boolean(trace?.enabled)) {
522
- return;
523
- }
524
- const parts = [
1
+ const crypto = require("crypto");
2
+ const { listWeixinAccounts } = require("./account-store");
3
+ const { resolveSelectedAccount } = require("./account-store");
4
+ const { loadPersistedContextTokens, persistContextToken } = require("./context-token-store");
5
+ const { runLegacyLoginFlow } = require("./login-legacy");
6
+ const { getConfig, getUpdates, sendMessage, sendTyping } = require("./api");
7
+ const { sendWeixinMediaFile } = require("./media-send");
8
+ const { normalizeWeixinIncomingMessage } = require("./message-utils");
9
+ const { loadSyncBuffer, saveSyncBuffer } = require("./sync-buffer-store");
10
+
11
+ const LONG_POLL_TIMEOUT_MS = 35_000;
12
+ const SEND_MESSAGE_CHUNK_INTERVAL_MS = 350;
13
+ const WEIXIN_SEND_CHUNK_LIMIT = 80;
14
+ const MAX_WEIXIN_CHUNK = 3800;
15
+ const WEIXIN_MAX_DELIVERY_MESSAGES = 10;
16
+ const SEND_RETRY_DELAYS_MS = [900, 1800];
17
+ const AMBIGUOUS_SEND_RETRY_DELAYS_MS = [1200];
18
+
19
+ function createLegacyWeixinChannelAdapter(config) {
20
+ let selectedAccount = null;
21
+ let contextTokenCache = null;
22
+
23
+ function ensureAccount() {
24
+ if (!selectedAccount) {
25
+ selectedAccount = resolveSelectedAccount(config);
26
+ contextTokenCache = loadPersistedContextTokens(config, selectedAccount.accountId);
27
+ }
28
+ return selectedAccount;
29
+ }
30
+
31
+ function ensureContextTokenCache() {
32
+ if (!contextTokenCache) {
33
+ const account = ensureAccount();
34
+ contextTokenCache = loadPersistedContextTokens(config, account.accountId);
35
+ }
36
+ return contextTokenCache;
37
+ }
38
+
39
+ function rememberContextToken(userId, contextToken) {
40
+ const account = ensureAccount();
41
+ const normalizedUserId = typeof userId === "string" ? userId.trim() : "";
42
+ const normalizedToken = typeof contextToken === "string" ? contextToken.trim() : "";
43
+ if (!normalizedUserId || !normalizedToken) {
44
+ return "";
45
+ }
46
+ contextTokenCache = persistContextToken(config, account.accountId, normalizedUserId, normalizedToken);
47
+ return normalizedToken;
48
+ }
49
+
50
+ function resolveContextToken(userId, explicitToken = "") {
51
+ const normalizedExplicitToken = typeof explicitToken === "string" ? explicitToken.trim() : "";
52
+ if (normalizedExplicitToken) {
53
+ return normalizedExplicitToken;
54
+ }
55
+ const normalizedUserId = typeof userId === "string" ? userId.trim() : "";
56
+ if (!normalizedUserId) {
57
+ return "";
58
+ }
59
+ return ensureContextTokenCache()[normalizedUserId] || "";
60
+ }
61
+
62
+ return {
63
+ describe() {
64
+ return {
65
+ id: "weixin",
66
+ variant: "legacy",
67
+ kind: "channel",
68
+ stateDir: config.stateDir,
69
+ baseUrl: config.weixinBaseUrl,
70
+ accountsDir: config.accountsDir,
71
+ syncBufferDir: config.syncBufferDir,
72
+ };
73
+ },
74
+ async login() {
75
+ await runLegacyLoginFlow(config);
76
+ },
77
+ printAccounts() {
78
+ const accounts = listWeixinAccounts(config);
79
+ if (!accounts.length) {
80
+ console.log("当前没有已保存的微信账号。先执行 `npm run login`。");
81
+ return;
82
+ }
83
+ console.log("已保存账号:");
84
+ for (const account of accounts) {
85
+ console.log(`- ${account.accountId}`);
86
+ console.log(` userId: ${account.userId || "(unknown)"}`);
87
+ console.log(` baseUrl: ${account.baseUrl || config.weixinBaseUrl}`);
88
+ if (account.routeTag) {
89
+ console.log(` routeTag: ${account.routeTag}`);
90
+ }
91
+ console.log(` savedAt: ${account.savedAt || "(unknown)"}`);
92
+ }
93
+ },
94
+ resolveAccount() {
95
+ return ensureAccount();
96
+ },
97
+ getKnownContextTokens() {
98
+ return { ...ensureContextTokenCache() };
99
+ },
100
+ loadSyncBuffer() {
101
+ const account = ensureAccount();
102
+ return loadSyncBuffer(config, account.accountId);
103
+ },
104
+ saveSyncBuffer(buffer) {
105
+ const account = ensureAccount();
106
+ saveSyncBuffer(config, account.accountId, buffer);
107
+ },
108
+ rememberContextToken,
109
+ async getUpdates({ syncBuffer = "", timeoutMs = LONG_POLL_TIMEOUT_MS } = {}) {
110
+ const account = ensureAccount();
111
+ const response = await getUpdates({
112
+ baseUrl: account.baseUrl,
113
+ token: account.token,
114
+ get_updates_buf: syncBuffer,
115
+ timeoutMs,
116
+ });
117
+ if (typeof response?.get_updates_buf === "string" && response.get_updates_buf.trim()) {
118
+ this.saveSyncBuffer(response.get_updates_buf.trim());
119
+ }
120
+ const messages = Array.isArray(response?.msgs) ? response.msgs : [];
121
+ for (const message of messages) {
122
+ const userId = typeof message?.from_user_id === "string" ? message.from_user_id.trim() : "";
123
+ const contextToken = typeof message?.context_token === "string" ? message.context_token.trim() : "";
124
+ if (userId && contextToken) {
125
+ rememberContextToken(userId, contextToken);
126
+ }
127
+ }
128
+ return response;
129
+ },
130
+ normalizeIncomingMessage(message) {
131
+ const account = ensureAccount();
132
+ return normalizeWeixinIncomingMessage(message, config, account.accountId);
133
+ },
134
+ async sendText({ userId, text, contextToken = "", preserveBlock = false, trace = null }) {
135
+ const account = ensureAccount();
136
+ const resolvedToken = resolveContextToken(userId, contextToken);
137
+ if (!resolvedToken) {
138
+ throw new Error(`缺少 context_token,无法回复用户 ${userId}`);
139
+ }
140
+ const content = String(text || "");
141
+ const sendChunks = preserveBlock
142
+ ? splitUtf8(compactPlainTextForWeixin(content) || "已完成。", MAX_WEIXIN_CHUNK)
143
+ : packChunksForWeixinDelivery(
144
+ chunkReplyTextForWeixin(content, WEIXIN_SEND_CHUNK_LIMIT).length
145
+ ? chunkReplyTextForWeixin(content, WEIXIN_SEND_CHUNK_LIMIT)
146
+ : ["已完成。"],
147
+ WEIXIN_MAX_DELIVERY_MESSAGES,
148
+ MAX_WEIXIN_CHUNK
149
+ );
150
+ const traceContext = buildWeixinTraceContext(trace, {
151
+ enabled: config.weixinDeliveryTrace,
152
+ origin: "adapter.sendText",
153
+ variant: "legacy",
154
+ preserveBlock,
155
+ chunkTotal: sendChunks.length,
156
+ });
157
+ for (let index = 0; index < sendChunks.length; index += 1) {
158
+ const compactChunk = compactPlainTextForWeixin(sendChunks[index]) || "已完成。";
159
+ const clientId = crypto.randomUUID();
160
+ await sendLegacyTextChunk({
161
+ baseUrl: account.baseUrl,
162
+ token: account.token,
163
+ toUserId: userId,
164
+ text: compactChunk,
165
+ contextToken: resolvedToken,
166
+ clientId,
167
+ trace: {
168
+ ...traceContext,
169
+ chunkIndex: index + 1,
170
+ chars: compactChunk.length,
171
+ textHash: hashTraceText(compactChunk),
172
+ clientId,
173
+ },
174
+ });
175
+ if (index < sendChunks.length - 1) {
176
+ await sleep(SEND_MESSAGE_CHUNK_INTERVAL_MS);
177
+ }
178
+ }
179
+ },
180
+ async sendTyping({ userId, status = 1, contextToken = "" }) {
181
+ const account = ensureAccount();
182
+ const resolvedToken = resolveContextToken(userId, contextToken);
183
+ if (!resolvedToken) {
184
+ return;
185
+ }
186
+ const configResponse = await getConfig({
187
+ baseUrl: account.baseUrl,
188
+ token: account.token,
189
+ ilinkUserId: userId,
190
+ contextToken: resolvedToken,
191
+ }).catch(() => null);
192
+ const typingTicket = typeof configResponse?.typing_ticket === "string"
193
+ ? configResponse.typing_ticket.trim()
194
+ : "";
195
+ if (!typingTicket) {
196
+ return;
197
+ }
198
+ await sendTyping({
199
+ baseUrl: account.baseUrl,
200
+ token: account.token,
201
+ body: {
202
+ ilink_user_id: userId,
203
+ typing_ticket: typingTicket,
204
+ status,
205
+ },
206
+ });
207
+ },
208
+ async sendFile({ userId, filePath, contextToken = "" }) {
209
+ const account = ensureAccount();
210
+ const resolvedToken = resolveContextToken(userId, contextToken);
211
+ if (!resolvedToken) {
212
+ throw new Error(`缺少 context_token,无法发送文件给用户 ${userId}`);
213
+ }
214
+ return sendWeixinMediaFile({
215
+ filePath,
216
+ to: userId,
217
+ contextToken: resolvedToken,
218
+ baseUrl: account.baseUrl,
219
+ token: account.token,
220
+ cdnBaseUrl: config.weixinCdnBaseUrl,
221
+ });
222
+ },
223
+ };
224
+ }
225
+
226
+ function splitUtf8(text, maxRunes) {
227
+ const runes = Array.from(String(text || ""));
228
+ if (!runes.length || runes.length <= maxRunes) {
229
+ return [String(text || "")];
230
+ }
231
+ const chunks = [];
232
+ while (runes.length) {
233
+ chunks.push(runes.splice(0, maxRunes).join(""));
234
+ }
235
+ return chunks;
236
+ }
237
+
238
+ function compactPlainTextForWeixin(text) {
239
+ const normalized = String(text || "").replace(/\r\n/g, "\n");
240
+ return trimOuterBlankLines(normalized.replace(/\n\s*\n+/g, "\n"));
241
+ }
242
+
243
+ function chunkReplyText(text, limit = 3500) {
244
+ const normalized = trimOuterBlankLines(String(text || "").replace(/\r\n/g, "\n"));
245
+ if (!normalized.trim()) {
246
+ return [];
247
+ }
248
+
249
+ const chunks = [];
250
+ let remaining = normalized;
251
+ while (remaining.length > limit) {
252
+ const candidate = remaining.slice(0, limit);
253
+ const splitIndex = Math.max(
254
+ candidate.lastIndexOf("\n\n"),
255
+ candidate.lastIndexOf("\n"),
256
+ candidate.lastIndexOf(""),
257
+ candidate.lastIndexOf(". "),
258
+ candidate.lastIndexOf(" ")
259
+ );
260
+ const cut = splitIndex > limit * 0.4 ? splitIndex + (candidate[splitIndex] === "\n" ? 0 : 1) : limit;
261
+ const chunk = trimOuterBlankLines(remaining.slice(0, cut));
262
+ if (chunk.trim()) {
263
+ chunks.push(chunk);
264
+ }
265
+ remaining = trimOuterBlankLines(remaining.slice(cut));
266
+ }
267
+ if (remaining) {
268
+ chunks.push(remaining);
269
+ }
270
+ return chunks.filter(Boolean);
271
+ }
272
+
273
+ function chunkReplyTextForWeixin(text, limit = 80) {
274
+ const normalized = trimOuterBlankLines(String(text || "").replace(/\r\n/g, "\n"));
275
+ if (!normalized.trim()) {
276
+ return [];
277
+ }
278
+
279
+ const boundaries = collectStreamingBoundaries(normalized);
280
+ if (!boundaries.length) {
281
+ return chunkReplyText(normalized, limit);
282
+ }
283
+
284
+ const units = [];
285
+ let start = 0;
286
+ for (const boundary of boundaries) {
287
+ if (boundary <= start) {
288
+ continue;
289
+ }
290
+ const unit = trimOuterBlankLines(normalized.slice(start, boundary));
291
+ if (unit) {
292
+ units.push(unit);
293
+ }
294
+ start = boundary;
295
+ }
296
+
297
+ const tail = trimOuterBlankLines(normalized.slice(start));
298
+ if (tail) {
299
+ units.push(tail);
300
+ }
301
+
302
+ if (!units.length) {
303
+ return chunkReplyText(normalized, limit);
304
+ }
305
+
306
+ const chunks = [];
307
+ for (const unit of units) {
308
+ if (unit.length <= limit) {
309
+ chunks.push(unit);
310
+ continue;
311
+ }
312
+ chunks.push(...chunkReplyText(unit, limit));
313
+ }
314
+ return chunks.filter(Boolean);
315
+ }
316
+
317
+ function packChunksForWeixinDelivery(chunks, maxMessages = 10, maxChunkChars = 3800) {
318
+ const normalizedChunks = Array.isArray(chunks)
319
+ ? chunks.map((chunk) => compactPlainTextForWeixin(chunk)).filter(Boolean)
320
+ : [];
321
+ if (!normalizedChunks.length) {
322
+ return normalizedChunks;
323
+ }
324
+
325
+ const groupedChunks = groupChunksWithinBudget(normalizedChunks, maxChunkChars);
326
+ if (groupedChunks.length <= maxMessages) {
327
+ return groupedChunks;
328
+ }
329
+
330
+ const fullText = compactPlainTextForWeixin(normalizedChunks.join("\n")) || "已完成。";
331
+ const hardChunks = splitUtf8(fullText, maxChunkChars);
332
+ if (hardChunks.length <= maxMessages) {
333
+ return hardChunks;
334
+ }
335
+
336
+ // `maxMessages` is only a spam guard. If the full reply still needs more
337
+ // chunks at the hard per-message budget, prefer complete delivery over
338
+ // silently dropping the tail.
339
+ return hardChunks;
340
+ }
341
+
342
+ function groupChunksWithinBudget(chunks, maxChunkChars) {
343
+ const grouped = [];
344
+ let current = "";
345
+
346
+ for (const rawChunk of Array.isArray(chunks) ? chunks : []) {
347
+ const normalizedChunk = compactPlainTextForWeixin(rawChunk);
348
+ if (!normalizedChunk) {
349
+ continue;
350
+ }
351
+
352
+ const units = normalizedChunk.length > maxChunkChars
353
+ ? splitUtf8(normalizedChunk, maxChunkChars)
354
+ : [normalizedChunk];
355
+
356
+ for (const unit of units) {
357
+ if (!current) {
358
+ current = unit;
359
+ continue;
360
+ }
361
+ const joined = `${current}\n${unit}`;
362
+ if (joined.length > maxChunkChars) {
363
+ grouped.push(current);
364
+ current = unit;
365
+ continue;
366
+ }
367
+ current = joined;
368
+ }
369
+ }
370
+
371
+ if (current) {
372
+ grouped.push(current);
373
+ }
374
+ return grouped;
375
+ }
376
+
377
+ function collectStreamingBoundaries(text) {
378
+ const boundaries = new Set();
379
+
380
+ const regex = /\n\s*\n+/g;
381
+ let match = regex.exec(text);
382
+ while (match) {
383
+ boundaries.add(match.index + match[0].length);
384
+ match = regex.exec(text);
385
+ }
386
+
387
+ const listRegex = /\n(?:(?:[-*])\s+|(?:\d+\.)\s+)/g;
388
+ match = listRegex.exec(text);
389
+ while (match) {
390
+ boundaries.add(match.index + 1);
391
+ match = listRegex.exec(text);
392
+ }
393
+
394
+ for (let index = 0; index < text.length; index += 1) {
395
+ const char = text[index];
396
+ if (!/[。!?!?]/.test(char)) {
397
+ continue;
398
+ }
399
+
400
+ let end = index + 1;
401
+ while (end < text.length && /["'”’))\]」』】]/.test(text[end])) {
402
+ end += 1;
403
+ }
404
+ while (end < text.length && /[\t \n]/.test(text[end])) {
405
+ end += 1;
406
+ }
407
+ boundaries.add(end);
408
+ }
409
+
410
+ return Array.from(boundaries).sort((left, right) => left - right);
411
+ }
412
+
413
+ async function sendTextChunkWithRetry(send, { trace = null } = {}) {
414
+ let lastError = null;
415
+ for (let attempt = 0; ; attempt += 1) {
416
+ const attemptNumber = attempt + 1;
417
+ try {
418
+ logWeixinSendTrace("attempt", {
419
+ ...buildWeixinTraceContext(trace),
420
+ attempt: attemptNumber,
421
+ });
422
+ const result = await send();
423
+ logWeixinSendTrace("success", {
424
+ ...buildWeixinTraceContext(trace),
425
+ attempt: attemptNumber,
426
+ });
427
+ return result;
428
+ } catch (error) {
429
+ lastError = error;
430
+ const retryDelays = getSendRetryDelaysMs(error);
431
+ const retryable = attempt < retryDelays.length;
432
+ logWeixinSendTrace("error", {
433
+ ...buildWeixinTraceContext(trace),
434
+ attempt: attemptNumber,
435
+ retryable,
436
+ error: String(error?.message || error || ""),
437
+ });
438
+ if (!retryable) {
439
+ throw error;
440
+ }
441
+ await sleep(retryDelays[attempt]);
442
+ }
443
+ }
444
+ throw lastError || new Error("sendText chunk failed");
445
+ }
446
+
447
+ function sendLegacyTextChunk({
448
+ sendMessageImpl = sendMessage,
449
+ baseUrl,
450
+ token,
451
+ toUserId,
452
+ text,
453
+ contextToken,
454
+ clientId = "",
455
+ trace = null,
456
+ }) {
457
+ const stableClientId = String(clientId || "").trim() || crypto.randomUUID();
458
+ return sendTextChunkWithRetry(
459
+ () => sendMessageImpl({
460
+ baseUrl,
461
+ token,
462
+ body: {
463
+ msg: {
464
+ client_id: stableClientId,
465
+ from_user_id: "",
466
+ to_user_id: toUserId,
467
+ message_type: 2,
468
+ message_state: 2,
469
+ item_list: [
470
+ {
471
+ type: 1,
472
+ text_item: { text: String(text || "") },
473
+ },
474
+ ],
475
+ context_token: contextToken,
476
+ },
477
+ },
478
+ }),
479
+ {
480
+ trace: buildWeixinTraceContext(trace, {
481
+ variant: "legacy",
482
+ clientId: stableClientId,
483
+ chars: String(text || "").length,
484
+ textHash: hashTraceText(text),
485
+ }),
486
+ }
487
+ );
488
+ }
489
+
490
+ function getSendRetryDelaysMs(error) {
491
+ const message = String(error?.message || error || "");
492
+ // Legacy sendmessage sees the same `ret=-2` ambiguity as v2. Retry it once
493
+ // with the same client_id so we bias toward complete delivery without
494
+ // turning one flaky send into a burst of duplicate assistant bubbles.
495
+ if (message.includes("ret=-2")) {
496
+ return AMBIGUOUS_SEND_RETRY_DELAYS_MS;
497
+ }
498
+ if (message.includes("AbortError")
499
+ || message.includes("aborted")
500
+ || message.includes("fetch failed")
501
+ || message.includes("ECONNRESET")
502
+ || message.includes("ETIMEDOUT")
503
+ || /http 5\d\d/.test(message)) {
504
+ return SEND_RETRY_DELAYS_MS;
505
+ }
506
+ return [];
507
+ }
508
+
509
+ function buildWeixinTraceContext(trace, defaults = {}) {
510
+ const normalizedTrace = normalizeTraceContext(trace);
511
+ return {
512
+ ...defaults,
513
+ ...normalizedTrace,
514
+ enabled: Boolean(normalizedTrace.enabled ?? defaults.enabled),
515
+ traceId: normalizeTraceText(normalizedTrace.traceId)
516
+ || normalizeTraceText(defaults.traceId)
517
+ || `wx-${crypto.randomUUID().slice(0, 8)}`,
518
+ };
519
+ }
520
+
521
+ function normalizeTraceContext(trace) {
522
+ if (!trace || typeof trace !== "object") {
523
+ return {};
524
+ }
525
+ return { ...trace };
526
+ }
527
+
528
+ function normalizeTraceText(value) {
529
+ return typeof value === "string" ? value.trim() : "";
530
+ }
531
+
532
+ function logWeixinSendTrace(stage, trace) {
533
+ if (!Boolean(trace?.enabled)) {
534
+ return;
535
+ }
536
+ const parts = [
525
537
  `[codeksei] weixin send trace stage=${stage}`,
526
- `pid=${process.pid}`,
527
- `trace=${trace.traceId || "(none)"}`,
528
- `origin=${trace.origin || "adapter.sendText"}`,
529
- `variant=${trace.variant || "legacy"}`,
530
- trace.threadId ? `thread=${trace.threadId}` : "",
531
- `turn=${trace.turnId || "(pending)"}`,
532
- trace.mode ? `mode=${trace.mode}` : "",
533
- trace.trigger ? `trigger=${trace.trigger}` : "",
534
- `chunk=${trace.chunkIndex || 1}/${trace.chunkTotal || 1}`,
535
- `preserveBlock=${trace.preserveBlock ? "1" : "0"}`,
536
- `attempt=${trace.attempt || 1}`,
537
- trace.retryable === undefined ? "" : `retryable=${trace.retryable ? "1" : "0"}`,
538
- `clientId=${trace.clientId || "(none)"}`,
539
- `chars=${trace.chars || 0}`,
540
- `hash=${trace.textHash || hashTraceText("")}`,
541
- ].filter(Boolean);
542
- if (trace.error) {
543
- parts.push(`error=${JSON.stringify(String(trace.error || ""))}`);
544
- console.error(parts.join(" "));
545
- return;
546
- }
547
- console.log(parts.join(" "));
548
- }
549
-
550
- function hashTraceText(text) {
551
- return crypto.createHash("sha1").update(String(text || ""), "utf8").digest("hex").slice(0, 12);
552
- }
553
-
554
- function trimOuterBlankLines(text) {
555
- return String(text || "")
556
- .replace(/^\s*\n+/g, "")
557
- .replace(/\n+\s*$/g, "");
558
- }
559
-
560
- function sleep(ms) {
561
- return new Promise((resolve) => setTimeout(resolve, ms));
562
- }
563
-
564
- module.exports = {
565
- createLegacyWeixinChannelAdapter,
566
- sendLegacyTextChunk,
567
- };
538
+ `pid=${process.pid}`,
539
+ `trace=${trace.traceId || "(none)"}`,
540
+ `origin=${trace.origin || "adapter.sendText"}`,
541
+ `variant=${trace.variant || "legacy"}`,
542
+ trace.threadId ? `thread=${trace.threadId}` : "",
543
+ `turn=${trace.turnId || "(pending)"}`,
544
+ trace.mode ? `mode=${trace.mode}` : "",
545
+ trace.trigger ? `trigger=${trace.trigger}` : "",
546
+ `chunk=${trace.chunkIndex || 1}/${trace.chunkTotal || 1}`,
547
+ `preserveBlock=${trace.preserveBlock ? "1" : "0"}`,
548
+ `attempt=${trace.attempt || 1}`,
549
+ trace.retryable === undefined ? "" : `retryable=${trace.retryable ? "1" : "0"}`,
550
+ `clientId=${trace.clientId || "(none)"}`,
551
+ `chars=${trace.chars || 0}`,
552
+ `hash=${trace.textHash || hashTraceText("")}`,
553
+ ].filter(Boolean);
554
+ if (trace.error) {
555
+ parts.push(`error=${JSON.stringify(String(trace.error || ""))}`);
556
+ console.error(parts.join(" "));
557
+ return;
558
+ }
559
+ console.log(parts.join(" "));
560
+ }
561
+
562
+ function hashTraceText(text) {
563
+ return crypto.createHash("sha1").update(String(text || ""), "utf8").digest("hex").slice(0, 12);
564
+ }
565
+
566
+ function trimOuterBlankLines(text) {
567
+ return String(text || "")
568
+ .replace(/^\s*\n+/g, "")
569
+ .replace(/\n+\s*$/g, "");
570
+ }
571
+
572
+ function sleep(ms) {
573
+ return new Promise((resolve) => setTimeout(resolve, ms));
574
+ }
575
+
576
+ module.exports = {
577
+ createLegacyWeixinChannelAdapter,
578
+ packChunksForWeixinDelivery,
579
+ sendLegacyTextChunk,
580
+ };