ocean-crew 0.0.0-stage → 0.4.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -2
  3. package/THIRD_PARTY_NOTICES.md +159 -0
  4. package/client.js +31389 -0
  5. package/cordis.patch.yml +6 -0
  6. package/dist/host/chat/btw.js +436 -0
  7. package/dist/host/chat/channel.js +1241 -0
  8. package/dist/host/chat/chat-index.js +344 -0
  9. package/dist/host/chat/delivery.js +231 -0
  10. package/dist/host/chat/feed.js +165 -0
  11. package/dist/host/chat/handoffs.js +625 -0
  12. package/dist/host/chat/parts.js +2033 -0
  13. package/dist/host/chat/requests.js +155 -0
  14. package/dist/host/chat/resume.js +364 -0
  15. package/dist/host/chat/side.js +457 -0
  16. package/dist/host/chat/steer.js +346 -0
  17. package/dist/host/chat/turns.js +1457 -0
  18. package/dist/host/connectors/catalog.js +331 -0
  19. package/dist/host/connectors/gateway.js +339 -0
  20. package/dist/host/connectors/index.js +1235 -0
  21. package/dist/host/connectors/mail/index.js +912 -0
  22. package/dist/host/connectors/mail/presets.js +53 -0
  23. package/dist/host/connectors/mail/transport.js +519 -0
  24. package/dist/host/connectors/oauth.js +337 -0
  25. package/dist/host/core/constants.js +123 -0
  26. package/dist/host/core/diagnostics.js +310 -0
  27. package/dist/host/core/dsh-update.js +212 -0
  28. package/dist/host/core/events.js +16 -0
  29. package/dist/host/core/files.js +37 -0
  30. package/dist/host/core/folders.js +283 -0
  31. package/dist/host/core/home-lock.js +137 -0
  32. package/dist/host/core/links.js +52 -0
  33. package/dist/host/core/runtime.js +5 -0
  34. package/dist/host/core/store.js +662 -0
  35. package/dist/host/core/text.js +59 -0
  36. package/dist/host/core/thinking.js +60 -0
  37. package/dist/host/core/time-context.js +69 -0
  38. package/dist/host/core/update.js +466 -0
  39. package/dist/host/groups/calls.js +684 -0
  40. package/dist/host/groups/index.js +79 -0
  41. package/dist/host/groups/log.js +334 -0
  42. package/dist/host/groups/prompt.js +124 -0
  43. package/dist/host/groups/relay.js +489 -0
  44. package/dist/host/groups/round.js +992 -0
  45. package/dist/host/groups/tasks.js +217 -0
  46. package/dist/host/groups/tools.js +478 -0
  47. package/dist/host/groups/types.js +8 -0
  48. package/dist/host/im/format.js +228 -0
  49. package/dist/host/im/index.js +2461 -0
  50. package/dist/host/im/platforms/feishu.js +1134 -0
  51. package/dist/host/im/platforms/index.js +6 -0
  52. package/dist/host/im/platforms/media-type.js +70 -0
  53. package/dist/host/im/platforms/weixin.js +1117 -0
  54. package/dist/host/im/reaction.js +51 -0
  55. package/dist/host/im/supervisor.js +56 -0
  56. package/dist/host/team/agents.js +161 -0
  57. package/dist/host/team/models.js +427 -0
  58. package/dist/host/team/permissions.js +263 -0
  59. package/dist/host/team/privacy.js +108 -0
  60. package/dist/host/team/prompt.js +86 -0
  61. package/dist/host/team/providers.js +461 -0
  62. package/dist/host/team/roster.js +1011 -0
  63. package/dist/host/team/tools.js +1638 -0
  64. package/dist/host/work/approvals.js +290 -0
  65. package/dist/host/work/avatar-library.js +155 -0
  66. package/dist/host/work/avatar.js +132 -0
  67. package/dist/host/work/capabilities.js +862 -0
  68. package/dist/host/work/companion.js +306 -0
  69. package/dist/host/work/goal-skills.js +91 -0
  70. package/dist/host/work/goals.js +1337 -0
  71. package/dist/host/work/images.js +283 -0
  72. package/dist/host/work/learning.js +1021 -0
  73. package/dist/host/work/memory-pending.js +108 -0
  74. package/dist/host/work/memory.js +3237 -0
  75. package/dist/host/work/notify.js +146 -0
  76. package/dist/host/work/reset.js +43 -0
  77. package/dist/host/work/schedules.js +309 -0
  78. package/dist/host/work/secrets.js +702 -0
  79. package/dist/host/work/steward.js +166 -0
  80. package/dist/host/work/tts.js +192 -0
  81. package/dist/host/work/usage.js +84 -0
  82. package/dist/index.js +137 -0
  83. package/dist/shared/api.js +32 -0
  84. package/dist/shared/companion.js +110 -0
  85. package/dist/shared/config.js +3 -0
  86. package/dist/shared/coordination.js +6 -0
  87. package/dist/shared/group-replies.js +95 -0
  88. package/dist/shared/im-platforms.js +35 -0
  89. package/dist/shared/im.js +3 -0
  90. package/dist/shared/mentions.js +85 -0
  91. package/dist/shared/state.js +25 -0
  92. package/package.json +81 -5
@@ -0,0 +1,1117 @@
1
+ // WeChat iLink: a QR login creates the connection — get_bot_qrcode, then a
2
+ // get_qrcode_status long poll until the owner confirms on their phone; the
3
+ // reply carries bot_token, ilink_bot_id, ilink_user_id and the base URL. From
4
+ // then msg/notifystart opens the session and a getupdates long poll delivers
5
+ // messages (the get_updates_buf cursor, the seen message ids and the latest
6
+ // context_token per user live in the connection runtime so they survive
7
+ // restarts); sends go out through sendmessage with that user's context_token,
8
+ // ~1800-char text chunks. Typing uses the getconfig ticket and that
9
+ // context_token. Voice items arrive with a transcript; images download from
10
+ // the CDN and decrypt with their AES key. A platform refusal is permanent and
11
+ // a send whose outcome is unknown is never retried blindly.
12
+ // from dsh-im (MIT, see NOTICE).
13
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
14
+ import { imageMediaTypeOf, mp3Timing } from "./media-type.js";
15
+ const QR_BASE = "https://ilinkai.weixin.qq.com/";
16
+ const BOT_TYPE = "3";
17
+ const TEXT_LIMIT = 1800;
18
+ // The pause between two bubbles of one reply.
19
+ const BUBBLE_GAP_MS = 450;
20
+ const POLL_TIMEOUT_MS = 35_000;
21
+ const POLL_GRACE_MS = 15_000;
22
+ const ILINK_APP_ID = "bot";
23
+ // The iLink client version, packed as 0x00MMNNPP: 2.4.6 is what the reference
24
+ // client (dsh-im 5.10.0, the source of this port) identifies itself as.
25
+ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
26
+ // The server answers this on getupdates when the bot session is not open; the
27
+ // account has to scan a new QR code.
28
+ const SESSION_EXPIRED = -14;
29
+ // Waits between attempts to open the session, from the reference client.
30
+ const START_RETRY_DELAYS_MS = [250, 1_000, 3_000];
31
+ // A chunk keeps one client_id across attempts. Only a fetch that never got an
32
+ // HTTP answer at all is retried: any other outcome may already have reached
33
+ // the server, so it is reported as unknown instead of being sent again.
34
+ const SEND_RETRY_DELAYS_MS = [400, 1_200];
35
+ const SEND_TIMEOUT_MS = 15_000;
36
+ // Poll failures: min(2000 * 2^(n-1), 10000) ms, and three in a row stop the
37
+ // connection with an error instead of retrying the same failure forever.
38
+ const BACKOFF_BASE_MS = 2_000;
39
+ const BACKOFF_MAX_MS = 10_000;
40
+ const MAX_CONSECUTIVE_FAILURES = 3;
41
+ const SEEN_LIMIT = 1_000;
42
+ const CONTEXT_TOKEN_LIMIT = 1_000;
43
+ const LOGIN_LIFETIME_MS = 5 * 60_000;
44
+ // iLink answers with the bare host (no trailing slash), and the request URL is
45
+ // built by appending a path: normalize to exactly one slash either way.
46
+ const withSlash = (value) => `${String(value).replace(/\/+$/, "")}/`;
47
+ // A server-supplied host (QR redirect, confirmed base URL) may come without a
48
+ // scheme; the connection URL is built by appending a path to it.
49
+ const serverBase = (value, fallback) => {
50
+ const raw = String(value ?? "").trim();
51
+ if (raw === "")
52
+ return withSlash(fallback);
53
+ return withSlash(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
54
+ };
55
+ const qrBase = (env) => withSlash(env.config?.qr ?? env.config?.api ?? QR_BASE);
56
+ const base = (env, fields) => withSlash(fields.baseUrl || env.config?.api || QR_BASE);
57
+ const baseInfo = () => ({ channel_version: "ocean-crew" });
58
+ // iLink identifies the calling client by these two headers on every request.
59
+ const clientHeaders = () => ({
60
+ "iLink-App-Id": ILINK_APP_ID,
61
+ "iLink-App-ClientVersion": String(ILINK_CLIENT_VERSION),
62
+ });
63
+ // X-WECHAT-UIN is base64 of a random uint32. The protocol reference and the
64
+ // reference client both mint a new value per request rather than keeping one.
65
+ const randomUin = () => Buffer.from(String(randomBytes(4).readUInt32BE(0)), "utf8").toString("base64");
66
+ const fail = (reason, opts = {}) => Object.assign(new Error(String(reason)), opts);
67
+ const stoppedError = () => fail("stopped", { stopped: true });
68
+ const refusal = (reason) => fail(reason, { permanent: true });
69
+ const uncertain = (reason, cause) => fail(reason, { unknown: true, ...(cause === undefined ? {} : { cause }) });
70
+ // iLink omits ret on a successful getupdates answer, so the answer itself has to
71
+ // prove it came from iLink: a bad proxy or a stale endpoint can answer 200 with
72
+ // anything, and that must not read as an idle successful poll.
73
+ const isUpdatesAnswer = (answer) => answer !== null &&
74
+ typeof answer === "object" &&
75
+ (Array.isArray(answer.msgs) || typeof answer.get_updates_buf === "string" || typeof answer.sync_buf === "string");
76
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
77
+ // The provider status is `ret` on bot calls and `errcode` on the notification
78
+ // calls; a missing field means success, like iLink's own omissions.
79
+ const providerCode = (answer) => {
80
+ if (!isObject(answer))
81
+ return undefined;
82
+ for (const field of ["ret", "errcode"]) {
83
+ const value = answer[field];
84
+ if (value === undefined || value === null)
85
+ continue;
86
+ const code = Number(value);
87
+ return Number.isFinite(code) ? code : String(value);
88
+ }
89
+ return undefined;
90
+ };
91
+ const rejection = (answer) => {
92
+ const code = providerCode(answer);
93
+ return code === undefined || code === 0 ? undefined : code;
94
+ };
95
+ const isExpired = (code) => Number(code) === SESSION_EXPIRED;
96
+ // A 429 carries the wait in Retry-After, in seconds or as an HTTP date.
97
+ const retryAfterSeconds = (value) => {
98
+ if (value === null || value === undefined || value === "")
99
+ return 1;
100
+ const seconds = Number(value);
101
+ if (Number.isFinite(seconds) && seconds >= 0)
102
+ return seconds;
103
+ const at = Date.parse(String(value));
104
+ return Number.isFinite(at) ? Math.max(0, Math.ceil((at - Date.now()) / 1_000)) : 1;
105
+ };
106
+ const sequence = (value) => {
107
+ const text = typeof value === "string" ? value : Number.isSafeInteger(value) ? String(value) : "";
108
+ return /^\d+$/.test(text) ? BigInt(text).toString() : null;
109
+ };
110
+ const timestamp = (value) => {
111
+ const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
112
+ return typeof number === "number" && Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
113
+ };
114
+ const sha256 = (value) => createHash("sha256")
115
+ .update(String(value ?? ""))
116
+ .digest("hex");
117
+ const backoffMs = (failures) => Math.min(BACKOFF_BASE_MS * 2 ** (failures - 1), BACKOFF_MAX_MS);
118
+ const sleep = (ms, signal) => new Promise((resolve) => {
119
+ if (signal?.aborted === true)
120
+ return resolve();
121
+ const timer = setTimeout(finish, ms);
122
+ function finish() {
123
+ clearTimeout(timer);
124
+ signal?.removeEventListener("abort", finish);
125
+ resolve();
126
+ }
127
+ signal?.addEventListener("abort", finish, { once: true });
128
+ });
129
+ const logins = new Map();
130
+ /** The iLink adapter the host registers; the host passes env with runtime/log/fetch. */
131
+ const weixin = {
132
+ id: "weixin",
133
+ // ---- QR login (im-login-start / im-login-wait drive these) --------------
134
+ async loginStart(env) {
135
+ const response = await env.fetch(`${qrBase(env)}ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`, {
136
+ method: "POST",
137
+ headers: { ...clientHeaders(), "content-type": "application/json" },
138
+ body: JSON.stringify({ local_token_list: [] }),
139
+ signal: AbortSignal.timeout(10_000),
140
+ });
141
+ const body = await response.json().catch(() => ({}));
142
+ if (body.qrcode === undefined) {
143
+ const code = rejection(body);
144
+ if (code !== undefined)
145
+ throw refusal(`WeChat refused the QR code (${body.errmsg ?? code})`);
146
+ throw fail(body.errmsg ?? "WeChat did not return a code");
147
+ }
148
+ const loginId = `wx_${randomBytes(8).toString("hex")}`;
149
+ const expiresAt = Date.now() + LOGIN_LIFETIME_MS;
150
+ logins.set(loginId, { qrcode: String(body.qrcode), baseUrl: qrBase(env), expiresAt });
151
+ for (const [id, held] of logins)
152
+ if (held.expiresAt < Date.now())
153
+ logins.delete(id);
154
+ return { loginId, qr: String(body.qrcode_img_content ?? body.qrcode), expiresAt };
155
+ },
156
+ // The status poll walks the login through waiting → scanned → confirmed. The
157
+ // host has no verification-code input, so the states that ask for one are
158
+ // reported as terminal failures (the settings page shows the error and
159
+ // offers a new code) rather than leaving the poll on "waiting" forever.
160
+ async loginWait(loginId, env) {
161
+ const id = String(loginId);
162
+ const held = logins.get(id);
163
+ if (held === undefined || held.expiresAt < Date.now()) {
164
+ logins.delete(id);
165
+ return { status: "expired" };
166
+ }
167
+ const response = await env
168
+ .fetch(`${held.baseUrl}ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(held.qrcode)}`, {
169
+ headers: clientHeaders(),
170
+ signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
171
+ })
172
+ .catch(() => undefined);
173
+ let body = {};
174
+ try {
175
+ body = await response.json();
176
+ }
177
+ catch {
178
+ body = {};
179
+ }
180
+ if (!isObject(body))
181
+ body = {};
182
+ const status = String(body.status ?? "wait");
183
+ if (status === "expired" || status === "invalid") {
184
+ logins.delete(id);
185
+ return { status: "expired" };
186
+ }
187
+ if (status === "scaned_but_redirect") {
188
+ // The account is served by another iLink host now: keep this login on the
189
+ // host the server named and poll there on the next call.
190
+ held.baseUrl = serverBase(body.redirect_host, held.baseUrl);
191
+ return { status: "scanned" };
192
+ }
193
+ if (status === "scaned")
194
+ return { status: "scanned" };
195
+ if (status === "need_verifycode") {
196
+ logins.delete(id);
197
+ throw refusal("WeChat asks for a verification code; scan a new code to retry");
198
+ }
199
+ if (status === "verify_code_blocked") {
200
+ logins.delete(id);
201
+ throw refusal("WeChat blocked the verification; scan a new code to retry");
202
+ }
203
+ if (status === "binded_redirect") {
204
+ logins.delete(id);
205
+ throw refusal("This WeChat account is already bound; restore that bot instead of scanning again");
206
+ }
207
+ if (status !== "confirmed")
208
+ return { status: "waiting" };
209
+ logins.delete(id);
210
+ const token = String(body.bot_token ?? "").trim();
211
+ const accountId = String(body.ilink_bot_id ?? "").trim();
212
+ if (token === "" || accountId === "")
213
+ throw refusal("WeChat confirmed the scan but returned no bot credentials; scan a new code");
214
+ return {
215
+ status: "done",
216
+ connection: {
217
+ settings: { baseUrl: serverBase(body.baseurl, held.baseUrl) },
218
+ secrets: { token },
219
+ account: { id: accountId, name: "WeChat" },
220
+ },
221
+ owner: { id: String(body.ilink_user_id ?? ""), name: String(body.ilink_user_id ?? "") },
222
+ };
223
+ },
224
+ // The credentials are proven by opening the session with msg/notifystart:
225
+ // unlike a getupdates probe this never consumes a pending message batch.
226
+ // iLink omits ret on success; a non-zero ret or errcode is the platform's
227
+ // answer — -14 means the session is gone and the account needs a new scan.
228
+ async check(fields, env) {
229
+ await notifyStart(env, base(env, fields), fields.token);
230
+ return { account: { id: String(fields.ilinkBotId ?? ""), name: "WeChat" } };
231
+ },
232
+ // -------------------------------------------------------------------------
233
+ connect(fields, env, handlers) {
234
+ const token = String(fields.token ?? "");
235
+ const credential = sha256(token);
236
+ let stopped = false;
237
+ let running = false;
238
+ let generation = 0;
239
+ const lifecycle = new AbortController();
240
+ const wait = typeof env.sleep === "function" ? env.sleep : sleep;
241
+ const runtime = () => env.runtime.get();
242
+ const setRuntime = (patch) => env.runtime.set(patch);
243
+ // ------------------------------------------------------------ seen ids
244
+ // Delivered message ids survive a restart: iLink can re-deliver a batch
245
+ // after a reconnect, and the same message must not reach the Bot twice.
246
+ const seen = new Set(Array.isArray(runtime().seenMessageIds)
247
+ ? runtime().seenMessageIds.filter((id) => typeof id === "string")
248
+ : []);
249
+ while (seen.size > SEEN_LIMIT)
250
+ seen.delete(seen.values().next().value);
251
+ let seenChanged = false;
252
+ const messageKey = (msg) => {
253
+ if (msg?.message_id !== undefined && msg.message_id !== null && String(msg.message_id) !== "")
254
+ return String(msg.message_id);
255
+ if (msg?.client_id !== undefined && msg.client_id !== null && String(msg.client_id) !== "")
256
+ return String(msg.client_id);
257
+ return "";
258
+ };
259
+ const markSeen = (id) => {
260
+ if (id === "" || seen.has(id))
261
+ return;
262
+ seen.add(id);
263
+ while (seen.size > SEEN_LIMIT)
264
+ seen.delete(seen.values().next().value);
265
+ seenChanged = true;
266
+ };
267
+ // ------------------------------------------------------ context tokens
268
+ // Context tokens belong to one bot login: a new bot token invalidates all
269
+ // of them, so the runtime records the token's hash and the map is cleared
270
+ // when the hash changes. Entries are kept in least-recently-used order and
271
+ // replaced only by a message that is not older (seq, then timestamp).
272
+ const contextEntry = (value) => {
273
+ if (typeof value === "string")
274
+ return { token: value, seq: null, messageTimeMs: null, receivedAt: null };
275
+ if (!isObject(value) || typeof value.token !== "string" || value.token === "")
276
+ return undefined;
277
+ return {
278
+ token: value.token,
279
+ seq: sequence(value.seq),
280
+ messageTimeMs: timestamp(value.messageTimeMs),
281
+ receivedAt: timestamp(value.receivedAt),
282
+ };
283
+ };
284
+ const contextUsers = () => {
285
+ const held = runtime().contextTokens;
286
+ if (!isObject(held) || held.credentialHash !== credential || !isObject(held.users))
287
+ return {};
288
+ const users = {};
289
+ for (const [userId, value] of Object.entries(held.users)) {
290
+ const entry = contextEntry(value);
291
+ if (entry !== undefined)
292
+ users[userId] = entry;
293
+ }
294
+ return users;
295
+ };
296
+ const contextTokenFor = (userId) => contextUsers()[userId]?.token;
297
+ const bindContext = () => {
298
+ const held = runtime().contextTokens;
299
+ if (isObject(held) && held.credentialHash === credential)
300
+ return;
301
+ setRuntime({ contextTokens: { credentialHash: credential, users: {} } });
302
+ };
303
+ const rememberContext = (userId, value, msg) => {
304
+ if (userId === "" || typeof value !== "string" || value === "")
305
+ return;
306
+ const users = contextUsers();
307
+ const previous = users[userId];
308
+ const next = {
309
+ token: value,
310
+ seq: sequence(msg?.seq),
311
+ messageTimeMs: timestamp(msg?.create_time_ms ?? msg?.message_time_ms),
312
+ receivedAt: Date.now(),
313
+ };
314
+ // A late/duplicate batch must not replace a newer conversation
315
+ // capability; without either ordering field the newest token wins.
316
+ if (previous !== undefined) {
317
+ if (previous.seq !== null && next.seq !== null && BigInt(next.seq) <= BigInt(previous.seq))
318
+ return;
319
+ if (previous.messageTimeMs !== null &&
320
+ next.messageTimeMs !== null &&
321
+ next.messageTimeMs < previous.messageTimeMs)
322
+ return;
323
+ delete users[userId];
324
+ }
325
+ users[userId] = next;
326
+ const keys = Object.keys(users);
327
+ for (const old of keys.slice(0, Math.max(0, keys.length - CONTEXT_TOKEN_LIMIT)))
328
+ delete users[old];
329
+ setRuntime({ contextTokens: { credentialHash: credential, users } });
330
+ };
331
+ // ------------------------------------------------------------- delivery
332
+ const deliver = async (msg) => {
333
+ const userId = String(msg.from_user_id ?? "");
334
+ if (userId === "")
335
+ return;
336
+ rememberContext(userId, msg.context_token, msg);
337
+ const images = [];
338
+ const files = [];
339
+ const texts = [];
340
+ for (const item of msg.item_list ?? []) {
341
+ if (item.type === 1 && item.text_item?.text !== undefined) {
342
+ texts.push(withQuoted(String(item.text_item.text), item.ref_msg));
343
+ }
344
+ // Voice items carry their transcript in voice_item.text.
345
+ else if (item.type === 3 && item.voice_item?.text !== undefined)
346
+ texts.push(String(item.voice_item.text));
347
+ else if (item.type === 2 && item.image_item?.media !== undefined) {
348
+ const bytes = await image(item.image_item.media, env).catch(() => undefined);
349
+ // WeChat re-encodes what it carries: the declared type has to be the bytes'
350
+ // own type, or the host refuses the message as IMAGE_TYPE_MISMATCH.
351
+ if (bytes !== undefined)
352
+ images.push({ mediaType: imageMediaTypeOf(bytes), data: bytes.toString("base64") });
353
+ }
354
+ else if (item.type === 4 && item.file_item !== undefined) {
355
+ const bytes = await mediaBytes(item.file_item, env).catch(() => undefined);
356
+ files.push({ name: String(item.file_item.file_name ?? "file"), ...(bytes ? { data: bytes } : {}) });
357
+ }
358
+ else if (item.type === 5 && item.video_item !== undefined) {
359
+ const bytes = await mediaBytes(item.video_item, env).catch(() => undefined);
360
+ const name = String(item.video_item.file_name ?? `video-${String(msg.client_id ?? msg.message_id ?? "clip")}.mp4`);
361
+ files.push({ name, ...(bytes ? { data: bytes } : {}) });
362
+ texts.push("[Video]");
363
+ }
364
+ }
365
+ await handlers.onMessage({
366
+ chat: { key: userId, kind: "direct", title: "" },
367
+ sender: { id: userId, name: userId },
368
+ text: texts.join("\n").trim(),
369
+ images,
370
+ files,
371
+ mentioned: false,
372
+ messageId: String(msg.client_id ?? msg.message_id ?? ""),
373
+ });
374
+ };
375
+ // A batch is delivered first and only then marked seen and followed by the
376
+ // cursor: a crash in the middle replays the batch (deduplicated above)
377
+ // instead of skipping the messages that were not delivered yet.
378
+ const deliverBatch = async (messages) => {
379
+ for (const msg of messages) {
380
+ const key = messageKey(msg);
381
+ if (key !== "" && seen.has(key))
382
+ continue;
383
+ try {
384
+ await deliver(msg);
385
+ markSeen(key);
386
+ }
387
+ catch (error) {
388
+ env.log?.warn?.("ocean-crew im: weixin message failed: %s", error?.message ?? error);
389
+ }
390
+ }
391
+ };
392
+ const flushRuntime = (cursor) => {
393
+ const patch = {};
394
+ if (seenChanged) {
395
+ patch.seenMessageIds = [...seen];
396
+ seenChanged = false;
397
+ }
398
+ const current = typeof runtime().cursor === "string" ? runtime().cursor : "";
399
+ if (typeof cursor === "string" && cursor !== current)
400
+ patch.cursor = cursor;
401
+ if (Object.keys(patch).length > 0)
402
+ setRuntime(patch);
403
+ };
404
+ // ------------------------------------------------------------------ poll
405
+ // Long-poll timeout is an idle batch, not a failure. Any other failure is
406
+ // retried with exponential backoff; three in a row stop the connection
407
+ // with an error, and -14 asks for a new QR scan at once.
408
+ const poll = async () => {
409
+ if (running || stopped)
410
+ return;
411
+ running = true;
412
+ const gen = generation;
413
+ let failures = 0;
414
+ try {
415
+ while (!stopped && generation === gen) {
416
+ let answer;
417
+ try {
418
+ answer = await request(env, base(env, fields), token, "getupdates", { get_updates_buf: typeof runtime().cursor === "string" ? runtime().cursor : "" }, POLL_TIMEOUT_MS + POLL_GRACE_MS, { requireJson: true, signal: lifecycle.signal });
419
+ }
420
+ catch (error) {
421
+ if (stopped || generation !== gen || error?.stopped === true)
422
+ return;
423
+ if (error?.timeout === true) {
424
+ handlers.onStatus({ state: "ready" });
425
+ continue;
426
+ }
427
+ failures += 1;
428
+ const message = String(error?.message ?? error);
429
+ env.log?.warn?.("ocean-crew im: weixin poll failed (%d/%d): %s", failures, MAX_CONSECUTIVE_FAILURES, message);
430
+ if (error?.permanent === true || failures >= MAX_CONSECUTIVE_FAILURES) {
431
+ env.log?.warn?.("ocean-crew im: weixin poll stopped: %s", message);
432
+ handlers.onStatus({ state: "error", error: message });
433
+ return;
434
+ }
435
+ handlers.onStatus({ state: "connecting" });
436
+ await wait(backoffMs(failures), lifecycle.signal);
437
+ continue;
438
+ }
439
+ const code = providerCode(answer);
440
+ if (code !== undefined && code !== 0) {
441
+ const expired = isExpired(code);
442
+ const message = expired
443
+ ? "WeChat session expired; scan the QR code again"
444
+ : `WeChat answered ${code}`;
445
+ env.log?.warn?.("ocean-crew im: weixin getupdates answered %s", code);
446
+ if (expired || Number(code) === 401 || Number(code) === 403) {
447
+ handlers.onStatus({ state: "error", error: message });
448
+ return;
449
+ }
450
+ failures += 1;
451
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
452
+ handlers.onStatus({ state: "error", error: message });
453
+ return;
454
+ }
455
+ handlers.onStatus({ state: "connecting" });
456
+ await wait(backoffMs(failures), lifecycle.signal);
457
+ continue;
458
+ }
459
+ if (code === undefined && !isUpdatesAnswer(answer)) {
460
+ failures += 1;
461
+ env.log?.warn?.("ocean-crew im: weixin getupdates answered an unreadable response");
462
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
463
+ handlers.onStatus({ state: "error", error: "WeChat answered an unreadable response" });
464
+ return;
465
+ }
466
+ handlers.onStatus({ state: "connecting" });
467
+ await wait(backoffMs(failures), lifecycle.signal);
468
+ continue;
469
+ }
470
+ failures = 0;
471
+ handlers.onStatus({ state: "ready" });
472
+ await deliverBatch(Array.isArray(answer.msgs) ? answer.msgs : []);
473
+ if (stopped || generation !== gen)
474
+ return;
475
+ flushRuntime(answer.get_updates_buf);
476
+ }
477
+ }
478
+ finally {
479
+ running = false;
480
+ }
481
+ };
482
+ // The session has to be opened before getupdates carries anything. A
483
+ // transient failure is retried; a refused or expired session is shown once
484
+ // and polling never starts, instead of looping on the same error.
485
+ const startSession = async () => {
486
+ for (let attempt = 0;; attempt += 1) {
487
+ if (stopped)
488
+ return;
489
+ try {
490
+ await notifyStart(env, base(env, fields), token, { signal: lifecycle.signal });
491
+ break;
492
+ }
493
+ catch (error) {
494
+ if (stopped || error?.stopped === true)
495
+ return;
496
+ const delay = START_RETRY_DELAYS_MS[attempt];
497
+ if (error?.permanent === true || delay === undefined) {
498
+ env.log?.warn?.("ocean-crew im: weixin could not start the session: %s", error?.message ?? error);
499
+ handlers.onStatus({ state: "error", error: String(error?.message ?? error) });
500
+ return;
501
+ }
502
+ env.log?.warn?.("ocean-crew im: weixin session start failed, retrying: %s", error?.message ?? error);
503
+ await wait(delay, lifecycle.signal);
504
+ }
505
+ }
506
+ if (!stopped)
507
+ void poll();
508
+ };
509
+ bindContext();
510
+ handlers.onStatus({ state: "connecting" });
511
+ void startSession();
512
+ // Every chat the adapter currently shows as typing, so a stop is only sent when
513
+ // there was something to stop.
514
+ const typingShown = new Set();
515
+ const typingTickets = new Map();
516
+ let typingWarned = false;
517
+ const TICKET_TTL_MS = 10 * 60_000;
518
+ const typingTicket = async (chatKey) => {
519
+ const held = typingTickets.get(chatKey);
520
+ if (held !== undefined && Date.now() - held.at < TICKET_TTL_MS)
521
+ return held.ticket;
522
+ const contextToken = contextTokenFor(chatKey);
523
+ const config = await request(env, base(env, fields), token, "getconfig", { ilink_user_id: chatKey, ...(contextToken ? { context_token: contextToken } : {}) }, 8_000, { signal: lifecycle.signal }).catch(() => undefined);
524
+ const ticket = String(config?.typing_ticket ?? "");
525
+ if (ticket !== "")
526
+ typingTickets.set(chatKey, { ticket, at: Date.now() });
527
+ else if (!typingWarned) {
528
+ typingWarned = true;
529
+ env.log?.warn?.("ocean-crew im: weixin sent no typing ticket; the chat will not show typing");
530
+ }
531
+ return ticket;
532
+ };
533
+ const sendTyping = async (chatKey, ticket, status, contextToken) => {
534
+ await request(env, base(env, fields), token, "sendtyping", {
535
+ ilink_user_id: chatKey,
536
+ typing_ticket: ticket,
537
+ status,
538
+ ...(contextToken ? { context_token: contextToken } : {}),
539
+ }, 8_000, { signal: lifecycle.signal }).catch(() => { });
540
+ };
541
+ return {
542
+ send: async (chat, { text, media } = {}) => {
543
+ const chatKey = String(chat.key);
544
+ const chunks = chunk(String(text ?? ""), TEXT_LIMIT);
545
+ const mediaItems = Array.isArray(media) ? media : [];
546
+ if (chunks.length === 0 && mediaItems.length === 0)
547
+ return {};
548
+ const contextToken = contextTokenFor(chatKey);
549
+ let messageId;
550
+ for (const [index, chunkText] of chunks.entries()) {
551
+ // A short pause between bubbles reads as someone typing; back-to-back
552
+ // bubbles read as a paste. The last one sends without waiting.
553
+ if (index > 0)
554
+ await new Promise((resolve) => setTimeout(resolve, BUBBLE_GAP_MS));
555
+ const clientId = `ocean-crew-${randomBytes(8).toString("hex")}`;
556
+ const message = {
557
+ from_user_id: "",
558
+ to_user_id: chatKey,
559
+ client_id: clientId,
560
+ message_type: 2,
561
+ message_state: 2,
562
+ item_list: [{ type: 1, text_item: { text: chunkText } }],
563
+ ...(contextToken ? { context_token: contextToken } : {}),
564
+ };
565
+ for (let attempt = 0;; attempt += 1) {
566
+ try {
567
+ const answer = await request(env, base(env, fields), token, "sendmessage", { msg: message }, SEND_TIMEOUT_MS, {
568
+ send: true,
569
+ signal: lifecycle.signal,
570
+ });
571
+ const code = rejection(answer);
572
+ if (code !== undefined)
573
+ throw refusal(`WeChat refused the reply (${answer.errmsg ?? code})`);
574
+ break;
575
+ }
576
+ catch (error) {
577
+ if (error?.stopped === true)
578
+ throw error;
579
+ const delay = SEND_RETRY_DELAYS_MS[attempt];
580
+ // Only a connection-level failure is certainly not delivered:
581
+ // retry that chunk with the same client_id. Timeouts, unreadable
582
+ // answers and server errors may have reached iLink, so they are
583
+ // reported as unknown and never repeated here.
584
+ if (error?.network === true && delay !== undefined && !stopped) {
585
+ env.log?.warn?.("ocean-crew im: weixin send failed, retrying: %s", error?.message ?? error);
586
+ await wait(delay, lifecycle.signal);
587
+ continue;
588
+ }
589
+ if (error?.network === true)
590
+ throw uncertain(`WeChat did not answer the reply (${error.message})`, error);
591
+ if (error?.timeout === true)
592
+ throw uncertain("WeChat did not confirm the reply in time", error);
593
+ throw error;
594
+ }
595
+ }
596
+ messageId = clientId;
597
+ }
598
+ for (const item of mediaItems) {
599
+ const bytes = Buffer.from(item.data);
600
+ const fileKey = randomBytes(16).toString("hex");
601
+ const aesKey = randomBytes(16);
602
+ const padded = Math.ceil((bytes.length + 1) / 16) * 16;
603
+ const image = item.kind === "image";
604
+ const ext = String(item.name ?? "")
605
+ .split(".")
606
+ .pop()
607
+ ?.toLowerCase() ?? "";
608
+ const voiceCodec = item.kind === "image" || item.voice !== true ? undefined : VOICE_CODECS[ext];
609
+ const uploadType = image ? 1 : voiceCodec === undefined ? 3 : 4;
610
+ const answer = await request(env, base(env, fields), token, "getuploadurl", {
611
+ filekey: fileKey,
612
+ media_type: uploadType,
613
+ to_user_id: chatKey,
614
+ rawsize: bytes.length,
615
+ rawfilemd5: createHash("md5").update(bytes).digest("hex"),
616
+ filesize: padded,
617
+ no_need_thumb: true,
618
+ aeskey: aesKey.toString("hex"),
619
+ }, SEND_TIMEOUT_MS, { signal: lifecycle.signal });
620
+ const refused = rejection(answer);
621
+ if (refused !== undefined)
622
+ throw refusal(`WeChat refused the upload (${answer?.errmsg ?? refused})`);
623
+ const downloadParam = await uploadCdn(env, uploadUrlOf(answer, fileKey), bytes, aesKey);
624
+ const mediaInfo = {
625
+ encrypt_query_param: downloadParam,
626
+ aes_key: Buffer.from(aesKey.toString("hex")).toString("base64"),
627
+ encrypt_type: 1,
628
+ };
629
+ const clientId = `ocean-crew-${randomBytes(8).toString("hex")}`;
630
+ const message = {
631
+ from_user_id: "",
632
+ to_user_id: chatKey,
633
+ client_id: clientId,
634
+ message_type: 2,
635
+ message_state: 2,
636
+ item_list: image
637
+ ? [{ type: 2, image_item: { media: mediaInfo, mid_size: padded } }]
638
+ : voiceCodec !== undefined
639
+ ? [
640
+ {
641
+ type: 3,
642
+ voice_item: {
643
+ media: mediaInfo,
644
+ encode_type: voiceCodec,
645
+ // The bubble needs a length; only an MP3 can be measured
646
+ // here, and an unmeasured one is left to the platform.
647
+ ...(() => {
648
+ const timing = ext === "mp3" ? mp3Timing(bytes) : undefined;
649
+ return timing === undefined
650
+ ? {}
651
+ : {
652
+ sample_rate: timing.sampleRate,
653
+ bits_per_sample: 16,
654
+ playtime: timing.durationMs,
655
+ };
656
+ })(),
657
+ },
658
+ },
659
+ ]
660
+ : [
661
+ {
662
+ type: 4,
663
+ file_item: {
664
+ media: mediaInfo,
665
+ file_name: String(item.name ?? "file"),
666
+ len: String(bytes.length),
667
+ },
668
+ },
669
+ ],
670
+ ...(contextToken ? { context_token: contextToken } : {}),
671
+ };
672
+ try {
673
+ const sent = await request(env, base(env, fields), token, "sendmessage", { msg: message }, SEND_TIMEOUT_MS, {
674
+ send: true,
675
+ signal: lifecycle.signal,
676
+ });
677
+ const code = rejection(sent);
678
+ if (code !== undefined)
679
+ throw refusal(`WeChat refused the ${voiceCodec === undefined ? (image ? "image" : "file") : "voice"} (${sent.errmsg ?? code})`);
680
+ }
681
+ catch (error) {
682
+ if (error?.network === true)
683
+ throw uncertain(`WeChat did not answer the file send (${error.message})`, error);
684
+ if (error?.timeout === true)
685
+ throw uncertain("WeChat did not confirm the file send in time", error);
686
+ throw error;
687
+ }
688
+ messageId = clientId;
689
+ }
690
+ return { messageId };
691
+ },
692
+ // The host ticks this while a turn runs, so the ticket is cached: `getconfig` is a
693
+ // second round trip that would otherwise run on every tick.
694
+ typing: async (chat) => {
695
+ const chatKey = String(chat.key);
696
+ const ticket = await typingTicket(chatKey);
697
+ if (ticket === "")
698
+ return;
699
+ const contextToken = contextTokenFor(chatKey);
700
+ await sendTyping(chatKey, ticket, 1, contextToken);
701
+ typingShown.add(chatKey);
702
+ },
703
+ // A chat that stops being busy stops showing "typing"; iLink also expires it,
704
+ // but saying so is what makes the indicator end when the answer does.
705
+ stopTyping: async (chat) => {
706
+ const chatKey = String(chat.key);
707
+ if (!typingShown.delete(chatKey))
708
+ return;
709
+ const ticket = await typingTicket(chatKey);
710
+ if (ticket === "")
711
+ return;
712
+ await sendTyping(chatKey, ticket, 2, contextTokenFor(chatKey));
713
+ },
714
+ stop: () => {
715
+ stopped = true;
716
+ generation += 1;
717
+ // A long poll must not outlive the adapter: abort it instead of waiting
718
+ // for the server to time out in ~50 s.
719
+ lifecycle.abort(stoppedError());
720
+ },
721
+ };
722
+ },
723
+ };
724
+ // The voice codecs iLink names in a voice item. A media item is sent as a voice message
725
+ // only when its sender asks for one: the WeChat client does not render bot voice bubbles
726
+ // reliably, so the default for spoken audio is a playable file.
727
+ const VOICE_CODECS = { mp3: 7, amr: 5, silk: 6, ogg: 8, opus: 8 };
728
+ // Visible length: a limit counts what the reader sees, so an emoji costs one, not two.
729
+ const width = (text) => [...text].length;
730
+ // A cut in the middle of a fenced block would leave the first bubble with an unclosed
731
+ // fence and the next with a bare body. The cut closes the fence and reopens it with the
732
+ // same language, so each bubble renders on its own.
733
+ const FENCE = /^\s*(?:```|~~~)(.*)$/;
734
+ // The fence written around a cut: a plain ``` line, kept out of a template literal so the
735
+ // source stays readable.
736
+ const CLOSE = "```";
737
+ // Whether any line of a multi-line block opens or closes a fence.
738
+ const FENCE_ANY = /^\s*(?:```|~~~)/m;
739
+ const OPEN = "```";
740
+ const fenceState = (text) => {
741
+ let open = false;
742
+ let lang = "";
743
+ for (const line of text.split("\n")) {
744
+ const found = FENCE.exec(line);
745
+ if (found === null)
746
+ continue;
747
+ if (!open)
748
+ lang = found[1].trim();
749
+ open = !open;
750
+ }
751
+ return { open, lang };
752
+ };
753
+ /**
754
+ * Split one block that is longer than the limit into bubbles that each render alone.
755
+ *
756
+ * A cut inside a fenced block would leave one bubble with an unclosed fence and the next
757
+ * with a bare body, so the cut closes the fence and reopens it with the same language.
758
+ * The state is carried in `inside` and the reopening fence is added when the bubble is
759
+ * emitted — never counted as a fence of the content, which is what would make the
760
+ * synthetic opener read as the closer.
761
+ */
762
+ const cut = (text, limit) => {
763
+ const out = [];
764
+ let rest = text;
765
+ let inside = false;
766
+ let lang = "";
767
+ while (width(rest) > limit) {
768
+ const opening = inside ? `${OPEN}${lang}\n` : "";
769
+ // A bubble may need to close a fence that its own body opens, not only one it
770
+ // entered with, so the four characters are reserved whenever the block has any
771
+ // fence left in it.
772
+ const closing = inside || FENCE_ANY.test(rest) ? 4 : 0;
773
+ const budget = Math.max(1, limit - width(opening) - closing);
774
+ const chars = [...rest];
775
+ const slice = chars.slice(0, budget).join("");
776
+ let at = budget;
777
+ const newline = slice.lastIndexOf("\n");
778
+ if (newline > budget / 2)
779
+ at = width(slice.slice(0, newline));
780
+ else {
781
+ const space = slice.lastIndexOf(" ");
782
+ if (space > budget / 2)
783
+ at = width(slice.slice(0, space));
784
+ }
785
+ const body = chars.slice(0, at).join("");
786
+ rest = chars.slice(at).join("").replace(/^\s+/, "");
787
+ // Only the content's own fences move the state.
788
+ let state = inside;
789
+ for (const line of body.split("\n")) {
790
+ const found = FENCE.exec(line);
791
+ if (found === null)
792
+ continue;
793
+ if (!state)
794
+ lang = found[1].trim();
795
+ state = !state;
796
+ }
797
+ out.push(`${opening}${body}${state ? `\n${CLOSE}` : ""}`);
798
+ inside = state;
799
+ }
800
+ if (rest !== "")
801
+ out.push(`${inside ? `${OPEN}${lang}\n` : ""}${rest}`);
802
+ return out;
803
+ };
804
+ // A short, chat-like block is sent as one bubble per line, the way a person texts,
805
+ // instead of one wall of text. Long prose, headings, lists and tables stay whole so a
806
+ // structure is never torn apart, and a fenced block is never split.
807
+ const CHATTY_MAX = 48;
808
+ const chattyLine = (line) => {
809
+ const text = line.trim();
810
+ if (text === "" || line.startsWith(" ") || line.startsWith("\t"))
811
+ return false;
812
+ if (text.length > CHATTY_MAX)
813
+ return false;
814
+ if (/^[>\-*【#|]/.test(text))
815
+ return false;
816
+ if (/^\*\*[^*]+\*\*$/.test(text))
817
+ return false;
818
+ if (/^\d+\.\s/.test(text))
819
+ return false;
820
+ return true;
821
+ };
822
+ const isHeading = (line) => {
823
+ const text = line.trim();
824
+ return text.endsWith(":") || text.endsWith(":") || (text.length <= 24 && text.startsWith("#"));
825
+ };
826
+ const chatty = (block) => {
827
+ const lines = block.split("\n").filter((line) => line.trim() !== "");
828
+ if (lines.length < 2 || lines.length > 6)
829
+ return false;
830
+ if (isHeading(lines[0]))
831
+ return false;
832
+ return lines.every(chattyLine);
833
+ };
834
+ // Blocks split at blank lines, fences kept whole.
835
+ const blocks = (text) => {
836
+ const out = [];
837
+ let current = [];
838
+ let fenced = false;
839
+ const flush = () => {
840
+ const block = current.join("\n").trim();
841
+ if (block !== "")
842
+ out.push(block);
843
+ current = [];
844
+ };
845
+ for (const raw of text.split("\n")) {
846
+ const line = raw.replace(/\s+$/, "");
847
+ if (/^\s*(?:```|~~~)/.test(line)) {
848
+ if (!fenced)
849
+ flush();
850
+ current.push(line);
851
+ fenced = !fenced;
852
+ if (!fenced)
853
+ flush();
854
+ continue;
855
+ }
856
+ if (fenced) {
857
+ current.push(line);
858
+ continue;
859
+ }
860
+ if (line.trim() === "")
861
+ flush();
862
+ else
863
+ current.push(line);
864
+ }
865
+ flush();
866
+ return out;
867
+ };
868
+ /**
869
+ * One reply as the sequence of chat bubbles WeChat receives. Exported for the test that
870
+ * pins the split: short chat-like lines become separate bubbles, everything else stays
871
+ * whole, and a fenced block is never torn.
872
+ */
873
+ export const chunk = (text, limit) => {
874
+ const whole = String(text ?? "").trimEnd();
875
+ if (whole.trim() === "")
876
+ return [];
877
+ if (width(whole) <= limit) {
878
+ // A short cascade of chat lines becomes a few bubbles; anything else stays one.
879
+ if (!chatty(whole))
880
+ return [whole];
881
+ return whole
882
+ .split("\n")
883
+ .map((line) => line.trim())
884
+ .filter((line) => line !== "");
885
+ }
886
+ const out = [];
887
+ let current = "";
888
+ for (const block of blocks(whole)) {
889
+ const candidate = current === "" ? block : `${current}\n\n${block}`;
890
+ if (candidate.length <= limit) {
891
+ current = candidate;
892
+ continue;
893
+ }
894
+ if (current !== "")
895
+ out.push(current);
896
+ if (block.length <= limit) {
897
+ current = block;
898
+ continue;
899
+ }
900
+ out.push(...cut(block, limit));
901
+ current = "";
902
+ }
903
+ if (current !== "")
904
+ out.push(current);
905
+ return out.filter((piece) => piece !== "");
906
+ };
907
+ // iLink only carries getupdates for an opened session: before msg/notifystart
908
+ // the server keeps answering {"errcode":-14,"errmsg":"session timeout"}.
909
+ async function notifyStart(env, baseUrl, token, { signal } = {}) {
910
+ const answer = await request(env, baseUrl, token, "msg/notifystart", {}, 10_000, { requireJson: true, signal });
911
+ const code = rejection(answer);
912
+ if (code === undefined)
913
+ return answer;
914
+ const reason = isExpired(code)
915
+ ? "WeChat session expired; scan the QR code again"
916
+ : `WeChat did not start the session (${code})`;
917
+ throw refusal(reason);
918
+ }
919
+ async function request(env, baseUrl, token, endpoint, body, timeoutMs = POLL_TIMEOUT_MS, { requireJson = false, send = false, signal } = {}) {
920
+ if (signal?.aborted === true)
921
+ throw stoppedError();
922
+ const controller = new AbortController();
923
+ let timedOut = false;
924
+ const link = () => controller.abort();
925
+ signal?.addEventListener("abort", link, { once: true });
926
+ const timer = setTimeout(() => {
927
+ timedOut = true;
928
+ controller.abort();
929
+ }, timeoutMs);
930
+ let response;
931
+ try {
932
+ response = await env.fetch(`${baseUrl}ilink/bot/${endpoint}`, {
933
+ method: "POST",
934
+ headers: {
935
+ ...clientHeaders(),
936
+ "content-type": "application/json",
937
+ "X-WECHAT-UIN": randomUin(),
938
+ ...(token ? { authorization: `Bearer ${token}`, AuthorizationType: "ilink_bot_token" } : {}),
939
+ },
940
+ body: JSON.stringify({ ...body, base_info: baseInfo() }),
941
+ signal: controller.signal,
942
+ });
943
+ if (signal?.aborted)
944
+ throw stoppedError();
945
+ }
946
+ catch (error) {
947
+ if (signal?.aborted)
948
+ throw stoppedError();
949
+ if (error?.stopped === true)
950
+ throw error;
951
+ // fetch reports "fetch failed" and hides ENOTFOUND, ECONNRESET and the like in
952
+ // cause; surface it in the message and keep the chain for diagnosis.
953
+ const cause = error?.cause?.code ?? error?.cause?.message;
954
+ if (timedOut ||
955
+ error?.name === "AbortError" ||
956
+ error?.name === "TimeoutError" ||
957
+ /timeout|aborted/i.test(String(error?.message))) {
958
+ throw fail("WeChat did not answer in time", { timeout: true, cause: error });
959
+ }
960
+ throw fail(String(cause ?? error?.message ?? error), { network: true, timeout: false, cause: error });
961
+ }
962
+ finally {
963
+ clearTimeout(timer);
964
+ signal?.removeEventListener("abort", link);
965
+ }
966
+ const text = await response.text().catch(() => "");
967
+ if (signal?.aborted)
968
+ throw stoppedError();
969
+ let parsed;
970
+ try {
971
+ parsed = text === "" ? {} : JSON.parse(text);
972
+ }
973
+ catch {
974
+ parsed = undefined;
975
+ }
976
+ const json = isObject(parsed) ? parsed : undefined;
977
+ const code = providerCode(json);
978
+ if (response.status === 429 || code === 429) {
979
+ throw fail("WeChat rate limited the request", {
980
+ retryAfter: retryAfterSeconds(response.headers.get("retry-after")),
981
+ status: response.status,
982
+ });
983
+ }
984
+ if (response.status === 401 || response.status === 403)
985
+ throw refusal("WeChat refused the token");
986
+ if (!response.ok) {
987
+ const error = fail(`WeChat answered ${response.status}`, { status: response.status });
988
+ if (send) {
989
+ if (response.status >= 400 && response.status < 500)
990
+ error.permanent = true;
991
+ else
992
+ error.unknown = true;
993
+ }
994
+ throw error;
995
+ }
996
+ // A 200 that is not a JSON object cannot prove the call succeeded: for a
997
+ // send the outcome is unknown, for a read it is a retryable failure. A caller
998
+ // that reads fields (getupdates, the session handshake) needs a real object;
999
+ // anyone else keeps the old empty answer on a malformed body.
1000
+ if (json === undefined) {
1001
+ if (send)
1002
+ throw uncertain("WeChat answered an unreadable response", undefined);
1003
+ if (requireJson)
1004
+ throw fail("WeChat answered an unreadable response", { status: response.status });
1005
+ return {};
1006
+ }
1007
+ return json;
1008
+ }
1009
+ // Inbound images: the CDN URL plus a base64 AES-128 key (ECB, PKCS7).
1010
+ const CDN_HOST = "novac2c.cdn.weixin.qq.com";
1011
+ const CDN_BASE = `https://${CDN_HOST}/c2c`;
1012
+ const uploadUrlOf = (answer, fileKey) => {
1013
+ const full = String(answer?.upload_full_url ?? "");
1014
+ if (full !== "") {
1015
+ let url;
1016
+ try {
1017
+ url = new URL(full);
1018
+ }
1019
+ catch {
1020
+ throw refusal("WeChat returned an invalid upload address");
1021
+ }
1022
+ if (url.protocol !== "https:" ||
1023
+ url.hostname !== CDN_HOST ||
1024
+ (url.port && url.port !== "443") ||
1025
+ url.pathname !== "/c2c/upload")
1026
+ throw refusal("WeChat returned an untrusted upload address");
1027
+ url.username = "";
1028
+ url.password = "";
1029
+ url.hash = "";
1030
+ return url.toString();
1031
+ }
1032
+ const param = String(answer?.upload_param ?? "");
1033
+ if (param === "")
1034
+ throw refusal("WeChat returned no upload address");
1035
+ const url = new URL(`${CDN_BASE}/upload`);
1036
+ url.searchParams.set("encrypted_query_param", param);
1037
+ url.searchParams.set("filekey", fileKey);
1038
+ return url.toString();
1039
+ };
1040
+ // The body is the AES-128-ECB ciphertext; iLink answers with the parameter a later
1041
+ // message names instead of the file itself.
1042
+ const uploadCdn = async (env, url, bytes, key) => {
1043
+ const cipher = createCipheriv("aes-128-ecb", key, null);
1044
+ const body = Buffer.concat([cipher.update(bytes), cipher.final()]);
1045
+ const response = await env.fetch(url, {
1046
+ method: "POST",
1047
+ headers: { "content-type": "application/octet-stream", "content-length": String(body.length) },
1048
+ body,
1049
+ });
1050
+ if (response?.status === 413)
1051
+ throw refusal("WeChat refused the upload: the file is too large");
1052
+ if (!response?.ok)
1053
+ throw fail(`WeChat refused the upload (${response?.status})`);
1054
+ const param = String(response?.headers?.get?.("x-encrypted-param") ?? "");
1055
+ if (param === "")
1056
+ throw refusal("WeChat returned no download parameter for the upload");
1057
+ return param;
1058
+ };
1059
+ const AES_KEY = /^[0-9a-f]{32}$/i;
1060
+ // The AES key of an attachment: iLink gives it either as a hex field on the item or
1061
+ // base64 inside `media.aes_key` (the pairing of the two encodings the reference client
1062
+ // handles). Anything else is not a key.
1063
+ function attachmentKey(holder) {
1064
+ const direct = String(holder?.aeskey ?? "");
1065
+ if (AES_KEY.test(direct))
1066
+ return Buffer.from(direct, "hex");
1067
+ const media = holder?.media ?? holder;
1068
+ const decoded = Buffer.from(String(media?.aes_key ?? ""), "base64").toString("utf8");
1069
+ if (AES_KEY.test(decoded))
1070
+ return Buffer.from(decoded, "hex");
1071
+ return undefined;
1072
+ }
1073
+ async function mediaBytes(holder, env) {
1074
+ const media = holder?.media ?? holder;
1075
+ const url = String(media?.full_url ?? media?.url ?? "");
1076
+ if (url === "")
1077
+ return undefined;
1078
+ const response = await env.fetch(url);
1079
+ if (!response.ok)
1080
+ return undefined;
1081
+ const encrypted = Buffer.from(await response.arrayBuffer());
1082
+ const key = attachmentKey(holder);
1083
+ if (key === undefined)
1084
+ return encrypted;
1085
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
1086
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
1087
+ }
1088
+ const image = (media, env) => mediaBytes(media, env);
1089
+ // A reply quotes the message it answers. The model needs that context as part of the
1090
+ // text, since the platform carries it as a nested item rather than a field: a quoted
1091
+ // image, video, file or voice is named by its title, a quoted text is inlined.
1092
+ const QUOTED_MEDIA = new Set([2, 3, 4, 5]);
1093
+ const quotedText = (item) => {
1094
+ if (item === undefined || item === null)
1095
+ return "";
1096
+ if (item.type === 1)
1097
+ return String(item.text_item?.text ?? "").trim();
1098
+ if (item.type === 3)
1099
+ return String(item.voice_item?.text ?? "").trim();
1100
+ return "";
1101
+ };
1102
+ const withQuoted = (text, ref) => {
1103
+ const inner = ref?.message_item;
1104
+ if (inner === undefined || inner === null)
1105
+ return text;
1106
+ const title = String(ref?.title ?? "").trim();
1107
+ if (QUOTED_MEDIA.has(Number(inner.type))) {
1108
+ return `${title === "" ? "[引用媒体]" : `[引用媒体: ${title}]`}\n${text}`.trim();
1109
+ }
1110
+ const quoted = quotedText(inner);
1111
+ const parts = [title, quoted].filter((part) => part !== "");
1112
+ if (parts.length === 0)
1113
+ return text;
1114
+ return `[引用: ${parts.join(" | ")}]\n${text}`.trim();
1115
+ };
1116
+ export default weixin;
1117
+ export { withQuoted };